Our Personal Data Server from scratch! tranquil.farm
oauth atproto pds rust postgresql objectstorage fun
at main 342 lines 9.2 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 {#if error} 147 <div class="error-message">{error}</div> 148 {/if} 149 150 {#if availableMethods.length > 1} 151 <div class="method-tabs"> 152 {#if availableMethods.includes('password')} 153 <button 154 class="tab" 155 class:active={activeMethod === 'password'} 156 onclick={() => activeMethod = 'password'} 157 > 158 {$_('reauth.password')} 159 </button> 160 {/if} 161 {#if availableMethods.includes('totp')} 162 <button 163 class="tab" 164 class:active={activeMethod === 'totp'} 165 onclick={() => activeMethod = 'totp'} 166 > 167 {$_('reauth.totp')} 168 </button> 169 {/if} 170 {#if availableMethods.includes('passkey')} 171 <button 172 class="tab" 173 class:active={activeMethod === 'passkey'} 174 onclick={() => activeMethod = 'passkey'} 175 > 176 {$_('reauth.passkey')} 177 </button> 178 {/if} 179 </div> 180 {/if} 181 182 <div class="modal-content"> 183 {#if activeMethod === 'password'} 184 <form onsubmit={handlePasswordSubmit}> 185 <div class="field"> 186 <label for="reauth-password">{$_('reauth.password')}</label> 187 <input 188 id="reauth-password" 189 type="password" 190 bind:value={password} 191 required 192 autocomplete="current-password" 193 /> 194 </div> 195 <button type="submit" disabled={loading || !password}> 196 {loading ? $_('common.verifying') : $_('common.verify')} 197 </button> 198 </form> 199 {:else if activeMethod === 'totp'} 200 <form onsubmit={handleTotpSubmit}> 201 <div class="field"> 202 <label for="reauth-totp">{$_('reauth.authenticatorCode')}</label> 203 <input 204 id="reauth-totp" 205 type="text" 206 bind:value={totpCode} 207 required 208 autocomplete="one-time-code" 209 inputmode="numeric" 210 pattern="[0-9]*" 211 maxlength="6" 212 /> 213 </div> 214 <button type="submit" disabled={loading || !totpCode}> 215 {loading ? $_('common.verifying') : $_('common.verify')} 216 </button> 217 </form> 218 {:else if activeMethod === 'passkey'} 219 <div class="passkey-auth"> 220 <button onclick={handlePasskeyAuth} disabled={loading}> 221 {loading ? $_('reauth.authenticating') : $_('reauth.usePasskey')} 222 </button> 223 </div> 224 {/if} 225 </div> 226 227 <div class="modal-footer"> 228 <button class="secondary" onclick={handleClose} disabled={loading}> 229 {$_('reauth.cancel')} 230 </button> 231 </div> 232 </div> 233 </div> 234{/if} 235 236<style> 237 .modal-backdrop { 238 position: fixed; 239 inset: 0; 240 background: var(--overlay-bg); 241 display: flex; 242 align-items: center; 243 justify-content: center; 244 z-index: var(--z-modal); 245 } 246 247 .modal { 248 background: var(--bg-card); 249 border-radius: var(--radius-xl); 250 box-shadow: var(--shadow-lg); 251 max-width: var(--width-sm); 252 width: 90%; 253 max-height: 90vh; 254 overflow-y: auto; 255 } 256 257 .modal-header { 258 display: flex; 259 justify-content: space-between; 260 align-items: center; 261 padding: var(--space-4) var(--space-6); 262 border-bottom: 1px solid var(--border-color); 263 } 264 265 .modal-header h2 { 266 margin: 0; 267 font-size: var(--text-lg); 268 } 269 270 .close-btn { 271 background: none; 272 border: none; 273 font-size: var(--text-xl); 274 cursor: pointer; 275 color: var(--text-secondary); 276 padding: 0; 277 line-height: 1; 278 } 279 280 .close-btn:hover { 281 color: var(--text-primary); 282 } 283 284 .error-message { 285 margin: var(--space-4) var(--space-6) 0; 286 padding: var(--space-3); 287 background: var(--error-bg); 288 border: 1px solid var(--error-border); 289 border-radius: var(--radius-md); 290 color: var(--error-text); 291 font-size: var(--text-sm); 292 } 293 294 .method-tabs { 295 display: flex; 296 gap: var(--space-2); 297 padding: var(--space-4) var(--space-6) 0; 298 } 299 300 .tab { 301 flex: 1; 302 padding: var(--space-2) var(--space-4); 303 background: var(--bg-input); 304 border: 1px solid var(--border-color); 305 border-radius: var(--radius-md); 306 cursor: pointer; 307 color: var(--text-secondary); 308 font-size: var(--text-sm); 309 } 310 311 .tab:hover { 312 background: var(--bg-secondary); 313 } 314 315 .tab.active { 316 background: var(--accent); 317 border-color: var(--accent); 318 color: var(--text-inverse); 319 } 320 321 .modal-content { 322 padding: var(--space-6); 323 } 324 325 .modal-content .field { 326 margin-bottom: var(--space-4); 327 } 328 329 .passkey-auth { 330 text-align: center; 331 } 332 333 .modal-content button:not(.tab) { 334 width: 100%; 335 } 336 337 .modal-footer { 338 padding: 0 var(--space-6) var(--space-6); 339 display: flex; 340 justify-content: flex-end; 341 } 342</style>