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