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('&', "&")
602 .replace('<', "<")
603 .replace('>', ">")
604 .replace('"', """)
605 .replace('\'', "'")
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}