this repo has no description
1<script lang="ts">
2 import { register, getAuthState } from '../lib/auth.svelte'
3 import { navigate } from '../lib/router.svelte'
4 import { api, ApiError, type VerificationChannel, type DidType } from '../lib/api'
5 import { _ } from '../lib/i18n'
6
7 const STORAGE_KEY = 'tranquil_pds_pending_verification'
8
9 let handle = $state('')
10 let email = $state('')
11 let password = $state('')
12 let confirmPassword = $state('')
13 let inviteCode = $state('')
14 let verificationChannel = $state<VerificationChannel>('email')
15 let discordId = $state('')
16 let telegramUsername = $state('')
17 let signalNumber = $state('')
18 let didType = $state<DidType>('plc')
19 let externalDid = $state('')
20 let submitting = $state(false)
21 let error = $state<string | null>(null)
22 let serverInfo = $state<{
23 availableUserDomains: string[]
24 inviteCodeRequired: boolean
25 availableCommsChannels?: string[]
26 } | null>(null)
27 let loadingServerInfo = $state(true)
28 let serverInfoLoaded = false
29
30 const auth = getAuthState()
31
32 $effect(() => {
33 if (!serverInfoLoaded) {
34 serverInfoLoaded = true
35 loadServerInfo()
36 }
37 })
38
39 async function loadServerInfo() {
40 try {
41 serverInfo = await api.describeServer()
42 } catch (e) {
43 console.error('Failed to load server info:', e)
44 } finally {
45 loadingServerInfo = false
46 }
47 }
48
49 let handleHasDot = $derived(handle.includes('.'))
50
51 function isChannelAvailable(channel: string): boolean {
52 const available = serverInfo?.availableCommsChannels ?? ['email']
53 return available.includes(channel)
54 }
55
56 function validateForm(): string | null {
57 if (!handle.trim()) return $_('register.validation.handleRequired')
58 if (handle.includes('.')) return $_('register.validation.handleNoDots')
59 if (!password) return $_('register.validation.passwordRequired')
60 if (password.length < 8) return $_('register.validation.passwordLength')
61 if (password !== confirmPassword) return $_('register.validation.passwordsMismatch')
62 if (serverInfo?.inviteCodeRequired && !inviteCode.trim()) {
63 return $_('register.validation.inviteCodeRequired')
64 }
65 if (didType === 'web-external') {
66 if (!externalDid.trim()) return $_('register.validation.externalDidRequired')
67 if (!externalDid.trim().startsWith('did:web:')) return $_('register.validation.externalDidFormat')
68 }
69 switch (verificationChannel) {
70 case 'email':
71 if (!email.trim()) return $_('register.validation.emailRequired')
72 break
73 case 'discord':
74 if (!discordId.trim()) return $_('register.validation.discordIdRequired')
75 break
76 case 'telegram':
77 if (!telegramUsername.trim()) return $_('register.validation.telegramRequired')
78 break
79 case 'signal':
80 if (!signalNumber.trim()) return $_('register.validation.signalRequired')
81 break
82 }
83 return null
84 }
85
86 async function handleSubmit(e: Event) {
87 e.preventDefault()
88 const validationError = validateForm()
89 if (validationError) {
90 error = validationError
91 return
92 }
93 submitting = true
94 error = null
95 try {
96 const result = await register({
97 handle: handle.trim(),
98 email: email.trim(),
99 password,
100 inviteCode: inviteCode.trim() || undefined,
101 didType,
102 did: didType === 'web-external' ? externalDid.trim() : undefined,
103 verificationChannel,
104 discordId: discordId.trim() || undefined,
105 telegramUsername: telegramUsername.trim() || undefined,
106 signalNumber: signalNumber.trim() || undefined,
107 })
108 if (result.verificationRequired) {
109 localStorage.setItem(STORAGE_KEY, JSON.stringify({
110 did: result.did,
111 handle: result.handle,
112 channel: result.verificationChannel,
113 }))
114 navigate('/verify')
115 } else {
116 navigate('/dashboard')
117 }
118 } catch (err: any) {
119 if (err instanceof ApiError) {
120 error = err.message || 'Registration failed'
121 } else if (err instanceof Error) {
122 error = err.message || 'Registration failed'
123 } else {
124 error = 'Registration failed'
125 }
126 } finally {
127 submitting = false
128 }
129 }
130
131 let fullHandle = $derived(() => {
132 if (!handle.trim()) return ''
133 if (handle.includes('.')) return handle.trim()
134 const domain = serverInfo?.availableUserDomains?.[0]
135 if (domain) return `${handle.trim()}.${domain}`
136 return handle.trim()
137 })
138</script>
139
140<div class="register-page">
141 <div class="migrate-callout">
142 <div class="migrate-icon">↗</div>
143 <div class="migrate-content">
144 <strong>{$_('register.migrateTitle')}</strong>
145 <p>{$_('register.migrateDescription')}</p>
146 <a href="https://pdsmoover.com/moover" target="_blank" rel="noopener" class="migrate-link">
147 {$_('register.migrateLink')} →
148 </a>
149 </div>
150 </div>
151
152 {#if error}
153 <div class="message error">{error}</div>
154 {/if}
155
156 <h1>{$_('register.title')}</h1>
157 <p class="subtitle">{$_('register.subtitle')}</p>
158
159 {#if loadingServerInfo}
160 <p class="loading">{$_('common.loading')}</p>
161 {:else}
162 <form onsubmit={(e) => { e.preventDefault(); handleSubmit(e); }}>
163 <div class="field">
164 <label for="handle">{$_('register.handle')}</label>
165 <input
166 id="handle"
167 type="text"
168 bind:value={handle}
169 placeholder={$_('register.handlePlaceholder')}
170 disabled={submitting}
171 required
172 />
173 {#if handleHasDot}
174 <p class="hint warning">{$_('register.handleDotWarning')}</p>
175 {:else if fullHandle()}
176 <p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
177 {/if}
178 </div>
179
180 <div class="field">
181 <label for="password">{$_('register.password')}</label>
182 <input
183 id="password"
184 type="password"
185 bind:value={password}
186 placeholder={$_('register.passwordPlaceholder')}
187 disabled={submitting}
188 required
189 minlength="8"
190 />
191 </div>
192
193 <div class="field">
194 <label for="confirm-password">{$_('register.confirmPassword')}</label>
195 <input
196 id="confirm-password"
197 type="password"
198 bind:value={confirmPassword}
199 placeholder={$_('register.confirmPasswordPlaceholder')}
200 disabled={submitting}
201 required
202 />
203 </div>
204
205 <fieldset class="section-fieldset">
206 <legend>{$_('register.identityType')}</legend>
207 <p class="section-hint">{$_('register.identityHint')}</p>
208
209 <div class="radio-group">
210 <label class="radio-label">
211 <input type="radio" name="didType" value="plc" bind:group={didType} disabled={submitting} />
212 <span class="radio-content">
213 <strong>{$_('register.didPlc')}</strong> {$_('register.didPlcRecommended')}
214 <span class="radio-hint">{$_('register.didPlcHint')}</span>
215 </span>
216 </label>
217
218 <label class="radio-label">
219 <input type="radio" name="didType" value="web" bind:group={didType} disabled={submitting} />
220 <span class="radio-content">
221 <strong>{$_('register.didWeb')}</strong>
222 <span class="radio-hint">{$_('register.didWebHint')}</span>
223 </span>
224 </label>
225
226 <label class="radio-label">
227 <input type="radio" name="didType" value="web-external" bind:group={didType} disabled={submitting} />
228 <span class="radio-content">
229 <strong>{$_('register.didWebBYOD')}</strong>
230 <span class="radio-hint">{$_('register.didWebBYODHint')}</span>
231 </span>
232 </label>
233 </div>
234
235 {#if didType === 'web'}
236 <div class="warning-box">
237 <strong>{$_('register.didWebWarningTitle')}</strong>
238 <ul>
239 <li><strong>{$_('register.didWebWarning1')}</strong> {$_('register.didWebWarning1Detail', { values: { did: `did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}` } })}</li>
240 <li><strong>{$_('register.didWebWarning2')}</strong> {$_('register.didWebWarning2Detail')}</li>
241 <li><strong>{$_('register.didWebWarning3')}</strong> {$_('register.didWebWarning3Detail')}</li>
242 <li><strong>{$_('register.didWebWarning4')}</strong> {$_('register.didWebWarning4Detail')}</li>
243 </ul>
244 </div>
245 {/if}
246
247 {#if didType === 'web-external'}
248 <div class="field">
249 <label for="external-did">{$_('register.externalDid')}</label>
250 <input
251 id="external-did"
252 type="text"
253 bind:value={externalDid}
254 placeholder={$_('register.externalDidPlaceholder')}
255 disabled={submitting}
256 required
257 />
258 <p class="hint">{$_('register.externalDidHint')}</p>
259 </div>
260 {/if}
261 </fieldset>
262
263 <fieldset class="section-fieldset">
264 <legend>{$_('register.contactMethod')}</legend>
265 <p class="section-hint">{$_('register.contactMethodHint')}</p>
266
267 <div class="field">
268 <label for="verification-channel">{$_('register.verificationMethod')}</label>
269 <select id="verification-channel" bind:value={verificationChannel} disabled={submitting}>
270 <option value="email">{$_('register.email')}</option>
271 <option value="discord" disabled={!isChannelAvailable('discord')}>
272 {$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
273 </option>
274 <option value="telegram" disabled={!isChannelAvailable('telegram')}>
275 {$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`}
276 </option>
277 <option value="signal" disabled={!isChannelAvailable('signal')}>
278 {$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`}
279 </option>
280 </select>
281 </div>
282
283 {#if verificationChannel === 'email'}
284 <div class="field">
285 <label for="email">{$_('register.emailAddress')}</label>
286 <input
287 id="email"
288 type="email"
289 bind:value={email}
290 placeholder={$_('register.emailPlaceholder')}
291 disabled={submitting}
292 required
293 />
294 </div>
295 {:else if verificationChannel === 'discord'}
296 <div class="field">
297 <label for="discord-id">{$_('register.discordId')}</label>
298 <input
299 id="discord-id"
300 type="text"
301 bind:value={discordId}
302 placeholder={$_('register.discordIdPlaceholder')}
303 disabled={submitting}
304 required
305 />
306 <p class="hint">{$_('register.discordIdHint')}</p>
307 </div>
308 {:else if verificationChannel === 'telegram'}
309 <div class="field">
310 <label for="telegram-username">{$_('register.telegramUsername')}</label>
311 <input
312 id="telegram-username"
313 type="text"
314 bind:value={telegramUsername}
315 placeholder={$_('register.telegramUsernamePlaceholder')}
316 disabled={submitting}
317 required
318 />
319 </div>
320 {:else if verificationChannel === 'signal'}
321 <div class="field">
322 <label for="signal-number">{$_('register.signalNumber')}</label>
323 <input
324 id="signal-number"
325 type="tel"
326 bind:value={signalNumber}
327 placeholder={$_('register.signalNumberPlaceholder')}
328 disabled={submitting}
329 required
330 />
331 <p class="hint">{$_('register.signalNumberHint')}</p>
332 </div>
333 {/if}
334 </fieldset>
335
336 {#if serverInfo?.inviteCodeRequired}
337 <div class="field">
338 <label for="invite-code">{$_('register.inviteCode')} <span class="required">{$_('register.inviteCodeRequired')}</span></label>
339 <input
340 id="invite-code"
341 type="text"
342 bind:value={inviteCode}
343 placeholder={$_('register.inviteCodePlaceholder')}
344 disabled={submitting}
345 required
346 />
347 </div>
348 {/if}
349
350 <button type="submit" disabled={submitting}>
351 {submitting ? $_('register.creating') : $_('register.createButton')}
352 </button>
353 </form>
354
355 <p class="link-text">
356 {$_('register.alreadyHaveAccount')} <a href="#/login">{$_('register.signIn')}</a>
357 </p>
358 <p class="link-text">
359 {$_('register.wantPasswordless')} <a href="#/register-passkey">{$_('register.createPasskeyAccount')}</a>
360 </p>
361 {/if}
362</div>
363
364<style>
365 .register-page {
366 max-width: var(--width-sm);
367 margin: var(--space-9) auto;
368 padding: var(--space-7);
369 }
370
371 .migrate-callout {
372 display: flex;
373 gap: var(--space-4);
374 padding: var(--space-5);
375 background: var(--accent-muted);
376 border: 1px solid var(--accent);
377 border-radius: var(--radius-xl);
378 margin-bottom: var(--space-6);
379 }
380
381 .migrate-icon {
382 font-size: var(--text-2xl);
383 line-height: 1;
384 color: var(--accent);
385 }
386
387 .migrate-content {
388 flex: 1;
389 }
390
391 .migrate-content strong {
392 display: block;
393 color: var(--text-primary);
394 margin-bottom: var(--space-2);
395 }
396
397 .migrate-content p {
398 margin: 0 0 var(--space-3) 0;
399 font-size: var(--text-sm);
400 color: var(--text-secondary);
401 line-height: var(--leading-relaxed);
402 }
403
404 .migrate-link {
405 font-size: var(--text-sm);
406 font-weight: var(--font-medium);
407 color: var(--accent);
408 text-decoration: none;
409 }
410
411 .migrate-link:hover {
412 text-decoration: underline;
413 }
414
415 h1 {
416 margin: 0 0 var(--space-3) 0;
417 }
418
419 .subtitle {
420 color: var(--text-secondary);
421 margin: 0 0 var(--space-7) 0;
422 }
423
424 .loading {
425 text-align: center;
426 color: var(--text-secondary);
427 }
428
429 form {
430 display: flex;
431 flex-direction: column;
432 gap: var(--space-5);
433 }
434
435 .required {
436 color: var(--error-text);
437 }
438
439 .section-fieldset {
440 border: 1px solid var(--border-color);
441 border-radius: var(--radius-lg);
442 padding: var(--space-5);
443 }
444
445 .section-fieldset legend {
446 font-weight: var(--font-semibold);
447 padding: 0 var(--space-3);
448 }
449
450 .section-hint {
451 font-size: var(--text-sm);
452 color: var(--text-secondary);
453 margin: 0 0 var(--space-5) 0;
454 }
455
456 .radio-group {
457 display: flex;
458 flex-direction: column;
459 gap: var(--space-4);
460 }
461
462 .radio-label {
463 display: flex;
464 align-items: flex-start;
465 gap: var(--space-3);
466 cursor: pointer;
467 font-size: var(--text-base);
468 font-weight: var(--font-normal);
469 margin-bottom: 0;
470 }
471
472 .radio-label input[type="radio"] {
473 margin-top: var(--space-1);
474 width: auto;
475 }
476
477 .radio-content {
478 display: flex;
479 flex-direction: column;
480 gap: var(--space-1);
481 }
482
483 .radio-hint {
484 font-size: var(--text-xs);
485 color: var(--text-secondary);
486 }
487
488 .warning-box {
489 margin-top: var(--space-5);
490 padding: var(--space-5);
491 background: var(--warning-bg);
492 border: 1px solid var(--warning-border);
493 border-radius: var(--radius-lg);
494 font-size: var(--text-sm);
495 }
496
497 .warning-box strong {
498 color: var(--warning-text);
499 }
500
501 .warning-box ul {
502 margin: var(--space-4) 0 0 0;
503 padding-left: var(--space-5);
504 }
505
506 .warning-box li {
507 margin-bottom: var(--space-3);
508 line-height: var(--leading-normal);
509 }
510
511 .warning-box li:last-child {
512 margin-bottom: 0;
513 }
514
515 button[type="submit"] {
516 margin-top: var(--space-3);
517 }
518
519 .link-text {
520 text-align: center;
521 margin-top: var(--space-6);
522 color: var(--text-secondary);
523 }
524
525 .link-text a {
526 color: var(--accent);
527 }
528</style>