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('&', "&")
684 .replace('<', "<")
685 .replace('>', ">")
686 .replace('"', """)
687 .replace('\'', "'")
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}