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'
17
18 let mode = $state<VerificationMode>('signup')
19 let pendingVerification = $state<PendingVerification | null>(null)
20 let verificationCode = $state('')
21 let identifier = $state('')
22 let submitting = $state(false)
23 let resendingCode = $state(false)
24 let error = $state<string | null>(null)
25 let resendMessage = $state<string | null>(null)
26 let success = $state(false)
27 let autoSubmitting = $state(false)
28 let successPurpose = $state<string | null>(null)
29 let successChannel = $state<string | null>(null)
30
31 const auth = getAuthState()
32
33
34 function parseQueryParams() {
35 const hash = window.location.hash
36 const queryIndex = hash.indexOf('?')
37 if (queryIndex === -1) return {}
38
39 const queryString = hash.slice(queryIndex + 1)
40 const params: Record<string, string> = {}
41 for (const pair of queryString.split('&')) {
42 const [key, value] = pair.split('=')
43 if (key && value) {
44 params[decodeURIComponent(key)] = decodeURIComponent(value)
45 }
46 }
47 return params
48 }
49
50 onMount(async () => {
51 const params = parseQueryParams()
52
53 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' && auth.session) {
79 clearPendingVerification()
80 navigate('/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: any) {
101 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: any) {
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 handleResendCode() {
138 if (mode === 'signup') {
139 if (!pendingVerification || resendingCode) return
140
141 resendingCode = true
142 resendMessage = null
143 error = null
144
145 try {
146 await resendVerification(pendingVerification.did)
147 resendMessage = $_('verify.codeResent')
148 } catch (e: any) {
149 error = e.message || 'Failed to resend code'
150 } finally {
151 resendingCode = false
152 }
153 } else {
154 if (!identifier.trim() || resendingCode) return
155
156 resendingCode = true
157 resendMessage = null
158 error = null
159
160 try {
161 await api.resendMigrationVerification(identifier.trim())
162 resendMessage = $_('verify.codeResentDetail')
163 } catch (e: any) {
164 error = e.message || 'Failed to resend verification'
165 } finally {
166 resendingCode = false
167 }
168 }
169 }
170
171 function channelLabel(ch: string): string {
172 switch (ch) {
173 case 'email': return $_('register.email')
174 case 'discord': return $_('register.discord')
175 case 'telegram': return $_('register.telegram')
176 case 'signal': return $_('register.signal')
177 default: return ch
178 }
179 }
180
181 function goToNextStep() {
182 if (successPurpose === 'migration') {
183 navigate('/login')
184 } else if (successChannel === 'email') {
185 navigate('/settings')
186 } else {
187 navigate('/comms')
188 }
189 }
190</script>
191
192<div class="verify-page">
193 {#if autoSubmitting}
194 <div class="loading-container">
195 <h1>{$_('verify.verifying')}</h1>
196 <p class="subtitle">{$_('verify.pleaseWait')}</p>
197 </div>
198 {:else if success}
199 <div class="success-container">
200 <h1>{$_('verify.verified')}</h1>
201 {#if successPurpose === 'migration' || successPurpose === 'signup'}
202 <p class="subtitle">{$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })}</p>
203 <p class="info-text">{$_('verify.canNowSignIn')}</p>
204 <div class="actions">
205 <a href="#/login" class="btn">{$_('verify.signIn')}</a>
206 </div>
207 {:else}
208 <p class="subtitle">
209 {$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })}
210 </p>
211 <div class="actions">
212 <button class="btn" onclick={goToNextStep}>{$_('verify.continue')}</button>
213 </div>
214 {/if}
215 </div>
216 {:else if mode === 'token'}
217 <h1>{$_('verify.tokenTitle')}</h1>
218 <p class="subtitle">{$_('verify.tokenSubtitle')}</p>
219
220 {#if error}
221 <div class="message error">{error}</div>
222 {/if}
223
224 {#if resendMessage}
225 <div class="message success">{resendMessage}</div>
226 {/if}
227
228 <form onsubmit={(e) => { e.preventDefault(); handleTokenVerification(); }}>
229 <div class="field">
230 <label for="identifier">{$_('verify.identifierLabel')}</label>
231 <input
232 id="identifier"
233 type="text"
234 bind:value={identifier}
235 placeholder={$_('verify.identifierPlaceholder')}
236 disabled={submitting}
237 required
238 autocomplete="email"
239 />
240 <p class="field-help">{$_('verify.identifierHelp')}</p>
241 </div>
242
243 <div class="field">
244 <label for="verification-code">{$_('verify.codeLabel')}</label>
245 <input
246 id="verification-code"
247 type="text"
248 bind:value={verificationCode}
249 placeholder={$_('verify.codePlaceholder')}
250 disabled={submitting}
251 required
252 autocomplete="off"
253 class="token-input"
254 />
255 <p class="field-help">{$_('verify.codeHelp')}</p>
256 </div>
257
258 <button type="submit" disabled={submitting || !verificationCode.trim() || !identifier.trim()}>
259 {submitting ? $_('verify.verifying') : $_('verify.verify')}
260 </button>
261
262 <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode || !identifier.trim()}>
263 {resendingCode ? $_('verify.sending') : $_('verify.resendCode')}
264 </button>
265 </form>
266
267 <p class="link-text">
268 <a href="#/login">{$_('verify.backToLogin')}</a>
269 </p>
270 {:else if pendingVerification}
271 <h1>{$_('verify.title')}</h1>
272 <p class="subtitle">
273 {$_('verify.subtitle', { values: { channel: channelLabel(pendingVerification.channel) } })}
274 </p>
275 <p class="handle-info">{$_('verify.verifyingAccount', { values: { handle: pendingVerification.handle } })}</p>
276
277 {#if error}
278 <div class="message error">{error}</div>
279 {/if}
280
281 {#if resendMessage}
282 <div class="message success">{resendMessage}</div>
283 {/if}
284
285 <form onsubmit={(e) => { e.preventDefault(); handleSignupVerification(e); }}>
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.codeHelp')}</p>
299 </div>
300
301 <button type="submit" disabled={submitting || !verificationCode.trim()}>
302 {submitting ? $_('verify.verifying') : $_('verify.verifyButton')}
303 </button>
304
305 <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}>
306 {resendingCode ? $_('verify.resending') : $_('verify.resendCode')}
307 </button>
308 </form>
309
310 <p class="link-text">
311 <a href="#/register" onclick={() => clearPendingVerification()}>{$_('verify.startOver')}</a>
312 </p>
313 {:else}
314 <h1>{$_('verify.title')}</h1>
315 <p class="subtitle">{$_('verify.noPending')}</p>
316 <p class="info-text">{$_('verify.noPendingInfo')}</p>
317
318 <div class="actions">
319 <a href="#/register" class="btn">{$_('verify.createAccount')}</a>
320 <a href="#/login" class="btn secondary">{$_('verify.signIn')}</a>
321 </div>
322 {/if}
323</div>
324
325<style>
326 .verify-page {
327 max-width: var(--width-sm);
328 margin: var(--space-9) auto;
329 padding: var(--space-7);
330 }
331
332 h1 {
333 margin: 0 0 var(--space-3) 0;
334 }
335
336 .subtitle {
337 color: var(--text-secondary);
338 margin: 0 0 var(--space-4) 0;
339 }
340
341 .handle-info {
342 font-size: var(--text-sm);
343 color: var(--text-secondary);
344 margin: 0 0 var(--space-6) 0;
345 }
346
347 .info-text {
348 color: var(--text-secondary);
349 margin: var(--space-4) 0 var(--space-6) 0;
350 }
351
352 form {
353 display: flex;
354 flex-direction: column;
355 gap: var(--space-4);
356 }
357
358 .field-help {
359 font-size: var(--text-xs);
360 color: var(--text-secondary);
361 margin: var(--space-1) 0 0 0;
362 }
363
364 .token-input {
365 font-family: var(--font-mono);
366 letter-spacing: 0.05em;
367 }
368
369 .link-text {
370 text-align: center;
371 margin-top: var(--space-6);
372 font-size: var(--text-sm);
373 }
374
375 .link-text a {
376 color: var(--text-secondary);
377 }
378
379 .actions {
380 display: flex;
381 gap: var(--space-4);
382 }
383
384 .btn {
385 flex: 1;
386 display: inline-block;
387 padding: var(--space-4);
388 background: var(--accent);
389 color: var(--text-inverse);
390 border: none;
391 border-radius: var(--radius-md);
392 font-size: var(--text-base);
393 font-weight: var(--font-medium);
394 cursor: pointer;
395 text-decoration: none;
396 text-align: center;
397 }
398
399 .btn:hover {
400 background: var(--accent-hover);
401 text-decoration: none;
402 }
403
404 .btn.secondary {
405 background: transparent;
406 color: var(--accent);
407 border: 1px solid var(--accent);
408 }
409
410 .btn.secondary:hover {
411 background: var(--accent);
412 color: var(--text-inverse);
413 }
414
415 .success-container,
416 .loading-container {
417 text-align: center;
418 }
419
420 .success-container .actions {
421 justify-content: center;
422 margin-top: var(--space-6);
423 }
424
425 .success-container .btn {
426 flex: none;
427 padding: var(--space-4) var(--space-8);
428 }
429</style>