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