Our Personal Data Server from scratch!
at fix/small-bugs 358 lines 9.6 kB view raw
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="field"> 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" 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="field"> 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" 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 onclick={handlePasskeyAuth} disabled={loading}> 226 {loading ? $_('reauth.authenticating') : $_('reauth.usePasskey')} 227 </button> 228 </div> 229 {/if} 230 </div> 231 232 <div class="modal-footer"> 233 <button class="secondary" onclick={handleClose} disabled={loading}> 234 {$_('reauth.cancel')} 235 </button> 236 </div> 237 </div> 238 </div> 239{/if} 240 241<style> 242 .modal-backdrop { 243 position: fixed; 244 inset: 0; 245 background: var(--overlay-bg); 246 display: flex; 247 align-items: center; 248 justify-content: center; 249 z-index: var(--z-modal); 250 } 251 252 .modal { 253 background: var(--bg-card); 254 border-radius: var(--radius-xl); 255 box-shadow: var(--shadow-lg); 256 max-width: var(--width-sm); 257 width: 90%; 258 max-height: 90vh; 259 overflow-y: auto; 260 } 261 262 .modal-header { 263 display: flex; 264 justify-content: space-between; 265 align-items: center; 266 padding: var(--space-4) var(--space-6); 267 border-bottom: 1px solid var(--border-color); 268 } 269 270 .modal-header h2 { 271 margin: 0; 272 font-size: var(--text-lg); 273 } 274 275 .close-btn { 276 background: none; 277 border: none; 278 font-size: var(--text-xl); 279 cursor: pointer; 280 color: var(--text-secondary); 281 padding: 0; 282 line-height: 1; 283 } 284 285 .close-btn:hover { 286 color: var(--text-primary); 287 } 288 289 .modal-description { 290 padding: var(--space-4) var(--space-6) 0; 291 margin: 0; 292 color: var(--text-secondary); 293 } 294 295 .error-message { 296 margin: var(--space-4) var(--space-6) 0; 297 padding: var(--space-3); 298 background: var(--error-bg); 299 border: 1px solid var(--error-border); 300 border-radius: var(--radius-md); 301 color: var(--error-text); 302 font-size: var(--text-sm); 303 } 304 305 .method-tabs { 306 display: flex; 307 gap: var(--space-2); 308 padding: var(--space-4) var(--space-6) 0; 309 } 310 311 .tab { 312 flex: 1; 313 padding: var(--space-2) var(--space-4); 314 background: var(--bg-input); 315 border: 1px solid var(--border-color); 316 border-radius: var(--radius-md); 317 cursor: pointer; 318 color: var(--text-secondary); 319 font-size: var(--text-sm); 320 } 321 322 .tab:hover { 323 background: var(--bg-secondary); 324 } 325 326 .tab.active { 327 background: var(--accent); 328 border-color: var(--accent); 329 color: var(--text-inverse); 330 } 331 332 .modal-content { 333 padding: var(--space-6); 334 } 335 336 .modal-content .field { 337 margin-bottom: var(--space-4); 338 } 339 340 .passkey-auth { 341 text-align: center; 342 } 343 344 .passkey-auth p { 345 margin-bottom: var(--space-4); 346 color: var(--text-secondary); 347 } 348 349 .modal-content button:not(.tab) { 350 width: 100%; 351 } 352 353 .modal-footer { 354 padding: 0 var(--space-6) var(--space-6); 355 display: flex; 356 justify-content: flex-end; 357 } 358</style>