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