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