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