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>