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