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('&', "&")
575 .replace('<', "<")
576 .replace('>', ">")
577 .replace('"', """)
578 .replace('\'', "'")
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}