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