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>