this repo has no description
1<script lang="ts">
2 import { navigate, routes } from '../lib/router.svelte'
3 import { _ } from '../lib/i18n'
4 import {
5 prepareRequestOptions,
6 serializeAssertionResponse,
7 type WebAuthnRequestOptionsResponse,
8 } from '../lib/webauthn'
9
10 let delegatedDid = $state<string | null>(null)
11 let delegatedHandle = $state<string | null>(null)
12 let controllerIdentifier = $state('')
13 let controllerDid = $state<string | null>(null)
14 let password = $state('')
15 let rememberDevice = $state(false)
16 let submitting = $state(false)
17 let loading = $state(true)
18 let error = $state<string | null>(null)
19 let hasPasskeys = $state(false)
20 let hasTotp = $state(false)
21 let passkeySupported = $state(false)
22 let step = $state<'identifier' | 'password'>('identifier')
23
24 $effect(() => {
25 passkeySupported = window.PublicKeyCredential !== undefined
26 })
27
28 function getRequestUri(): string | null {
29 const params = new URLSearchParams(window.location.search)
30 return params.get('request_uri')
31 }
32
33 function getDelegatedDid(): string | null {
34 const params = new URLSearchParams(window.location.search)
35 return params.get('delegated_did')
36 }
37
38 $effect(() => {
39 loadDelegationInfo()
40 })
41
42 async function loadDelegationInfo() {
43 const requestUri = getRequestUri()
44 delegatedDid = getDelegatedDid()
45
46 if (!requestUri || !delegatedDid) {
47 error = $_('oauthDelegation.missingParams')
48 loading = false
49 return
50 }
51
52 try {
53 const response = await fetch(`/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(delegatedDid.replace('did:', ''))}`)
54 if (response.ok) {
55 const data = await response.json()
56 delegatedHandle = data.handle || delegatedDid
57 } else {
58 const handleResponse = await fetch(`/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(delegatedDid)}`)
59 if (handleResponse.ok) {
60 const data = await handleResponse.json()
61 delegatedHandle = data.handle || delegatedDid
62 } else {
63 delegatedHandle = delegatedDid
64 }
65 }
66 } catch {
67 delegatedHandle = delegatedDid
68 } finally {
69 loading = false
70 }
71 }
72
73 async function handleIdentifierSubmit(e: Event) {
74 e.preventDefault()
75 if (!controllerIdentifier.trim()) return
76
77 submitting = true
78 error = null
79
80 try {
81 let resolvedDid = controllerIdentifier.trim()
82 if (!resolvedDid.startsWith('did:')) {
83 resolvedDid = resolvedDid.replace(/^@/, '')
84 const response = await fetch(`/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(resolvedDid)}`)
85 if (!response.ok) {
86 error = $_('oauthDelegation.controllerNotFound')
87 submitting = false
88 return
89 }
90 const data = await response.json()
91 resolvedDid = data.did
92 }
93
94 controllerDid = resolvedDid
95
96 const securityResponse = await fetch(`/oauth/security-status?identifier=${encodeURIComponent(controllerIdentifier.trim().replace(/^@/, ''))}`)
97 if (securityResponse.ok) {
98 const data = await securityResponse.json()
99 hasPasskeys = passkeySupported && data.hasPasskeys === true
100 hasTotp = data.hasTotp === true
101 }
102
103 step = 'password'
104 } catch {
105 error = $_('oauthDelegation.controllerNotFound')
106 } finally {
107 submitting = false
108 }
109 }
110
111 async function handlePasskeyLogin() {
112 const requestUri = getRequestUri()
113 if (!requestUri || !controllerDid || !delegatedDid) {
114 error = $_('oauthDelegation.missingInfo')
115 return
116 }
117
118 submitting = true
119 error = null
120
121 try {
122 const startResponse = await fetch('/oauth/passkey/start', {
123 method: 'POST',
124 headers: {
125 'Content-Type': 'application/json',
126 'Accept': 'application/json'
127 },
128 body: JSON.stringify({
129 request_uri: requestUri,
130 identifier: controllerIdentifier.trim().replace(/^@/, '')
131 })
132 })
133
134 if (!startResponse.ok) {
135 const data = await startResponse.json()
136 error = data.error_description || data.error || $_('oauthDelegation.failedPasskeyStart')
137 submitting = false
138 return
139 }
140
141 const { options } = await startResponse.json()
142 const publicKeyOptions = prepareRequestOptions(options as WebAuthnRequestOptionsResponse)
143
144 const credential = await navigator.credentials.get({
145 publicKey: publicKeyOptions
146 }) as PublicKeyCredential | null
147
148 if (!credential) {
149 error = $_('oauthDelegation.passkeyCancelled')
150 submitting = false
151 return
152 }
153
154 const credentialData = serializeAssertionResponse(credential)
155
156 const finishResponse = await fetch('/oauth/passkey/finish', {
157 method: 'POST',
158 headers: {
159 'Content-Type': 'application/json',
160 'Accept': 'application/json'
161 },
162 body: JSON.stringify({
163 request_uri: requestUri,
164 identifier: controllerIdentifier.trim().replace(/^@/, ''),
165 credential: credentialData,
166 delegated_did: delegatedDid,
167 controller_did: controllerDid
168 })
169 })
170
171 const data = await finishResponse.json()
172
173 if (!finishResponse.ok || data.success === false || data.error) {
174 error = data.error_description || data.error || $_('oauthDelegation.passkeyFailed')
175 submitting = false
176 return
177 }
178
179 if (data.needs_totp) {
180 navigate(routes.oauthTotp, { params: { request_uri: requestUri } })
181 return
182 }
183
184 if (data.needs_2fa) {
185 navigate(routes.oauth2fa, { params: { request_uri: requestUri, channel: data.channel || '' } })
186 return
187 }
188
189 if (data.redirect_uri) {
190 window.location.href = data.redirect_uri
191 return
192 }
193
194 error = $_('oauthDelegation.unexpectedResponse')
195 submitting = false
196 } catch (e) {
197 console.error('Passkey login error:', e)
198 error = $_('oauthDelegation.authFailed')
199 submitting = false
200 }
201 }
202
203 async function handlePasswordSubmit(e: Event) {
204 e.preventDefault()
205 const requestUri = getRequestUri()
206 if (!requestUri || !controllerDid || !delegatedDid) {
207 error = $_('oauthDelegation.missingInfo')
208 return
209 }
210
211 submitting = true
212 error = null
213
214 try {
215 const response = await fetch('/oauth/delegation/auth', {
216 method: 'POST',
217 headers: {
218 'Content-Type': 'application/json',
219 'Accept': 'application/json'
220 },
221 body: JSON.stringify({
222 request_uri: requestUri,
223 delegated_did: delegatedDid,
224 controller_did: controllerDid,
225 password,
226 remember_device: rememberDevice
227 })
228 })
229
230 const data = await response.json()
231
232 if (!response.ok || data.success === false || data.error) {
233 error = data.error_description || data.error || $_('oauthDelegation.authFailed')
234 submitting = false
235 return
236 }
237
238 if (data.needs_totp) {
239 navigate(routes.oauthTotp, { params: { request_uri: requestUri } })
240 return
241 }
242
243 if (data.needs_2fa) {
244 navigate(routes.oauth2fa, { params: { request_uri: requestUri, channel: data.channel || '' } })
245 return
246 }
247
248 if (data.redirect_uri) {
249 window.location.href = data.redirect_uri
250 return
251 }
252
253 error = $_('oauthDelegation.unexpectedResponse')
254 submitting = false
255 } catch {
256 error = $_('oauthDelegation.authFailed')
257 submitting = false
258 }
259 }
260
261 async function handleCancel() {
262 const requestUri = getRequestUri()
263 if (!requestUri) {
264 window.history.back()
265 return
266 }
267
268 submitting = true
269 try {
270 const response = await fetch('/oauth/authorize/deny', {
271 method: 'POST',
272 headers: {
273 'Content-Type': 'application/json',
274 'Accept': 'application/json'
275 },
276 body: JSON.stringify({ request_uri: requestUri })
277 })
278
279 const data = await response.json()
280 if (data.redirect_uri) {
281 window.location.href = data.redirect_uri
282 }
283 } catch {
284 window.history.back()
285 }
286 }
287
288 function goBack() {
289 step = 'identifier'
290 password = ''
291 error = null
292 }
293</script>
294
295<div class="delegation-container">
296 {#if loading}
297 <div class="loading">
298 <p>{$_('oauthDelegation.loading')}</p>
299 </div>
300 {:else if step === 'identifier'}
301 <header class="page-header">
302 <h1>{$_('oauthDelegation.title')}</h1>
303 <p class="subtitle">
304 {$_('oauthDelegation.isDelegated', { values: { handle: delegatedHandle } })}
305 <br />{$_('oauthDelegation.enterControllerHandle')}
306 </p>
307 </header>
308
309 {#if error}
310 <div class="error">{error}</div>
311 {/if}
312
313 <form onsubmit={handleIdentifierSubmit}>
314 <div class="field">
315 <label for="controller-identifier">{$_('oauthDelegation.controllerHandle')}</label>
316 <input
317 id="controller-identifier"
318 type="text"
319 bind:value={controllerIdentifier}
320 disabled={submitting}
321 required
322 autocomplete="username"
323 placeholder={$_('oauthDelegation.handlePlaceholder')}
324 />
325 </div>
326
327 <div class="actions">
328 <button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}>
329 {$_('common.cancel')}
330 </button>
331 <button type="submit" class="submit-btn" disabled={submitting || !controllerIdentifier.trim()}>
332 {submitting ? $_('oauthDelegation.checking') : $_('common.continue')}
333 </button>
334 </div>
335 </form>
336 {:else if step === 'password'}
337 <header class="page-header">
338 <h1>{$_('oauthDelegation.signInAsController')}</h1>
339 <p class="subtitle">
340 {$_('oauthDelegation.authenticateAs', { values: { controller: '@' + controllerIdentifier.replace(/^@/, ''), delegated: delegatedHandle } })}
341 </p>
342 </header>
343
344 {#if error}
345 <div class="error">{error}</div>
346 {/if}
347
348 <button class="back-link" onclick={goBack} disabled={submitting}>
349 ← {$_('oauthDelegation.useDifferentController')}
350 </button>
351
352 <form onsubmit={handlePasswordSubmit}>
353 {#if passkeySupported && hasPasskeys}
354 <div class="auth-methods">
355 <div class="passkey-method">
356 <h3>{$_('oauthDelegation.signInWithPasskey')}</h3>
357 <button
358 type="button"
359 class="passkey-btn"
360 onclick={handlePasskeyLogin}
361 disabled={submitting}
362 >
363 <svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
364 <path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" />
365 <path d="M17 17v4l3-2-3-2z" />
366 <path d="M12 11c-4 0-6 2-6 4v4h9" />
367 </svg>
368 <span class="passkey-text">
369 {submitting ? $_('oauthDelegation.authenticating') : $_('oauthDelegation.usePasskey')}
370 </span>
371 </button>
372 </div>
373
374 <div class="method-divider">
375 <span>{$_('oauthDelegation.or')}</span>
376 </div>
377
378 <div class="password-method">
379 <h3>{$_('oauthDelegation.password')}</h3>
380 <div class="field">
381 <input
382 type="password"
383 bind:value={password}
384 disabled={submitting}
385 required
386 autocomplete="current-password"
387 placeholder={$_('oauthDelegation.enterPassword')}
388 />
389 </div>
390
391 <label class="remember-device">
392 <input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
393 <span>{$_('oauthDelegation.rememberDevice')}</span>
394 </label>
395
396 <button type="submit" class="submit-btn" disabled={submitting || !password}>
397 {submitting ? $_('oauthDelegation.signingIn') : $_('oauthDelegation.signIn')}
398 </button>
399 </div>
400 </div>
401 {:else}
402 <div class="field">
403 <label for="password">{$_('oauthDelegation.password')}</label>
404 <input
405 id="password"
406 type="password"
407 bind:value={password}
408 disabled={submitting}
409 required
410 autocomplete="current-password"
411 />
412 </div>
413
414 <label class="remember-device">
415 <input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
416 <span>{$_('oauthDelegation.rememberDevice')}</span>
417 </label>
418
419 <div class="actions">
420 <button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}>
421 {$_('common.cancel')}
422 </button>
423 <button type="submit" class="submit-btn" disabled={submitting || !password}>
424 {submitting ? $_('oauthDelegation.signingIn') : $_('oauthDelegation.signIn')}
425 </button>
426 </div>
427 {/if}
428 </form>
429 {:else}
430 <header class="page-header">
431 <h1>{$_('oauthDelegation.title')}</h1>
432 </header>
433 <div class="error">{error || $_('oauthDelegation.unableToLoad')}</div>
434 <div class="actions">
435 <button type="button" class="cancel-btn" onclick={handleCancel}>
436 {$_('oauthDelegation.goBack')}
437 </button>
438 </div>
439 {/if}
440</div>
441
442<style>
443 .delegation-container {
444 max-width: var(--width-md);
445 margin: var(--space-9) auto;
446 padding: var(--space-7);
447 }
448
449 .loading {
450 display: flex;
451 align-items: center;
452 justify-content: center;
453 min-height: 200px;
454 color: var(--text-secondary);
455 }
456
457 .page-header {
458 margin-bottom: var(--space-6);
459 }
460
461 h1 {
462 margin: 0 0 var(--space-2) 0;
463 }
464
465 .subtitle {
466 color: var(--text-secondary);
467 margin: 0;
468 line-height: 1.6;
469 }
470
471 .back-link {
472 display: inline-flex;
473 align-items: center;
474 padding: var(--space-2) 0;
475 background: none;
476 border: none;
477 color: var(--accent);
478 font-size: var(--text-sm);
479 cursor: pointer;
480 margin-bottom: var(--space-4);
481 }
482
483 .back-link:hover:not(:disabled) {
484 text-decoration: underline;
485 }
486
487 .back-link:disabled {
488 opacity: 0.6;
489 cursor: not-allowed;
490 }
491
492 form {
493 display: flex;
494 flex-direction: column;
495 gap: var(--space-4);
496 }
497
498 .auth-methods {
499 display: grid;
500 grid-template-columns: 1fr;
501 gap: var(--space-5);
502 margin-top: var(--space-4);
503 }
504
505 @media (min-width: 600px) {
506 .auth-methods {
507 grid-template-columns: 1fr auto 1fr;
508 align-items: start;
509 }
510 }
511
512 .passkey-method,
513 .password-method {
514 display: flex;
515 flex-direction: column;
516 gap: var(--space-4);
517 padding: var(--space-5);
518 background: var(--bg-secondary);
519 border-radius: var(--radius-xl);
520 }
521
522 .passkey-method h3,
523 .password-method h3 {
524 margin: 0;
525 font-size: var(--text-sm);
526 font-weight: var(--font-semibold);
527 color: var(--text-secondary);
528 text-transform: uppercase;
529 letter-spacing: 0.05em;
530 }
531
532 .method-divider {
533 display: flex;
534 align-items: center;
535 justify-content: center;
536 color: var(--text-muted);
537 font-size: var(--text-sm);
538 }
539
540 @media (min-width: 600px) {
541 .method-divider {
542 flex-direction: column;
543 padding: 0 var(--space-3);
544 }
545
546 .method-divider::before,
547 .method-divider::after {
548 content: '';
549 width: 1px;
550 height: var(--space-6);
551 background: var(--border-color);
552 }
553
554 .method-divider span {
555 writing-mode: vertical-rl;
556 text-orientation: mixed;
557 transform: rotate(180deg);
558 padding: var(--space-2) 0;
559 }
560 }
561
562 @media (max-width: 599px) {
563 .method-divider {
564 gap: var(--space-4);
565 }
566
567 .method-divider::before,
568 .method-divider::after {
569 content: '';
570 flex: 1;
571 height: 1px;
572 background: var(--border-color);
573 }
574 }
575
576 .field {
577 display: flex;
578 flex-direction: column;
579 gap: var(--space-1);
580 }
581
582 label {
583 font-size: var(--text-sm);
584 font-weight: var(--font-medium);
585 }
586
587 input[type="password"],
588 input[type="text"] {
589 padding: var(--space-3);
590 border: 1px solid var(--border-color);
591 border-radius: var(--radius-md);
592 font-size: var(--text-base);
593 background: var(--bg-input);
594 color: var(--text-primary);
595 }
596
597 input:focus {
598 outline: none;
599 border-color: var(--accent);
600 }
601
602 .remember-device {
603 display: flex;
604 align-items: center;
605 gap: var(--space-2);
606 cursor: pointer;
607 color: var(--text-secondary);
608 font-size: var(--text-sm);
609 }
610
611 .remember-device input {
612 width: 16px;
613 height: 16px;
614 }
615
616 .error {
617 padding: var(--space-3);
618 background: var(--error-bg);
619 border: 1px solid var(--error-border);
620 border-radius: var(--radius-md);
621 color: var(--error-text);
622 margin-bottom: var(--space-4);
623 }
624
625 .actions {
626 display: flex;
627 gap: var(--space-4);
628 margin-top: var(--space-2);
629 }
630
631 .actions button {
632 flex: 1;
633 padding: var(--space-3);
634 border: none;
635 border-radius: var(--radius-md);
636 font-size: var(--text-base);
637 cursor: pointer;
638 transition: background-color var(--transition-fast);
639 }
640
641 .actions button:disabled {
642 opacity: 0.6;
643 cursor: not-allowed;
644 }
645
646 .cancel-btn {
647 background: var(--bg-secondary);
648 color: var(--text-primary);
649 border: 1px solid var(--border-color);
650 }
651
652 .cancel-btn:hover:not(:disabled) {
653 background: var(--error-bg);
654 border-color: var(--error-border);
655 color: var(--error-text);
656 }
657
658 .submit-btn {
659 background: var(--accent);
660 color: var(--text-inverse);
661 }
662
663 .submit-btn:hover:not(:disabled) {
664 background: var(--accent-hover);
665 }
666
667 .passkey-btn {
668 display: flex;
669 align-items: center;
670 justify-content: center;
671 gap: var(--space-2);
672 width: 100%;
673 padding: var(--space-3);
674 background: var(--accent);
675 color: var(--text-inverse);
676 border: 1px solid var(--accent);
677 border-radius: var(--radius-md);
678 font-size: var(--text-base);
679 cursor: pointer;
680 transition: background-color var(--transition-fast), border-color var(--transition-fast);
681 }
682
683 .passkey-btn:hover:not(:disabled) {
684 background: var(--accent-hover);
685 border-color: var(--accent-hover);
686 }
687
688 .passkey-btn:disabled {
689 opacity: 0.6;
690 cursor: not-allowed;
691 }
692
693 .passkey-icon {
694 width: 20px;
695 height: 20px;
696 }
697
698 .passkey-text {
699 flex: 1;
700 text-align: left;
701 }
702</style>