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