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