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>