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('&', "&")
690 .replace('<', "<")
691 .replace('>', ">")
692 .replace('"', """)
693 .replace('\'', "'")
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}