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