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