this repo has no description
at main 15 kB view raw
1<script lang="ts"> 2 import { onMount } from 'svelte' 3 import { confirmSignup, resendVerification, getAuthState } from '../lib/auth.svelte' 4 import { api, ApiError } from '../lib/api' 5 import { navigate, routes, getFullUrl } from '../lib/router.svelte' 6 import { _ } from '../lib/i18n' 7 import type { Session } from '../lib/types/api' 8 import { unsafeAsDid, unsafeAsEmail, type Did } from '../lib/types/branded' 9 10 const STORAGE_KEY = 'tranquil_pds_pending_verification' 11 12 interface PendingVerification { 13 did: Did 14 handle: string 15 channel: string 16 } 17 18 type VerificationMode = 'signup' | 'token' | 'email-update' 19 20 let mode = $state<VerificationMode>('signup') 21 let newEmail = $state('') 22 let pendingVerification = $state<PendingVerification | null>(null) 23 let verificationCode = $state('') 24 let identifier = $state('') 25 let submitting = $state(false) 26 let resendingCode = $state(false) 27 let error = $state<string | null>(null) 28 let resendMessage = $state<string | null>(null) 29 let success = $state(false) 30 let autoSubmitting = $state(false) 31 let successPurpose = $state<string | null>(null) 32 let successChannel = $state<string | null>(null) 33 34 const auth = $derived(getAuthState()) 35 36 function getSession(): Session | null { 37 return auth.kind === 'authenticated' ? auth.session : null 38 } 39 40 const session = $derived(getSession()) 41 42 function parseQueryParams(): Record<string, string> { 43 return Object.fromEntries(new URLSearchParams(window.location.search)) 44 } 45 46 onMount(async () => { 47 const params = parseQueryParams() 48 49 if (params.type === 'email-update') { 50 mode = 'email-update' 51 if (params.token) { 52 verificationCode = params.token 53 } 54 } else if (params.token) { 55 mode = 'token' 56 verificationCode = params.token 57 if (params.identifier) { 58 identifier = params.identifier 59 } 60 if (verificationCode && identifier) { 61 autoSubmitting = true 62 await handleTokenVerification() 63 autoSubmitting = false 64 } 65 } else { 66 mode = 'signup' 67 const stored = localStorage.getItem(STORAGE_KEY) 68 if (stored) { 69 try { 70 const parsed = JSON.parse(stored) 71 pendingVerification = { 72 did: unsafeAsDid(parsed.did), 73 handle: parsed.handle, 74 channel: parsed.channel, 75 } 76 } catch { 77 pendingVerification = null 78 } 79 } 80 } 81 }) 82 83 $effect(() => { 84 if (mode === 'signup' && session) { 85 clearPendingVerification() 86 navigate(routes.dashboard) 87 } 88 }) 89 90 function clearPendingVerification() { 91 localStorage.removeItem(STORAGE_KEY) 92 pendingVerification = null 93 } 94 95 async function handleSignupVerification(e: Event) { 96 e.preventDefault() 97 if (!pendingVerification || !verificationCode.trim()) return 98 99 submitting = true 100 error = null 101 102 try { 103 await confirmSignup(pendingVerification.did, verificationCode.trim()) 104 clearPendingVerification() 105 navigate('/dashboard') 106 } catch (e) { 107 error = e instanceof Error ? e.message : 'Verification failed' 108 } finally { 109 submitting = false 110 } 111 } 112 113 async function handleTokenVerification() { 114 if (!verificationCode.trim() || !identifier.trim()) return 115 116 submitting = true 117 error = null 118 119 try { 120 const result = await api.verifyToken( 121 verificationCode.trim(), 122 identifier.trim(), 123 session?.accessJwt 124 ) 125 success = true 126 successPurpose = result.purpose 127 successChannel = result.channel 128 } catch (e) { 129 if (e instanceof ApiError) { 130 if (e.error === 'AuthenticationRequired') { 131 error = 'You must be signed in to complete this verification. Please sign in and try again.' 132 } else { 133 error = e.message 134 } 135 } else { 136 error = 'Verification failed' 137 } 138 } finally { 139 submitting = false 140 } 141 } 142 143 async function handleEmailUpdate() { 144 if (!verificationCode.trim() || !newEmail.trim()) return 145 146 if (!session) { 147 error = $_('verify.emailUpdateRequiresAuth') 148 return 149 } 150 151 submitting = true 152 error = null 153 154 try { 155 await api.updateEmail(session.accessJwt, newEmail.trim(), verificationCode.trim()) 156 success = true 157 successPurpose = 'email-update' 158 successChannel = 'email' 159 } catch (e) { 160 if (e instanceof ApiError) { 161 error = e.message 162 } else { 163 error = $_('verify.emailUpdateFailed') 164 } 165 } finally { 166 submitting = false 167 } 168 } 169 170 async function handleResendCode() { 171 if (mode === 'signup') { 172 if (!pendingVerification || resendingCode) return 173 174 resendingCode = true 175 resendMessage = null 176 error = null 177 178 try { 179 await resendVerification(pendingVerification.did) 180 resendMessage = $_('verify.codeResent') 181 } catch (e) { 182 error = e instanceof Error ? e.message : 'Failed to resend code' 183 } finally { 184 resendingCode = false 185 } 186 } else { 187 if (!identifier.trim() || resendingCode) return 188 189 resendingCode = true 190 resendMessage = null 191 error = null 192 193 try { 194 await api.resendMigrationVerification(unsafeAsEmail(identifier.trim())) 195 resendMessage = $_('verify.codeResentDetail') 196 } catch (e) { 197 error = e instanceof Error ? e.message : 'Failed to resend verification' 198 } finally { 199 resendingCode = false 200 } 201 } 202 } 203 204 function channelLabel(ch: string): string { 205 switch (ch) { 206 case 'email': return $_('register.email') 207 case 'discord': return $_('register.discord') 208 case 'telegram': return $_('register.telegram') 209 case 'signal': return $_('register.signal') 210 default: return ch 211 } 212 } 213 214 function goToNextStep() { 215 if (successPurpose === 'migration') { 216 navigate('/login') 217 } else if (successChannel === 'email') { 218 navigate('/settings') 219 } else { 220 navigate('/comms') 221 } 222 } 223</script> 224 225<div class="verify-page"> 226 {#if autoSubmitting} 227 <div class="loading-container"> 228 <h1>{$_('common.verifying')}</h1> 229 <p class="subtitle">{$_('verify.pleaseWait')}</p> 230 </div> 231 {:else if success} 232 <div class="success-container"> 233 <h1>{$_('verify.verified')}</h1> 234 {#if successPurpose === 'email-update'} 235 <p class="subtitle">{$_('verify.emailUpdated')}</p> 236 <p class="info-text">{$_('verify.emailUpdatedInfo')}</p> 237 <div class="actions"> 238 <a href="/app/settings" class="btn">{$_('common.backToSettings')}</a> 239 </div> 240 {:else if successPurpose === 'migration'} 241 <p class="subtitle">{$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })}</p> 242 <p class="info-text">{$_('verify.migrationContinue')}</p> 243 {:else if successPurpose === 'signup'} 244 <p class="subtitle">{$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })}</p> 245 <p class="info-text">{$_('verify.canNowSignIn')}</p> 246 <div class="actions"> 247 <a href="/app/login" class="btn">{$_('verify.signIn')}</a> 248 </div> 249 {:else} 250 <p class="subtitle"> 251 {$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })} 252 </p> 253 <div class="actions"> 254 <button class="btn" onclick={goToNextStep}>{$_('verify.continue')}</button> 255 </div> 256 {/if} 257 </div> 258 {:else if mode === 'email-update'} 259 <h1>{$_('verify.emailUpdateTitle')}</h1> 260 <p class="subtitle">{$_('verify.emailUpdateSubtitle')}</p> 261 262 {#if !session} 263 <div class="message warning">{$_('verify.emailUpdateRequiresAuth')}</div> 264 <div class="actions"> 265 <a href="/app/login" class="btn">{$_('verify.signIn')}</a> 266 </div> 267 {:else} 268 {#if error} 269 <div class="message error">{error}</div> 270 {/if} 271 272 <form onsubmit={(e) => { e.preventDefault(); handleEmailUpdate(); }}> 273 <div class="field"> 274 <label for="new-email">{$_('verify.newEmailLabel')}</label> 275 <input 276 id="new-email" 277 type="email" 278 bind:value={newEmail} 279 placeholder={$_('verify.newEmailPlaceholder')} 280 disabled={submitting} 281 required 282 autocomplete="email" 283 /> 284 </div> 285 286 <div class="field"> 287 <label for="verification-code">{$_('verify.codeLabel')}</label> 288 <input 289 id="verification-code" 290 type="text" 291 bind:value={verificationCode} 292 placeholder={$_('verify.codePlaceholder')} 293 disabled={submitting} 294 required 295 autocomplete="off" 296 class="token-input" 297 /> 298 <p class="field-help">{$_('verify.emailUpdateCodeHelp')}</p> 299 </div> 300 301 <button type="submit" disabled={submitting || !verificationCode.trim() || !newEmail.trim()}> 302 {submitting ? $_('verify.updating') : $_('verify.updateEmail')} 303 </button> 304 </form> 305 306 <p class="link-text"> 307 <a href="/app/settings">{$_('common.backToSettings')}</a> 308 </p> 309 {/if} 310 {:else if mode === 'token'} 311 <h1>{$_('verify.tokenTitle')}</h1> 312 <p class="subtitle">{$_('verify.tokenSubtitle')}</p> 313 314 {#if error} 315 <div class="message error">{error}</div> 316 {/if} 317 318 {#if resendMessage} 319 <div class="message success">{resendMessage}</div> 320 {/if} 321 322 <form onsubmit={(e) => { e.preventDefault(); handleTokenVerification(); }}> 323 <div class="field"> 324 <label for="identifier">{$_('verify.identifierLabel')}</label> 325 <input 326 id="identifier" 327 type="text" 328 bind:value={identifier} 329 placeholder={$_('verify.identifierPlaceholder')} 330 disabled={submitting} 331 required 332 autocomplete="email" 333 /> 334 <p class="field-help">{$_('verify.identifierHelp')}</p> 335 </div> 336 337 <div class="field"> 338 <label for="verification-code">{$_('verify.codeLabel')}</label> 339 <input 340 id="verification-code" 341 type="text" 342 bind:value={verificationCode} 343 placeholder={$_('verify.codePlaceholder')} 344 disabled={submitting} 345 required 346 autocomplete="off" 347 class="token-input" 348 /> 349 <p class="field-help">{$_('verify.codeHelp')}</p> 350 </div> 351 352 <button type="submit" disabled={submitting || !verificationCode.trim() || !identifier.trim()}> 353 {submitting ? $_('common.verifying') : $_('common.verify')} 354 </button> 355 356 <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode || !identifier.trim()}> 357 {resendingCode ? $_('common.sending') : $_('common.resendCode')} 358 </button> 359 </form> 360 361 <p class="link-text"> 362 <a href="/app/login">{$_('common.backToLogin')}</a> 363 </p> 364 {:else if pendingVerification} 365 <h1>{$_('verify.title')}</h1> 366 <p class="subtitle"> 367 {$_('verify.subtitle', { values: { channel: channelLabel(pendingVerification.channel) } })} 368 </p> 369 <p class="handle-info">{$_('verify.verifyingAccount', { values: { handle: pendingVerification.handle } })}</p> 370 371 {#if error} 372 <div class="message error">{error}</div> 373 {/if} 374 375 {#if resendMessage} 376 <div class="message success">{resendMessage}</div> 377 {/if} 378 379 <form onsubmit={(e) => { e.preventDefault(); handleSignupVerification(e); }}> 380 <div class="field"> 381 <label for="verification-code">{$_('verify.codeLabel')}</label> 382 <input 383 id="verification-code" 384 type="text" 385 bind:value={verificationCode} 386 placeholder={$_('verify.codePlaceholder')} 387 disabled={submitting} 388 required 389 autocomplete="off" 390 class="token-input" 391 /> 392 <p class="field-help">{$_('verify.codeHelp')}</p> 393 </div> 394 395 <button type="submit" disabled={submitting || !verificationCode.trim()}> 396 {submitting ? $_('common.verifying') : $_('common.verify')} 397 </button> 398 399 <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}> 400 {resendingCode ? $_('common.sending') : $_('common.resendCode')} 401 </button> 402 </form> 403 404 <p class="link-text"> 405 <a href="/app/register" onclick={() => clearPendingVerification()}>{$_('verify.startOver')}</a> 406 </p> 407 {:else} 408 <h1>{$_('verify.title')}</h1> 409 <p class="subtitle">{$_('verify.noPending')}</p> 410 <p class="info-text">{$_('verify.noPendingInfo')}</p> 411 412 <div class="actions"> 413 <a href="/app/register" class="btn">{$_('verify.createAccount')}</a> 414 <a href="/app/login" class="btn secondary">{$_('verify.signIn')}</a> 415 </div> 416 {/if} 417</div> 418 419<style> 420 .verify-page { 421 max-width: var(--width-sm); 422 margin: var(--space-9) auto; 423 padding: var(--space-7); 424 } 425 426 h1 { 427 margin: 0 0 var(--space-3) 0; 428 } 429 430 .subtitle { 431 color: var(--text-secondary); 432 margin: 0 0 var(--space-4) 0; 433 } 434 435 .handle-info { 436 font-size: var(--text-sm); 437 color: var(--text-secondary); 438 margin: 0 0 var(--space-6) 0; 439 } 440 441 .info-text { 442 color: var(--text-secondary); 443 margin: var(--space-4) 0 var(--space-6) 0; 444 } 445 446 form { 447 display: flex; 448 flex-direction: column; 449 gap: var(--space-4); 450 } 451 452 .field-help { 453 font-size: var(--text-xs); 454 color: var(--text-secondary); 455 margin: var(--space-1) 0 0 0; 456 } 457 458 .token-input { 459 font-family: var(--font-mono); 460 letter-spacing: 0.05em; 461 } 462 463 .link-text { 464 text-align: center; 465 margin-top: var(--space-6); 466 font-size: var(--text-sm); 467 } 468 469 .link-text a { 470 color: var(--text-secondary); 471 } 472 473 .actions { 474 display: flex; 475 gap: var(--space-4); 476 } 477 478 .btn { 479 flex: 1; 480 display: inline-block; 481 padding: var(--space-4); 482 background: var(--accent); 483 color: var(--text-inverse); 484 border: none; 485 border-radius: var(--radius-md); 486 font-size: var(--text-base); 487 font-weight: var(--font-medium); 488 cursor: pointer; 489 text-decoration: none; 490 text-align: center; 491 } 492 493 .btn:hover { 494 background: var(--accent-hover); 495 text-decoration: none; 496 } 497 498 .btn.secondary { 499 background: transparent; 500 color: var(--accent); 501 border: 1px solid var(--accent); 502 } 503 504 .btn.secondary:hover { 505 background: var(--accent); 506 color: var(--text-inverse); 507 } 508 509 .success-container, 510 .loading-container { 511 text-align: center; 512 } 513 514 .success-container .actions { 515 justify-content: center; 516 margin-top: var(--space-6); 517 } 518 519 .success-container .btn { 520 flex: none; 521 padding: var(--space-4) var(--space-8); 522 } 523</style>