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