this repo has no description
1<script lang="ts"> 2 import { getAuthState, getValidToken } from '../lib/auth.svelte' 3 import { api, ApiError } from '../lib/api' 4 import { _ } from '../lib/i18n' 5 import type { Session } from '../lib/types/api' 6 import { 7 prepareRequestOptions, 8 serializeAssertionResponse, 9 type WebAuthnRequestOptionsResponse, 10 } from '../lib/webauthn' 11 12 interface Props { 13 show: boolean 14 availableMethods?: string[] 15 onSuccess: () => void 16 onCancel: () => void 17 } 18 19 let { show = $bindable(), availableMethods = ['password'], onSuccess, onCancel }: Props = $props() 20 21 const auth = $derived(getAuthState()) 22 23 function getSession(): Session | null { 24 return auth.kind === 'authenticated' ? auth.session : null 25 } 26 27 const session = $derived(getSession()) 28 let activeMethod = $state<'password' | 'totp' | 'passkey'>('password') 29 let password = $state('') 30 let totpCode = $state('') 31 let loading = $state(false) 32 let error = $state('') 33 34 $effect(() => { 35 if (show) { 36 password = '' 37 totpCode = '' 38 error = '' 39 if (availableMethods.includes('password')) { 40 activeMethod = 'password' 41 } else if (availableMethods.includes('totp')) { 42 activeMethod = 'totp' 43 } else if (availableMethods.includes('passkey')) { 44 activeMethod = 'passkey' 45 if (availableMethods.length === 1) { 46 handlePasskeyAuth() 47 } 48 } 49 } 50 }) 51 52 async function handlePasswordSubmit(e: Event) { 53 e.preventDefault() 54 if (!session || !password) return 55 loading = true 56 error = '' 57 try { 58 const token = await getValidToken() 59 if (!token) { 60 error = 'Session expired. Please log in again.' 61 return 62 } 63 await api.reauthPassword(token, password) 64 show = false 65 onSuccess() 66 } catch (e) { 67 error = e instanceof ApiError ? e.message : 'Authentication failed' 68 } finally { 69 loading = false 70 } 71 } 72 73 async function handleTotpSubmit(e: Event) { 74 e.preventDefault() 75 if (!session || !totpCode) return 76 loading = true 77 error = '' 78 try { 79 const token = await getValidToken() 80 if (!token) { 81 error = 'Session expired. Please log in again.' 82 return 83 } 84 await api.reauthTotp(token, totpCode) 85 show = false 86 onSuccess() 87 } catch (e) { 88 error = e instanceof ApiError ? e.message : 'Invalid code' 89 } finally { 90 loading = false 91 } 92 } 93 94 async function handlePasskeyAuth() { 95 if (!session) return 96 if (!window.PublicKeyCredential) { 97 error = 'Passkeys are not supported in this browser' 98 return 99 } 100 loading = true 101 error = '' 102 try { 103 const token = await getValidToken() 104 if (!token) { 105 error = 'Session expired. Please log in again.' 106 return 107 } 108 const { options } = await api.reauthPasskeyStart(token) 109 const publicKeyOptions = prepareRequestOptions(options as unknown as WebAuthnRequestOptionsResponse) 110 const credential = await navigator.credentials.get({ 111 publicKey: publicKeyOptions 112 }) 113 if (!credential) { 114 error = 'Passkey authentication was cancelled' 115 return 116 } 117 const credentialResponse = serializeAssertionResponse(credential as PublicKeyCredential) 118 await api.reauthPasskeyFinish(token, credentialResponse) 119 show = false 120 onSuccess() 121 } catch (e) { 122 if (e instanceof DOMException && e.name === 'NotAllowedError') { 123 error = 'Passkey authentication was cancelled' 124 } else { 125 error = e instanceof ApiError ? e.message : 'Passkey authentication failed' 126 } 127 } finally { 128 loading = false 129 } 130 } 131 132 function handleClose() { 133 show = false 134 onCancel() 135 } 136</script> 137 138{#if show} 139 <div class="modal-backdrop" onclick={handleClose} onkeydown={(e) => e.key === 'Escape' && handleClose()} role="presentation"> 140 <div class="modal" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="dialog" aria-modal="true" tabindex="-1"> 141 <div class="modal-header"> 142 <h2>{$_('reauth.title')}</h2> 143 <button class="close-btn" onclick={handleClose} aria-label="Close">&times;</button> 144 </div> 145 146 <p class="modal-description"> 147 {$_('reauth.subtitle')} 148 </p> 149 150 {#if error} 151 <div class="error-message">{error}</div> 152 {/if} 153 154 {#if availableMethods.length > 1} 155 <div class="method-tabs"> 156 {#if availableMethods.includes('password')} 157 <button 158 class="tab" 159 class:active={activeMethod === 'password'} 160 onclick={() => activeMethod = 'password'} 161 > 162 {$_('reauth.password')} 163 </button> 164 {/if} 165 {#if availableMethods.includes('totp')} 166 <button 167 class="tab" 168 class:active={activeMethod === 'totp'} 169 onclick={() => activeMethod = 'totp'} 170 > 171 {$_('reauth.totp')} 172 </button> 173 {/if} 174 {#if availableMethods.includes('passkey')} 175 <button 176 class="tab" 177 class:active={activeMethod === 'passkey'} 178 onclick={() => activeMethod = 'passkey'} 179 > 180 {$_('reauth.passkey')} 181 </button> 182 {/if} 183 </div> 184 {/if} 185 186 <div class="modal-content"> 187 {#if activeMethod === 'password'} 188 <form onsubmit={handlePasswordSubmit}> 189 <div class="form-group"> 190 <label for="reauth-password">{$_('reauth.password')}</label> 191 <input 192 id="reauth-password" 193 type="password" 194 bind:value={password} 195 required 196 autocomplete="current-password" 197 /> 198 </div> 199 <button type="submit" class="btn-primary" disabled={loading || !password}> 200 {loading ? $_('common.verifying') : $_('common.verify')} 201 </button> 202 </form> 203 {:else if activeMethod === 'totp'} 204 <form onsubmit={handleTotpSubmit}> 205 <div class="form-group"> 206 <label for="reauth-totp">{$_('reauth.authenticatorCode')}</label> 207 <input 208 id="reauth-totp" 209 type="text" 210 bind:value={totpCode} 211 required 212 autocomplete="one-time-code" 213 inputmode="numeric" 214 pattern="[0-9]*" 215 maxlength="6" 216 /> 217 </div> 218 <button type="submit" class="btn-primary" disabled={loading || !totpCode}> 219 {loading ? $_('common.verifying') : $_('common.verify')} 220 </button> 221 </form> 222 {:else if activeMethod === 'passkey'} 223 <div class="passkey-auth"> 224 <p>{$_('reauth.passkeyPrompt')}</p> 225 <button 226 class="btn-primary" 227 onclick={handlePasskeyAuth} 228 disabled={loading} 229 > 230 {loading ? $_('reauth.authenticating') : $_('reauth.usePasskey')} 231 </button> 232 </div> 233 {/if} 234 </div> 235 236 <div class="modal-footer"> 237 <button class="btn-secondary" onclick={handleClose} disabled={loading}> 238 {$_('reauth.cancel')} 239 </button> 240 </div> 241 </div> 242 </div> 243{/if} 244 245<style> 246 .modal-backdrop { 247 position: fixed; 248 inset: 0; 249 background: rgba(0, 0, 0, 0.5); 250 display: flex; 251 align-items: center; 252 justify-content: center; 253 z-index: 1000; 254 } 255 256 .modal { 257 background: var(--bg-card); 258 border-radius: 8px; 259 box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); 260 max-width: 400px; 261 width: 90%; 262 max-height: 90vh; 263 overflow-y: auto; 264 } 265 266 .modal-header { 267 display: flex; 268 justify-content: space-between; 269 align-items: center; 270 padding: 1rem 1.5rem; 271 border-bottom: 1px solid var(--border-color); 272 } 273 274 .modal-header h2 { 275 margin: 0; 276 font-size: 1.25rem; 277 } 278 279 .close-btn { 280 background: none; 281 border: none; 282 font-size: 1.5rem; 283 cursor: pointer; 284 color: var(--text-secondary); 285 padding: 0; 286 line-height: 1; 287 } 288 289 .close-btn:hover { 290 color: var(--text-primary); 291 } 292 293 .modal-description { 294 padding: 1rem 1.5rem 0; 295 margin: 0; 296 color: var(--text-secondary); 297 } 298 299 .error-message { 300 margin: 1rem 1.5rem 0; 301 padding: 0.75rem; 302 background: var(--error-bg); 303 border: 1px solid var(--error-border); 304 border-radius: 4px; 305 color: var(--error-text); 306 font-size: 0.875rem; 307 } 308 309 .method-tabs { 310 display: flex; 311 gap: 0.5rem; 312 padding: 1rem 1.5rem 0; 313 } 314 315 .tab { 316 flex: 1; 317 padding: 0.5rem 1rem; 318 background: var(--bg-input); 319 border: 1px solid var(--border-color); 320 border-radius: 4px; 321 cursor: pointer; 322 color: var(--text-secondary); 323 font-size: 0.875rem; 324 } 325 326 .tab:hover { 327 background: var(--bg-secondary); 328 } 329 330 .tab.active { 331 background: var(--accent); 332 border-color: var(--accent); 333 color: white; 334 } 335 336 .modal-content { 337 padding: 1.5rem; 338 } 339 340 .form-group { 341 margin-bottom: 1rem; 342 } 343 344 .form-group label { 345 display: block; 346 margin-bottom: 0.5rem; 347 font-weight: 500; 348 } 349 350 .form-group input { 351 width: 100%; 352 padding: 0.75rem; 353 border: 1px solid var(--border-color); 354 border-radius: 4px; 355 background: var(--bg-input); 356 color: var(--text-primary); 357 font-size: 1rem; 358 } 359 360 .form-group input:focus { 361 outline: none; 362 border-color: var(--accent); 363 } 364 365 .passkey-auth { 366 text-align: center; 367 } 368 369 .passkey-auth p { 370 margin-bottom: 1rem; 371 color: var(--text-secondary); 372 } 373 374 .btn-primary { 375 width: 100%; 376 padding: 0.75rem 1.5rem; 377 background: var(--accent); 378 color: white; 379 border: none; 380 border-radius: 4px; 381 font-size: 1rem; 382 cursor: pointer; 383 } 384 385 .btn-primary:hover:not(:disabled) { 386 background: var(--accent-hover); 387 } 388 389 .btn-primary:disabled { 390 opacity: 0.6; 391 cursor: not-allowed; 392 } 393 394 .modal-footer { 395 padding: 0 1.5rem 1.5rem; 396 display: flex; 397 justify-content: flex-end; 398 } 399 400 .btn-secondary { 401 padding: 0.5rem 1rem; 402 background: var(--bg-input); 403 border: 1px solid var(--border-color); 404 border-radius: 4px; 405 color: var(--text-secondary); 406 cursor: pointer; 407 font-size: 0.875rem; 408 } 409 410 .btn-secondary:hover:not(:disabled) { 411 background: var(--bg-secondary); 412 } 413 414 .btn-secondary:disabled { 415 opacity: 0.6; 416 cursor: not-allowed; 417 } 418</style>