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