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 <h1>{$_('oauth.login.title')}</h1>
319 <p class="subtitle">
320 {#if clientName}
321 {$_('oauth.login.subtitle')} <strong>{clientName}</strong>
322 {:else}
323 {$_('oauth.login.subtitle')}
324 {/if}
325 </p>
326
327 {#if error}
328 <div class="error">{error}</div>
329 {/if}
330
331 <form onsubmit={handleSubmit}>
332 <div class="field">
333 <label for="username">{$_('register.handle')}</label>
334 <input
335 id="username"
336 type="text"
337 bind:value={username}
338 placeholder={$_('register.emailPlaceholder')}
339 disabled={submitting}
340 required
341 autocomplete="username"
342 />
343 </div>
344
345 {#if passkeySupported && username.length >= 3}
346 <button
347 type="button"
348 class="passkey-btn"
349 class:passkey-unavailable={!hasPasskeys || checkingSecurityStatus || !securityStatusChecked}
350 onclick={handlePasskeyLogin}
351 disabled={submitting || !hasPasskeys || !username || checkingSecurityStatus || !securityStatusChecked}
352 title={checkingSecurityStatus ? $_('oauth.login.passkeyHintChecking') : hasPasskeys ? $_('oauth.login.passkeyHintAvailable') : $_('oauth.login.passkeyHintNotAvailable')}
353 >
354 <svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
355 <path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" />
356 <path d="M17 17v4l3-2-3-2z" />
357 <path d="M12 11c-4 0-6 2-6 4v4h9" />
358 </svg>
359 <span class="passkey-text">
360 {#if submitting}
361 {$_('oauth.login.authenticating')}
362 {:else if checkingSecurityStatus || !securityStatusChecked}
363 {$_('oauth.login.checkingPasskey')}
364 {:else if hasPasskeys}
365 {$_('oauth.login.signInWithPasskey')}
366 {:else}
367 {$_('oauth.login.passkeyNotSetUp')}
368 {/if}
369 </span>
370 </button>
371
372 <div class="auth-divider">
373 <span>{$_('oauth.login.orUsePassword')}</span>
374 </div>
375 {/if}
376
377 <div class="field">
378 <label for="password">{$_('oauth.login.password')}</label>
379 <input
380 id="password"
381 type="password"
382 bind:value={password}
383 disabled={submitting}
384 required
385 autocomplete="current-password"
386 />
387 </div>
388
389 <label class="remember-device">
390 <input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
391 <span>{$_('oauth.login.rememberDevice')}</span>
392 </label>
393
394 <div class="actions">
395 <button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}>
396 {$_('common.cancel')}
397 </button>
398 <button type="submit" class="submit-btn" disabled={submitting || !username || !password}>
399 {submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
400 </button>
401 </div>
402 </form>
403
404 <p class="help-links">
405 <a href="#/reset-password">{$_('login.forgotPassword')}</a> · <a href="#/request-passkey-recovery">{$_('login.lostPasskey')}</a>
406 </p>
407</div>
408
409<style>
410 .help-links {
411 text-align: center;
412 margin-top: var(--space-4);
413 font-size: var(--text-sm);
414 }
415
416 .help-links a {
417 color: var(--accent);
418 text-decoration: none;
419 }
420
421 .help-links a:hover {
422 text-decoration: underline;
423 }
424
425 .oauth-login-container {
426 max-width: var(--width-sm);
427 margin: var(--space-9) auto;
428 padding: var(--space-7);
429 }
430
431 h1 {
432 margin: 0 0 var(--space-2) 0;
433 }
434
435 .subtitle {
436 color: var(--text-secondary);
437 margin: 0 0 var(--space-7) 0;
438 }
439
440 form {
441 display: flex;
442 flex-direction: column;
443 gap: var(--space-4);
444 }
445
446 .field {
447 display: flex;
448 flex-direction: column;
449 gap: var(--space-1);
450 }
451
452 label {
453 font-size: var(--text-sm);
454 font-weight: var(--font-medium);
455 }
456
457 input[type="text"],
458 input[type="password"] {
459 padding: var(--space-3);
460 border: 1px solid var(--border-color);
461 border-radius: var(--radius-md);
462 font-size: var(--text-base);
463 background: var(--bg-input);
464 color: var(--text-primary);
465 }
466
467 input:focus {
468 outline: none;
469 border-color: var(--accent);
470 }
471
472 .remember-device {
473 display: flex;
474 align-items: center;
475 gap: var(--space-2);
476 cursor: pointer;
477 color: var(--text-secondary);
478 font-size: var(--text-sm);
479 }
480
481 .remember-device input {
482 width: 16px;
483 height: 16px;
484 }
485
486 .error {
487 padding: var(--space-3);
488 background: var(--error-bg);
489 border: 1px solid var(--error-border);
490 border-radius: var(--radius-md);
491 color: var(--error-text);
492 margin-bottom: var(--space-4);
493 }
494
495 .actions {
496 display: flex;
497 gap: var(--space-4);
498 margin-top: var(--space-2);
499 }
500
501 .actions button {
502 flex: 1;
503 padding: var(--space-3);
504 border: none;
505 border-radius: var(--radius-md);
506 font-size: var(--text-base);
507 cursor: pointer;
508 transition: background-color var(--transition-fast);
509 }
510
511 .actions button:disabled {
512 opacity: 0.6;
513 cursor: not-allowed;
514 }
515
516 .cancel-btn {
517 background: var(--bg-secondary);
518 color: var(--text-primary);
519 border: 1px solid var(--border-color);
520 }
521
522 .cancel-btn:hover:not(:disabled) {
523 background: var(--error-bg);
524 border-color: var(--error-border);
525 color: var(--error-text);
526 }
527
528 .submit-btn {
529 background: var(--accent);
530 color: var(--text-inverse);
531 }
532
533 .submit-btn:hover:not(:disabled) {
534 background: var(--accent-hover);
535 }
536
537 .auth-divider {
538 display: flex;
539 align-items: center;
540 gap: var(--space-4);
541 margin: var(--space-2) 0;
542 }
543
544 .auth-divider::before,
545 .auth-divider::after {
546 content: '';
547 flex: 1;
548 height: 1px;
549 background: var(--border-color);
550 }
551
552 .auth-divider span {
553 color: var(--text-secondary);
554 font-size: var(--text-sm);
555 }
556
557 .passkey-btn {
558 display: flex;
559 align-items: center;
560 justify-content: center;
561 gap: var(--space-2);
562 width: 100%;
563 padding: var(--space-3);
564 background: var(--accent);
565 color: var(--text-inverse);
566 border: 1px solid var(--accent);
567 border-radius: var(--radius-md);
568 font-size: var(--text-base);
569 cursor: pointer;
570 transition: background-color var(--transition-fast), border-color var(--transition-fast), opacity var(--transition-fast);
571 }
572
573 .passkey-btn:hover:not(:disabled) {
574 background: var(--accent-hover);
575 border-color: var(--accent-hover);
576 }
577
578 .passkey-btn:disabled {
579 opacity: 0.6;
580 cursor: not-allowed;
581 }
582
583 .passkey-btn.passkey-unavailable {
584 background: var(--bg-secondary);
585 color: var(--text-secondary);
586 border-color: var(--border-color);
587 }
588
589 .passkey-icon {
590 width: 20px;
591 height: 20px;
592 }
593
594 .passkey-text {
595 flex: 1;
596 text-align: left;
597 }
598</style>