this repo has no description
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' || successPurpose === 'signup'} 241 <p class="subtitle">{$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })}</p> 242 <p class="info-text">{$_('verify.canNowSignIn')}</p> 243 <div class="actions"> 244 <a href="/app/login" class="btn">{$_('verify.signIn')}</a> 245 </div> 246 {:else} 247 <p class="subtitle"> 248 {$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })} 249 </p> 250 <div class="actions"> 251 <button class="btn" onclick={goToNextStep}>{$_('verify.continue')}</button> 252 </div> 253 {/if} 254 </div> 255 {:else if mode === 'email-update'} 256 <h1>{$_('verify.emailUpdateTitle')}</h1> 257 <p class="subtitle">{$_('verify.emailUpdateSubtitle')}</p> 258 259 {#if !session} 260 <div class="message warning">{$_('verify.emailUpdateRequiresAuth')}</div> 261 <div class="actions"> 262 <a href="/app/login" class="btn">{$_('verify.signIn')}</a> 263 </div> 264 {:else} 265 {#if error} 266 <div class="message error">{error}</div> 267 {/if} 268 269 <form onsubmit={(e) => { e.preventDefault(); handleEmailUpdate(); }}> 270 <div class="field"> 271 <label for="new-email">{$_('verify.newEmailLabel')}</label> 272 <input 273 id="new-email" 274 type="email" 275 bind:value={newEmail} 276 placeholder={$_('verify.newEmailPlaceholder')} 277 disabled={submitting} 278 required 279 autocomplete="email" 280 /> 281 </div> 282 283 <div class="field"> 284 <label for="verification-code">{$_('verify.codeLabel')}</label> 285 <input 286 id="verification-code" 287 type="text" 288 bind:value={verificationCode} 289 placeholder={$_('verify.codePlaceholder')} 290 disabled={submitting} 291 required 292 autocomplete="off" 293 class="token-input" 294 /> 295 <p class="field-help">{$_('verify.emailUpdateCodeHelp')}</p> 296 </div> 297 298 <button type="submit" disabled={submitting || !verificationCode.trim() || !newEmail.trim()}> 299 {submitting ? $_('verify.updating') : $_('verify.updateEmail')} 300 </button> 301 </form> 302 303 <p class="link-text"> 304 <a href="/app/settings">{$_('common.backToSettings')}</a> 305 </p> 306 {/if} 307 {:else if mode === 'token'} 308 <h1>{$_('verify.tokenTitle')}</h1> 309 <p class="subtitle">{$_('verify.tokenSubtitle')}</p> 310 311 {#if error} 312 <div class="message error">{error}</div> 313 {/if} 314 315 {#if resendMessage} 316 <div class="message success">{resendMessage}</div> 317 {/if} 318 319 <form onsubmit={(e) => { e.preventDefault(); handleTokenVerification(); }}> 320 <div class="field"> 321 <label for="identifier">{$_('verify.identifierLabel')}</label> 322 <input 323 id="identifier" 324 type="text" 325 bind:value={identifier} 326 placeholder={$_('verify.identifierPlaceholder')} 327 disabled={submitting} 328 required 329 autocomplete="email" 330 /> 331 <p class="field-help">{$_('verify.identifierHelp')}</p> 332 </div> 333 334 <div class="field"> 335 <label for="verification-code">{$_('verify.codeLabel')}</label> 336 <input 337 id="verification-code" 338 type="text" 339 bind:value={verificationCode} 340 placeholder={$_('verify.codePlaceholder')} 341 disabled={submitting} 342 required 343 autocomplete="off" 344 class="token-input" 345 /> 346 <p class="field-help">{$_('verify.codeHelp')}</p> 347 </div> 348 349 <button type="submit" disabled={submitting || !verificationCode.trim() || !identifier.trim()}> 350 {submitting ? $_('common.verifying') : $_('common.verify')} 351 </button> 352 353 <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode || !identifier.trim()}> 354 {resendingCode ? $_('common.sending') : $_('common.resendCode')} 355 </button> 356 </form> 357 358 <p class="link-text"> 359 <a href="/app/login">{$_('common.backToLogin')}</a> 360 </p> 361 {:else if pendingVerification} 362 <h1>{$_('verify.title')}</h1> 363 <p class="subtitle"> 364 {$_('verify.subtitle', { values: { channel: channelLabel(pendingVerification.channel) } })} 365 </p> 366 <p class="handle-info">{$_('verify.verifyingAccount', { values: { handle: pendingVerification.handle } })}</p> 367 368 {#if error} 369 <div class="message error">{error}</div> 370 {/if} 371 372 {#if resendMessage} 373 <div class="message success">{resendMessage}</div> 374 {/if} 375 376 <form onsubmit={(e) => { e.preventDefault(); handleSignupVerification(e); }}> 377 <div class="field"> 378 <label for="verification-code">{$_('verify.codeLabel')}</label> 379 <input 380 id="verification-code" 381 type="text" 382 bind:value={verificationCode} 383 placeholder={$_('verify.codePlaceholder')} 384 disabled={submitting} 385 required 386 autocomplete="off" 387 class="token-input" 388 /> 389 <p class="field-help">{$_('verify.codeHelp')}</p> 390 </div> 391 392 <button type="submit" disabled={submitting || !verificationCode.trim()}> 393 {submitting ? $_('common.verifying') : $_('common.verify')} 394 </button> 395 396 <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}> 397 {resendingCode ? $_('common.sending') : $_('common.resendCode')} 398 </button> 399 </form> 400 401 <p class="link-text"> 402 <a href="/app/register" onclick={() => clearPendingVerification()}>{$_('verify.startOver')}</a> 403 </p> 404 {:else} 405 <h1>{$_('verify.title')}</h1> 406 <p class="subtitle">{$_('verify.noPending')}</p> 407 <p class="info-text">{$_('verify.noPendingInfo')}</p> 408 409 <div class="actions"> 410 <a href="/app/register" class="btn">{$_('verify.createAccount')}</a> 411 <a href="/app/login" class="btn secondary">{$_('verify.signIn')}</a> 412 </div> 413 {/if} 414</div> 415 416<style> 417 .verify-page { 418 max-width: var(--width-sm); 419 margin: var(--space-9) auto; 420 padding: var(--space-7); 421 } 422 423 h1 { 424 margin: 0 0 var(--space-3) 0; 425 } 426 427 .subtitle { 428 color: var(--text-secondary); 429 margin: 0 0 var(--space-4) 0; 430 } 431 432 .handle-info { 433 font-size: var(--text-sm); 434 color: var(--text-secondary); 435 margin: 0 0 var(--space-6) 0; 436 } 437 438 .info-text { 439 color: var(--text-secondary); 440 margin: var(--space-4) 0 var(--space-6) 0; 441 } 442 443 form { 444 display: flex; 445 flex-direction: column; 446 gap: var(--space-4); 447 } 448 449 .field-help { 450 font-size: var(--text-xs); 451 color: var(--text-secondary); 452 margin: var(--space-1) 0 0 0; 453 } 454 455 .token-input { 456 font-family: var(--font-mono); 457 letter-spacing: 0.05em; 458 } 459 460 .link-text { 461 text-align: center; 462 margin-top: var(--space-6); 463 font-size: var(--text-sm); 464 } 465 466 .link-text a { 467 color: var(--text-secondary); 468 } 469 470 .actions { 471 display: flex; 472 gap: var(--space-4); 473 } 474 475 .btn { 476 flex: 1; 477 display: inline-block; 478 padding: var(--space-4); 479 background: var(--accent); 480 color: var(--text-inverse); 481 border: none; 482 border-radius: var(--radius-md); 483 font-size: var(--text-base); 484 font-weight: var(--font-medium); 485 cursor: pointer; 486 text-decoration: none; 487 text-align: center; 488 } 489 490 .btn:hover { 491 background: var(--accent-hover); 492 text-decoration: none; 493 } 494 495 .btn.secondary { 496 background: transparent; 497 color: var(--accent); 498 border: 1px solid var(--accent); 499 } 500 501 .btn.secondary:hover { 502 background: var(--accent); 503 color: var(--text-inverse); 504 } 505 506 .success-container, 507 .loading-container { 508 text-align: center; 509 } 510 511 .success-container .actions { 512 justify-content: center; 513 margin-top: var(--space-6); 514 } 515 516 .success-container .btn { 517 flex: none; 518 padding: var(--space-4) var(--space-8); 519 } 520</style>