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