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