this repo has no description
1<script lang="ts">
2 import { navigate } from '../lib/router.svelte'
3
4 let username = $state('')
5 let password = $state('')
6 let rememberDevice = $state(false)
7 let submitting = $state(false)
8 let error = $state<string | null>(null)
9 let hasPasskeys = $state(false)
10 let hasTotp = $state(false)
11 let checkingSecurityStatus = $state(false)
12 let securityStatusChecked = $state(false)
13 let passkeySupported = $state(false)
14 let clientName = $state<string | null>(null)
15
16 $effect(() => {
17 passkeySupported = window.PublicKeyCredential !== undefined
18 })
19
20 function getRequestUri(): string | null {
21 const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
22 return params.get('request_uri')
23 }
24
25 function getErrorFromUrl(): string | null {
26 const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
27 return params.get('error')
28 }
29
30 $effect(() => {
31 const urlError = getErrorFromUrl()
32 if (urlError) {
33 error = urlError
34 }
35 })
36
37 $effect(() => {
38 fetchAuthRequestInfo()
39 })
40
41 async function fetchAuthRequestInfo() {
42 const requestUri = getRequestUri()
43 if (!requestUri) return
44
45 try {
46 const response = await fetch(`/oauth/authorize?request_uri=${encodeURIComponent(requestUri)}`, {
47 headers: { 'Accept': 'application/json' }
48 })
49 if (response.ok) {
50 const data = await response.json()
51 if (data.login_hint && !username) {
52 username = data.login_hint
53 }
54 if (data.client_name) {
55 clientName = data.client_name
56 }
57 }
58 } catch {
59 // Ignore errors fetching auth info
60 }
61 }
62
63 let checkTimeout: ReturnType<typeof setTimeout> | null = null
64
65 $effect(() => {
66 if (checkTimeout) {
67 clearTimeout(checkTimeout)
68 }
69 hasPasskeys = false
70 hasTotp = false
71 securityStatusChecked = false
72 if (username.length >= 3) {
73 checkTimeout = setTimeout(() => checkUserSecurityStatus(), 500)
74 }
75 })
76
77 async function checkUserSecurityStatus() {
78 if (!username || checkingSecurityStatus) return
79 checkingSecurityStatus = true
80 try {
81 const response = await fetch(`/oauth/security-status?identifier=${encodeURIComponent(username)}`)
82 if (response.ok) {
83 const data = await response.json()
84 hasPasskeys = passkeySupported && data.hasPasskeys === true
85 hasTotp = data.hasTotp === true
86 securityStatusChecked = true
87 }
88 } catch {
89 hasPasskeys = false
90 hasTotp = false
91 } finally {
92 checkingSecurityStatus = false
93 }
94 }
95
96
97 async function handlePasskeyLogin() {
98 const requestUri = getRequestUri()
99 if (!requestUri || !username) {
100 error = 'Missing required parameters'
101 return
102 }
103
104 submitting = true
105 error = null
106
107 try {
108 const startResponse = await fetch('/oauth/passkey/start', {
109 method: 'POST',
110 headers: {
111 'Content-Type': 'application/json',
112 'Accept': 'application/json'
113 },
114 body: JSON.stringify({
115 request_uri: requestUri,
116 identifier: username
117 })
118 })
119
120 if (!startResponse.ok) {
121 const data = await startResponse.json()
122 error = data.error_description || data.error || 'Failed to start passkey login'
123 submitting = false
124 return
125 }
126
127 const { options } = await startResponse.json()
128
129 const credential = await navigator.credentials.get({
130 publicKey: prepareCredentialRequestOptions(options.publicKey)
131 }) as PublicKeyCredential | null
132
133 if (!credential) {
134 error = 'Passkey authentication was cancelled'
135 submitting = false
136 return
137 }
138
139 const assertionResponse = credential.response as AuthenticatorAssertionResponse
140 const credentialData = {
141 id: credential.id,
142 type: credential.type,
143 rawId: arrayBufferToBase64Url(credential.rawId),
144 response: {
145 clientDataJSON: arrayBufferToBase64Url(assertionResponse.clientDataJSON),
146 authenticatorData: arrayBufferToBase64Url(assertionResponse.authenticatorData),
147 signature: arrayBufferToBase64Url(assertionResponse.signature),
148 userHandle: assertionResponse.userHandle ? arrayBufferToBase64Url(assertionResponse.userHandle) : null
149 }
150 }
151
152 const finishResponse = await fetch('/oauth/passkey/finish', {
153 method: 'POST',
154 headers: {
155 'Content-Type': 'application/json',
156 'Accept': 'application/json'
157 },
158 body: JSON.stringify({
159 request_uri: requestUri,
160 credential: credentialData
161 })
162 })
163
164 const data = await finishResponse.json()
165
166 if (!finishResponse.ok) {
167 error = data.error_description || data.error || 'Passkey authentication failed'
168 submitting = false
169 return
170 }
171
172 if (data.needs_totp) {
173 navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`)
174 return
175 }
176
177 if (data.needs_2fa) {
178 navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`)
179 return
180 }
181
182 if (data.redirect_uri) {
183 window.location.href = data.redirect_uri
184 return
185 }
186
187 error = 'Unexpected response from server'
188 submitting = false
189 } catch (e) {
190 console.error('Passkey login error:', e)
191 if (e instanceof DOMException && e.name === 'NotAllowedError') {
192 error = 'Passkey authentication was cancelled'
193 } else {
194 error = `Failed to authenticate with passkey: ${e instanceof Error ? e.message : String(e)}`
195 }
196 submitting = false
197 }
198 }
199
200 function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
201 const bytes = new Uint8Array(buffer)
202 let binary = ''
203 for (let i = 0; i < bytes.byteLength; i++) {
204 binary += String.fromCharCode(bytes[i])
205 }
206 return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
207 }
208
209 function base64UrlToArrayBuffer(base64url: string): ArrayBuffer {
210 const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
211 const padded = base64 + '='.repeat((4 - base64.length % 4) % 4)
212 const binary = atob(padded)
213 const bytes = new Uint8Array(binary.length)
214 for (let i = 0; i < binary.length; i++) {
215 bytes[i] = binary.charCodeAt(i)
216 }
217 return bytes.buffer
218 }
219
220 function prepareCredentialRequestOptions(options: any): PublicKeyCredentialRequestOptions {
221 return {
222 ...options,
223 challenge: base64UrlToArrayBuffer(options.challenge),
224 allowCredentials: options.allowCredentials?.map((cred: any) => ({
225 ...cred,
226 id: base64UrlToArrayBuffer(cred.id)
227 })) || []
228 }
229 }
230
231 async function handleSubmit(e: Event) {
232 e.preventDefault()
233 const requestUri = getRequestUri()
234 if (!requestUri) {
235 error = 'Missing request_uri parameter'
236 return
237 }
238
239 submitting = true
240 error = null
241
242 try {
243 const response = await fetch('/oauth/authorize', {
244 method: 'POST',
245 headers: {
246 'Content-Type': 'application/json',
247 'Accept': 'application/json'
248 },
249 body: JSON.stringify({
250 request_uri: requestUri,
251 username,
252 password,
253 remember_device: rememberDevice
254 })
255 })
256
257 const data = await response.json()
258
259 if (!response.ok) {
260 error = data.error_description || data.error || 'Login failed'
261 submitting = false
262 return
263 }
264
265 if (data.needs_totp) {
266 navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`)
267 return
268 }
269
270 if (data.needs_2fa) {
271 navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`)
272 return
273 }
274
275 if (data.redirect_uri) {
276 window.location.href = data.redirect_uri
277 return
278 }
279
280 error = 'Unexpected response from server'
281 submitting = false
282 } catch {
283 error = 'Failed to connect to server'
284 submitting = false
285 }
286 }
287
288 async function handleCancel() {
289 const requestUri = getRequestUri()
290 if (!requestUri) {
291 window.history.back()
292 return
293 }
294
295 submitting = true
296 try {
297 const response = await fetch('/oauth/authorize/deny', {
298 method: 'POST',
299 headers: {
300 'Content-Type': 'application/json',
301 'Accept': 'application/json'
302 },
303 body: JSON.stringify({ request_uri: requestUri })
304 })
305
306 const data = await response.json()
307 if (data.redirect_uri) {
308 window.location.href = data.redirect_uri
309 }
310 } catch {
311 window.history.back()
312 }
313 }
314</script>
315
316<div class="oauth-login-container">
317 <h1>Sign In</h1>
318 <p class="subtitle">
319 {#if clientName}
320 Sign in to continue to <strong>{clientName}</strong>
321 {:else}
322 Sign in to continue to the application
323 {/if}
324 </p>
325
326 {#if error}
327 <div class="error">{error}</div>
328 {/if}
329
330 <form onsubmit={handleSubmit}>
331 <div class="field">
332 <label for="username">Handle or Email</label>
333 <input
334 id="username"
335 type="text"
336 bind:value={username}
337 placeholder="you@example.com or handle"
338 disabled={submitting}
339 required
340 autocomplete="username"
341 />
342 </div>
343
344 {#if passkeySupported && username.length >= 3}
345 <button
346 type="button"
347 class="passkey-btn"
348 class:passkey-unavailable={!hasPasskeys || checkingSecurityStatus || !securityStatusChecked}
349 onclick={handlePasskeyLogin}
350 disabled={submitting || !hasPasskeys || !username || checkingSecurityStatus || !securityStatusChecked}
351 title={checkingSecurityStatus ? 'Checking passkey status...' : hasPasskeys ? 'Sign in with your passkey' : 'No passkeys registered for this account'}
352 >
353 <svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
354 <path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" />
355 <path d="M17 17v4l3-2-3-2z" />
356 <path d="M12 11c-4 0-6 2-6 4v4h9" />
357 </svg>
358 <span class="passkey-text">
359 {#if submitting}
360 Authenticating...
361 {:else if checkingSecurityStatus || !securityStatusChecked}
362 Checking passkey...
363 {:else if hasPasskeys}
364 Sign in with passkey
365 {:else}
366 Passkey not set up
367 {/if}
368 </span>
369 </button>
370
371 <div class="auth-divider">
372 <span>or use password</span>
373 </div>
374 {/if}
375
376 <div class="field">
377 <label for="password">Password</label>
378 <input
379 id="password"
380 type="password"
381 bind:value={password}
382 disabled={submitting}
383 required
384 autocomplete="current-password"
385 />
386 </div>
387
388 <label class="remember-device">
389 <input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
390 <span>Remember this device</span>
391 </label>
392
393 <div class="actions">
394 <button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}>
395 Cancel
396 </button>
397 <button type="submit" class="submit-btn" disabled={submitting || !username || !password}>
398 {submitting ? 'Signing in...' : 'Sign In'}
399 </button>
400 </div>
401 </form>
402
403 <p class="help-links">
404 <a href="#/reset-password">Forgot password?</a> · <a href="#/request-passkey-recovery">Lost passkey?</a>
405 </p>
406</div>
407
408<style>
409 .help-links {
410 text-align: center;
411 margin-top: 1rem;
412 font-size: 0.875rem;
413 }
414
415 .help-links a {
416 color: var(--accent);
417 text-decoration: none;
418 }
419
420 .help-links a:hover {
421 text-decoration: underline;
422 }
423
424 .oauth-login-container {
425 max-width: 400px;
426 margin: 4rem auto;
427 padding: 2rem;
428 }
429
430 h1 {
431 margin: 0 0 0.5rem 0;
432 }
433
434 .subtitle {
435 color: var(--text-secondary);
436 margin: 0 0 2rem 0;
437 }
438
439 form {
440 display: flex;
441 flex-direction: column;
442 gap: 1rem;
443 }
444
445 .field {
446 display: flex;
447 flex-direction: column;
448 gap: 0.25rem;
449 }
450
451 label {
452 font-size: 0.875rem;
453 font-weight: 500;
454 }
455
456 input[type="text"],
457 input[type="password"] {
458 padding: 0.75rem;
459 border: 1px solid var(--border-color-light);
460 border-radius: 4px;
461 font-size: 1rem;
462 background: var(--bg-input);
463 color: var(--text-primary);
464 }
465
466 input:focus {
467 outline: none;
468 border-color: var(--accent);
469 }
470
471 .remember-device {
472 display: flex;
473 align-items: center;
474 gap: 0.5rem;
475 cursor: pointer;
476 color: var(--text-secondary);
477 font-size: 0.875rem;
478 }
479
480 .remember-device input {
481 width: 16px;
482 height: 16px;
483 }
484
485 .error {
486 padding: 0.75rem;
487 background: var(--error-bg);
488 border: 1px solid var(--error-border);
489 border-radius: 4px;
490 color: var(--error-text);
491 margin-bottom: 1rem;
492 }
493
494 .actions {
495 display: flex;
496 gap: 1rem;
497 margin-top: 0.5rem;
498 }
499
500 .actions button {
501 flex: 1;
502 padding: 0.75rem;
503 border: none;
504 border-radius: 4px;
505 font-size: 1rem;
506 cursor: pointer;
507 transition: background-color 0.15s;
508 }
509
510 .actions button:disabled {
511 opacity: 0.6;
512 cursor: not-allowed;
513 }
514
515 .cancel-btn {
516 background: var(--bg-secondary);
517 color: var(--text-primary);
518 border: 1px solid var(--border-color);
519 }
520
521 .cancel-btn:hover:not(:disabled) {
522 background: var(--error-bg);
523 border-color: var(--error-border);
524 color: var(--error-text);
525 }
526
527 .submit-btn {
528 background: var(--accent);
529 color: white;
530 }
531
532 .submit-btn:hover:not(:disabled) {
533 background: var(--accent-hover);
534 }
535
536 .auth-divider {
537 display: flex;
538 align-items: center;
539 gap: 1rem;
540 margin: 0.5rem 0;
541 }
542
543 .auth-divider::before,
544 .auth-divider::after {
545 content: '';
546 flex: 1;
547 height: 1px;
548 background: var(--border-color-light);
549 }
550
551 .auth-divider span {
552 color: var(--text-secondary);
553 font-size: 0.875rem;
554 }
555
556 .passkey-btn {
557 display: flex;
558 align-items: center;
559 justify-content: center;
560 gap: 0.5rem;
561 width: 100%;
562 padding: 0.75rem;
563 background: var(--accent);
564 color: white;
565 border: 1px solid var(--accent);
566 border-radius: 4px;
567 font-size: 1rem;
568 cursor: pointer;
569 transition: background-color 0.15s, border-color 0.15s, opacity 0.15s;
570 }
571
572 .passkey-btn:hover:not(:disabled) {
573 background: var(--accent-hover);
574 border-color: var(--accent-hover);
575 }
576
577 .passkey-btn:disabled {
578 opacity: 0.6;
579 cursor: not-allowed;
580 }
581
582 .passkey-btn.passkey-unavailable {
583 background: var(--bg-secondary);
584 color: var(--text-secondary);
585 border-color: var(--border-color);
586 }
587
588 .passkey-icon {
589 width: 20px;
590 height: 20px;
591 }
592
593 .passkey-text {
594 flex: 1;
595 text-align: left;
596 }
597</style>