this repo has no description
1use chrono::{DateTime, Utc}; 2 3fn base_styles() -> &'static str { 4 r#" 5 :root { 6 --primary: #0085ff; 7 --primary-hover: #0077e6; 8 --primary-contrast: #ffffff; 9 --primary-100: #dbeafe; 10 --primary-400: #60a5fa; 11 --primary-600-30: rgba(37, 99, 235, 0.3); 12 --contrast-0: #ffffff; 13 --contrast-25: #f8f9fa; 14 --contrast-50: #f1f3f5; 15 --contrast-100: #e9ecef; 16 --contrast-200: #dee2e6; 17 --contrast-300: #ced4da; 18 --contrast-400: #adb5bd; 19 --contrast-500: #6b7280; 20 --contrast-600: #4b5563; 21 --contrast-700: #374151; 22 --contrast-800: #1f2937; 23 --contrast-900: #111827; 24 --error: #dc2626; 25 --error-bg: #fef2f2; 26 --success: #059669; 27 --success-bg: #ecfdf5; 28 } 29 @media (prefers-color-scheme: dark) { 30 :root { 31 --contrast-0: #111827; 32 --contrast-25: #1f2937; 33 --contrast-50: #374151; 34 --contrast-100: #4b5563; 35 --contrast-200: #6b7280; 36 --contrast-300: #9ca3af; 37 --contrast-400: #d1d5db; 38 --contrast-500: #e5e7eb; 39 --contrast-600: #f3f4f6; 40 --contrast-700: #f9fafb; 41 --contrast-800: #ffffff; 42 --contrast-900: #ffffff; 43 --error-bg: #451a1a; 44 --success-bg: #064e3b; 45 } 46 } 47 * { 48 box-sizing: border-box; 49 margin: 0; 50 padding: 0; 51 } 52 body { 53 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; 54 background: var(--contrast-50); 55 color: var(--contrast-900); 56 min-height: 100vh; 57 display: flex; 58 align-items: center; 59 justify-content: center; 60 padding: 1rem; 61 line-height: 1.5; 62 } 63 .container { 64 width: 100%; 65 max-width: 400px; 66 } 67 .card { 68 background: var(--contrast-0); 69 border: 1px solid var(--contrast-100); 70 border-radius: 0.75rem; 71 padding: 1.5rem; 72 box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); 73 } 74 @media (prefers-color-scheme: dark) { 75 .card { 76 box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.4), 0 8px 10px -6px rgba(0, 0, 0, 0.3); 77 } 78 } 79 h1 { 80 font-size: 1.5rem; 81 font-weight: 600; 82 color: var(--contrast-900); 83 margin-bottom: 0.5rem; 84 } 85 .subtitle { 86 color: var(--contrast-500); 87 font-size: 0.875rem; 88 margin-bottom: 1.5rem; 89 } 90 .subtitle strong { 91 color: var(--contrast-700); 92 } 93 .client-info { 94 background: var(--contrast-25); 95 border-radius: 0.5rem; 96 padding: 1rem; 97 margin-bottom: 1.5rem; 98 } 99 .client-info .client-name { 100 font-weight: 500; 101 color: var(--contrast-900); 102 display: block; 103 margin-bottom: 0.25rem; 104 } 105 .client-info .scope { 106 color: var(--contrast-500); 107 font-size: 0.875rem; 108 } 109 .error-banner { 110 background: var(--error-bg); 111 color: var(--error); 112 border-radius: 0.5rem; 113 padding: 0.75rem 1rem; 114 margin-bottom: 1rem; 115 font-size: 0.875rem; 116 } 117 .form-group { 118 margin-bottom: 1.25rem; 119 } 120 label { 121 display: block; 122 font-size: 0.875rem; 123 font-weight: 500; 124 color: var(--contrast-700); 125 margin-bottom: 0.375rem; 126 } 127 input[type="text"], 128 input[type="email"], 129 input[type="password"] { 130 width: 100%; 131 padding: 0.625rem 0.875rem; 132 border: 2px solid var(--contrast-200); 133 border-radius: 0.375rem; 134 font-size: 1rem; 135 color: var(--contrast-900); 136 background: var(--contrast-0); 137 transition: border-color 0.15s, box-shadow 0.15s; 138 } 139 input[type="text"]:focus, 140 input[type="email"]:focus, 141 input[type="password"]:focus { 142 outline: none; 143 border-color: var(--primary); 144 box-shadow: 0 0 0 3px var(--primary-600-30); 145 } 146 input[type="text"]::placeholder, 147 input[type="email"]::placeholder, 148 input[type="password"]::placeholder { 149 color: var(--contrast-400); 150 } 151 .checkbox-group { 152 display: flex; 153 align-items: center; 154 gap: 0.5rem; 155 margin-bottom: 1.5rem; 156 } 157 .checkbox-group input[type="checkbox"] { 158 width: 1.125rem; 159 height: 1.125rem; 160 accent-color: var(--primary); 161 } 162 .checkbox-group label { 163 margin-bottom: 0; 164 font-weight: normal; 165 color: var(--contrast-600); 166 cursor: pointer; 167 } 168 .buttons { 169 display: flex; 170 gap: 0.75rem; 171 } 172 .btn { 173 flex: 1; 174 padding: 0.625rem 1.25rem; 175 border-radius: 0.375rem; 176 font-size: 1rem; 177 font-weight: 500; 178 cursor: pointer; 179 transition: background-color 0.15s, transform 0.1s; 180 border: none; 181 text-align: center; 182 text-decoration: none; 183 display: inline-flex; 184 align-items: center; 185 justify-content: center; 186 } 187 .btn:active { 188 transform: scale(0.98); 189 } 190 .btn-primary { 191 background: var(--primary); 192 color: var(--primary-contrast); 193 } 194 .btn-primary:hover { 195 background: var(--primary-hover); 196 } 197 .btn-primary:disabled { 198 background: var(--primary-400); 199 cursor: not-allowed; 200 } 201 .btn-secondary { 202 background: var(--contrast-200); 203 color: var(--contrast-800); 204 } 205 .btn-secondary:hover { 206 background: var(--contrast-300); 207 } 208 .footer { 209 text-align: center; 210 margin-top: 1.5rem; 211 font-size: 0.75rem; 212 color: var(--contrast-400); 213 } 214 .accounts { 215 display: flex; 216 flex-direction: column; 217 gap: 0.5rem; 218 margin-bottom: 1rem; 219 } 220 .account-item { 221 display: flex; 222 align-items: center; 223 gap: 0.75rem; 224 width: 100%; 225 padding: 0.75rem; 226 background: var(--contrast-25); 227 border: 1px solid var(--contrast-100); 228 border-radius: 0.5rem; 229 cursor: pointer; 230 transition: background-color 0.15s, border-color 0.15s; 231 text-align: left; 232 } 233 .account-item:hover { 234 background: var(--contrast-50); 235 border-color: var(--contrast-200); 236 } 237 .avatar { 238 width: 2.5rem; 239 height: 2.5rem; 240 border-radius: 50%; 241 background: var(--primary); 242 color: var(--primary-contrast); 243 display: flex; 244 align-items: center; 245 justify-content: center; 246 font-weight: 600; 247 font-size: 0.875rem; 248 flex-shrink: 0; 249 } 250 .account-info { 251 flex: 1; 252 min-width: 0; 253 } 254 .account-info .handle { 255 display: block; 256 font-weight: 500; 257 color: var(--contrast-900); 258 overflow: hidden; 259 text-overflow: ellipsis; 260 white-space: nowrap; 261 } 262 .account-info .email { 263 display: block; 264 font-size: 0.875rem; 265 color: var(--contrast-500); 266 overflow: hidden; 267 text-overflow: ellipsis; 268 white-space: nowrap; 269 } 270 .chevron { 271 color: var(--contrast-400); 272 font-size: 1.25rem; 273 flex-shrink: 0; 274 } 275 .divider { 276 height: 1px; 277 background: var(--contrast-100); 278 margin: 1rem 0; 279 } 280 .link-button { 281 background: none; 282 border: none; 283 color: var(--primary); 284 cursor: pointer; 285 font-size: inherit; 286 padding: 0; 287 text-decoration: underline; 288 } 289 .link-button:hover { 290 color: var(--primary-hover); 291 } 292 .new-account-link { 293 display: block; 294 text-align: center; 295 color: var(--primary); 296 text-decoration: none; 297 font-size: 0.875rem; 298 } 299 .new-account-link:hover { 300 text-decoration: underline; 301 } 302 .help-text { 303 text-align: center; 304 margin-top: 1rem; 305 font-size: 0.875rem; 306 color: var(--contrast-500); 307 } 308 .icon { 309 font-size: 3rem; 310 margin-bottom: 1rem; 311 } 312 .error-code { 313 background: var(--error-bg); 314 color: var(--error); 315 padding: 0.5rem 1rem; 316 border-radius: 0.375rem; 317 font-family: monospace; 318 display: inline-block; 319 margin-bottom: 1rem; 320 } 321 .success-icon { 322 width: 3rem; 323 height: 3rem; 324 border-radius: 50%; 325 background: var(--success-bg); 326 color: var(--success); 327 display: flex; 328 align-items: center; 329 justify-content: center; 330 font-size: 1.5rem; 331 margin: 0 auto 1rem; 332 } 333 .text-center { 334 text-align: center; 335 } 336 .code-input { 337 letter-spacing: 0.5em; 338 text-align: center; 339 font-size: 1.5rem; 340 font-family: monospace; 341 } 342 "# 343} 344 345pub fn login_page( 346 client_id: &str, 347 client_name: Option<&str>, 348 scope: Option<&str>, 349 request_uri: &str, 350 error_message: Option<&str>, 351 login_hint: Option<&str>, 352) -> String { 353 let client_display = client_name.unwrap_or(client_id); 354 let scope_display = scope.unwrap_or("access your account"); 355 let error_html = error_message 356 .map(|msg| format!(r#"<div class="error-banner">{}</div>"#, html_escape(msg))) 357 .unwrap_or_default(); 358 let login_hint_value = login_hint.unwrap_or(""); 359 format!( 360 r#"<!DOCTYPE html> 361<html lang="en"> 362<head> 363 <meta charset="UTF-8"> 364 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 365 <meta name="robots" content="noindex"> 366 <title>Sign in</title> 367 <style>{styles}</style> 368</head> 369<body> 370 <div class="container"> 371 <div class="card"> 372 <h1>Sign in</h1> 373 <p class="subtitle">to continue to <strong>{client_display}</strong></p> 374 <div class="client-info"> 375 <span class="client-name">{client_display}</span> 376 <span class="scope">wants to {scope_display}</span> 377 </div> 378 {error_html} 379 <form method="POST" action="/oauth/authorize"> 380 <input type="hidden" name="request_uri" value="{request_uri}"> 381 <div class="form-group"> 382 <label for="username">Handle or Email</label> 383 <input type="text" id="username" name="username" value="{login_hint_value}" 384 required autocomplete="username" autofocus 385 placeholder="you@example.com"> 386 </div> 387 <div class="form-group"> 388 <label for="password">Password</label> 389 <input type="password" id="password" name="password" required 390 autocomplete="current-password" placeholder="Enter your password"> 391 </div> 392 <div class="checkbox-group"> 393 <input type="checkbox" id="remember_device" name="remember_device" value="true"> 394 <label for="remember_device">Remember this device</label> 395 </div> 396 <div class="buttons"> 397 <button type="submit" class="btn btn-primary">Sign in</button> 398 <button type="submit" formaction="/oauth/authorize/deny" class="btn btn-secondary">Cancel</button> 399 </div> 400 </form> 401 <div class="footer"> 402 By signing in, you agree to share your account information with this application. 403 </div> 404 </div> 405 </div> 406</body> 407</html>"#, 408 styles = base_styles(), 409 client_display = html_escape(client_display), 410 scope_display = html_escape(scope_display), 411 request_uri = html_escape(request_uri), 412 error_html = error_html, 413 login_hint_value = html_escape(login_hint_value), 414 ) 415} 416 417pub struct DeviceAccount { 418 pub did: String, 419 pub handle: String, 420 pub email: Option<String>, 421 pub last_used_at: DateTime<Utc>, 422} 423 424pub fn account_selector_page( 425 client_id: &str, 426 client_name: Option<&str>, 427 request_uri: &str, 428 accounts: &[DeviceAccount], 429) -> String { 430 let client_display = client_name.unwrap_or(client_id); 431 let accounts_html: String = accounts 432 .iter() 433 .map(|account| { 434 let initials = get_initials(&account.handle); 435 let email_display = account.email.as_deref().unwrap_or(""); 436 format!( 437 r#"<form method="POST" action="/oauth/authorize/select" style="margin:0"> 438 <input type="hidden" name="request_uri" value="{request_uri}"> 439 <input type="hidden" name="did" value="{did}"> 440 <button type="submit" class="account-item"> 441 <div class="avatar">{initials}</div> 442 <div class="account-info"> 443 <span class="handle">@{handle}</span> 444 <span class="email">{email}</span> 445 </div> 446 <span class="chevron">›</span> 447 </button> 448 </form>"#, 449 request_uri = html_escape(request_uri), 450 did = html_escape(&account.did), 451 initials = html_escape(&initials), 452 handle = html_escape(&account.handle), 453 email = html_escape(email_display), 454 ) 455 }) 456 .collect(); 457 format!( 458 r#"<!DOCTYPE html> 459<html lang="en"> 460<head> 461 <meta charset="UTF-8"> 462 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 463 <meta name="robots" content="noindex"> 464 <title>Choose an account</title> 465 <style>{styles}</style> 466</head> 467<body> 468 <div class="container"> 469 <div class="card"> 470 <h1>Choose an account</h1> 471 <p class="subtitle">to continue to <strong>{client_display}</strong></p> 472 <div class="accounts"> 473 {accounts_html} 474 </div> 475 <div class="divider"></div> 476 <a href="/oauth/authorize?request_uri={request_uri_encoded}&new_account=true" class="new-account-link"> 477 Sign in with another account 478 </a> 479 </div> 480 </div> 481</body> 482</html>"#, 483 styles = base_styles(), 484 client_display = html_escape(client_display), 485 accounts_html = accounts_html, 486 request_uri_encoded = urlencoding::encode(request_uri), 487 ) 488} 489 490pub fn two_factor_page(request_uri: &str, channel: &str, error_message: Option<&str>) -> String { 491 let error_html = error_message 492 .map(|msg| format!(r#"<div class="error-banner">{}</div>"#, html_escape(msg))) 493 .unwrap_or_default(); 494 let (title, subtitle) = match channel { 495 "email" => ( 496 "Check your email", 497 "We sent a verification code to your email", 498 ), 499 "Discord" => ( 500 "Check Discord", 501 "We sent a verification code to your Discord", 502 ), 503 "Telegram" => ( 504 "Check Telegram", 505 "We sent a verification code to your Telegram", 506 ), 507 "Signal" => ("Check Signal", "We sent a verification code to your Signal"), 508 _ => ("Check your messages", "We sent you a verification code"), 509 }; 510 format!( 511 r#"<!DOCTYPE html> 512<html lang="en"> 513<head> 514 <meta charset="UTF-8"> 515 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 516 <meta name="robots" content="noindex"> 517 <title>Verify your identity</title> 518 <style>{styles}</style> 519</head> 520<body> 521 <div class="container"> 522 <div class="card"> 523 <h1>{title}</h1> 524 <p class="subtitle">{subtitle}</p> 525 {error_html} 526 <form method="POST" action="/oauth/authorize/2fa"> 527 <input type="hidden" name="request_uri" value="{request_uri}"> 528 <div class="form-group"> 529 <label for="code">Verification code</label> 530 <input type="text" id="code" name="code" class="code-input" 531 placeholder="000000" 532 pattern="[0-9]{{6}}" maxlength="6" 533 inputmode="numeric" autocomplete="one-time-code" 534 autofocus required> 535 </div> 536 <button type="submit" class="btn btn-primary" style="width:100%">Verify</button> 537 </form> 538 <p class="help-text"> 539 Code expires in 10 minutes. 540 </p> 541 </div> 542 </div> 543</body> 544</html>"#, 545 styles = base_styles(), 546 title = title, 547 subtitle = subtitle, 548 request_uri = html_escape(request_uri), 549 error_html = error_html, 550 ) 551} 552 553pub fn error_page(error: &str, error_description: Option<&str>) -> String { 554 let description = 555 error_description.unwrap_or("An error occurred during the authorization process."); 556 format!( 557 r#"<!DOCTYPE html> 558<html lang="en"> 559<head> 560 <meta charset="UTF-8"> 561 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 562 <meta name="robots" content="noindex"> 563 <title>Authorization Error</title> 564 <style>{styles}</style> 565</head> 566<body> 567 <div class="container"> 568 <div class="card text-center"> 569 <div class="icon">⚠️</div> 570 <h1>Authorization Failed</h1> 571 <div class="error-code">{error}</div> 572 <p class="subtitle" style="margin-bottom:0">{description}</p> 573 <div style="margin-top:1.5rem"> 574 <button onclick="window.close()" class="btn btn-secondary">Close this window</button> 575 </div> 576 </div> 577 </div> 578</body> 579</html>"#, 580 styles = base_styles(), 581 error = html_escape(error), 582 description = html_escape(description), 583 ) 584} 585 586pub fn success_page(client_name: Option<&str>) -> String { 587 let client_display = client_name.unwrap_or("The application"); 588 format!( 589 r#"<!DOCTYPE html> 590<html lang="en"> 591<head> 592 <meta charset="UTF-8"> 593 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 594 <meta name="robots" content="noindex"> 595 <title>Authorization Successful</title> 596 <style>{styles}</style> 597</head> 598<body> 599 <div class="container"> 600 <div class="card text-center"> 601 <div class="success-icon">✓</div> 602 <h1 style="color:var(--success)">Authorization Successful</h1> 603 <p class="subtitle">{client_display} has been granted access to your account.</p> 604 <p class="help-text">You can close this window and return to the application.</p> 605 </div> 606 </div> 607</body> 608</html>"#, 609 styles = base_styles(), 610 client_display = html_escape(client_display), 611 ) 612} 613 614fn html_escape(s: &str) -> String { 615 s.replace('&', "&amp;") 616 .replace('<', "&lt;") 617 .replace('>', "&gt;") 618 .replace('"', "&quot;") 619 .replace('\'', "&#39;") 620} 621 622fn get_initials(handle: &str) -> String { 623 let clean = handle.trim_start_matches('@'); 624 if clean.is_empty() { 625 return "?".to_string(); 626 } 627 clean 628 .chars() 629 .next() 630 .unwrap_or('?') 631 .to_uppercase() 632 .to_string() 633} 634 635pub fn mask_email(email: &str) -> String { 636 if let Some(at_pos) = email.find('@') { 637 let local = &email[..at_pos]; 638 let domain = &email[at_pos..]; 639 if local.len() <= 2 { 640 format!("{}***{}", local.chars().next().unwrap_or('*'), domain) 641 } else { 642 let first = local.chars().next().unwrap_or('*'); 643 let last = local.chars().last().unwrap_or('*'); 644 format!("{}***{}{}", first, last, domain) 645 } 646 } else { 647 "***".to_string() 648 } 649}