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 } from '../lib/router.svelte' 6 import { _ } from '../lib/i18n' 7 8 const STORAGE_KEY = 'tranquil_pds_pending_verification' 9 10 interface PendingVerification { 11 did: string 12 handle: string 13 channel: string 14 } 15 16 type VerificationMode = 'signup' | 'token' | 'email-update' 17 18 let mode = $state<VerificationMode>('signup') 19 let newEmail = $state('') 20 let pendingVerification = $state<PendingVerification | null>(null) 21 let verificationCode = $state('') 22 let identifier = $state('') 23 let submitting = $state(false) 24 let resendingCode = $state(false) 25 let error = $state<string | null>(null) 26 let resendMessage = $state<string | null>(null) 27 let success = $state(false) 28 let autoSubmitting = $state(false) 29 let successPurpose = $state<string | null>(null) 30 let successChannel = $state<string | null>(null) 31 32 const auth = getAuthState() 33 34 35 function parseQueryParams() { 36 const hash = window.location.hash 37 const queryIndex = hash.indexOf('?') 38 if (queryIndex === -1) return {} 39 40 const queryString = hash.slice(queryIndex + 1) 41 const params: Record<string, string> = {} 42 for (const pair of queryString.split('&')) { 43 const [key, value] = pair.split('=') 44 if (key && value) { 45 params[decodeURIComponent(key)] = decodeURIComponent(value) 46 } 47 } 48 return params 49 } 50 51 onMount(async () => { 52 const params = parseQueryParams() 53 54 if (params.type === 'email-update') { 55 mode = 'email-update' 56 if (params.token) { 57 verificationCode = params.token 58 } 59 } else if (params.token) { 60 mode = 'token' 61 verificationCode = params.token 62 if (params.identifier) { 63 identifier = params.identifier 64 } 65 if (verificationCode && identifier) { 66 autoSubmitting = true 67 await handleTokenVerification() 68 autoSubmitting = false 69 } 70 } else { 71 mode = 'signup' 72 const stored = localStorage.getItem(STORAGE_KEY) 73 if (stored) { 74 try { 75 pendingVerification = JSON.parse(stored) 76 } catch { 77 pendingVerification = null 78 } 79 } 80 } 81 }) 82 83 $effect(() => { 84 if (mode === 'signup' && auth.session) { 85 clearPendingVerification() 86 navigate('/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: any) { 107 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 auth.session?.accessJwt 124 ) 125 success = true 126 successPurpose = result.purpose 127 successChannel = result.channel 128 } catch (e: any) { 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 (!auth.session) { 147 error = $_('verify.emailUpdateRequiresAuth') 148 return 149 } 150 151 submitting = true 152 error = null 153 154 try { 155 await api.updateEmail(auth.session.accessJwt, newEmail.trim(), verificationCode.trim()) 156 success = true 157 successPurpose = 'email-update' 158 successChannel = 'email' 159 } catch (e: any) { 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: any) { 182 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(identifier.trim()) 195 resendMessage = $_('verify.codeResentDetail') 196 } catch (e: any) { 197 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="#/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="#/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 !auth.session} 260 <div class="message warning">{$_('verify.emailUpdateRequiresAuth')}</div> 261 <div class="actions"> 262 <a href="#/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="#/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="#/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="#/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="#/register" class="btn">{$_('verify.createAccount')}</a> 411 <a href="#/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>