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 $_('register.subtitleKeyChoice')
146 case 'initial-did-doc': return $_('register.subtitleInitialDidDoc')
147 case 'creating': return $_('register.creating')
148 case 'verify': return $_('register.subtitleVerify', { values: { channel: channelLabel(flow.info.verificationChannel) } })
149 case 'updated-did-doc': return $_('register.subtitleUpdatedDidDoc')
150 case 'activating': return $_('register.subtitleActivating')
151 case 'redirect-to-dashboard': return $_('register.subtitleComplete')
152 default: return ''
153 }
154 }
155</script>
156
157<div class="register-page">
158 <header class="page-header">
159 <h1>{$_('register.title')}</h1>
160 <p class="subtitle">{getSubtitle()}</p>
161 </header>
162
163 {#if flow?.state.error}
164 <div class="message error">{flow.state.error}</div>
165 {/if}
166
167 {#if loadingServerInfo || !flow}
168 <p class="loading">{$_('common.loading')}</p>
169
170 {:else if flow.state.step === 'info'}
171 <div class="migrate-callout">
172 <div class="migrate-icon">↗</div>
173 <div class="migrate-content">
174 <strong>{$_('register.migrateTitle')}</strong>
175 <p>{$_('register.migrateDescription')}</p>
176 <a href="https://pdsmoover.com/moover" target="_blank" rel="noopener" class="migrate-link">
177 {$_('register.migrateLink')} →
178 </a>
179 </div>
180 </div>
181
182 <div class="split-layout sidebar-right">
183 <div class="form-section">
184 <form onsubmit={handleInfoSubmit}>
185 <div class="field">
186 <label for="handle">{$_('register.handle')}</label>
187 <input
188 id="handle"
189 type="text"
190 bind:value={flow.info.handle}
191 placeholder={$_('register.handlePlaceholder')}
192 disabled={flow.state.submitting}
193 required
194 />
195 {#if flow.info.handle.includes('.')}
196 <p class="hint warning">{$_('register.handleDotWarning')}</p>
197 {:else if fullHandle()}
198 <p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
199 {/if}
200 </div>
201
202 <div class="form-row">
203 <div class="field">
204 <label for="password">{$_('register.password')}</label>
205 <input
206 id="password"
207 type="password"
208 bind:value={flow.info.password}
209 placeholder={$_('register.passwordPlaceholder')}
210 disabled={flow.state.submitting}
211 required
212 minlength="8"
213 />
214 </div>
215
216 <div class="field">
217 <label for="confirm-password">{$_('register.confirmPassword')}</label>
218 <input
219 id="confirm-password"
220 type="password"
221 bind:value={confirmPassword}
222 placeholder={$_('register.confirmPasswordPlaceholder')}
223 disabled={flow.state.submitting}
224 required
225 />
226 </div>
227 </div>
228
229 <fieldset class="section-fieldset">
230 <legend>{$_('register.identityType')}</legend>
231 <div class="radio-group">
232 <label class="radio-label">
233 <input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
234 <span class="radio-content">
235 <strong>{$_('register.didPlc')}</strong> {$_('register.didPlcRecommended')}
236 <span class="radio-hint">{$_('register.didPlcHint')}</span>
237 </span>
238 </label>
239
240 <label class="radio-label">
241 <input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting} />
242 <span class="radio-content">
243 <strong>{$_('register.didWeb')}</strong>
244 <span class="radio-hint">{$_('register.didWebHint')}</span>
245 </span>
246 </label>
247
248 <label class="radio-label">
249 <input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} />
250 <span class="radio-content">
251 <strong>{$_('register.didWebBYOD')}</strong>
252 <span class="radio-hint">{$_('register.didWebBYODHint')}</span>
253 </span>
254 </label>
255 </div>
256
257 {#if flow.info.didType === 'web'}
258 <div class="warning-box">
259 <strong>{$_('register.didWebWarningTitle')}</strong>
260 <ul>
261 <li><strong>{$_('register.didWebWarning1')}</strong> {$_('register.didWebWarning1Detail', { values: { did: `did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}` } })}</li>
262 <li><strong>{$_('register.didWebWarning2')}</strong> {$_('register.didWebWarning2Detail')}</li>
263 <li><strong>{$_('register.didWebWarning3')}</strong> {$_('register.didWebWarning3Detail')}</li>
264 <li><strong>{$_('register.didWebWarning4')}</strong> {$_('register.didWebWarning4Detail')}</li>
265 </ul>
266 </div>
267 {/if}
268
269 {#if flow.info.didType === 'web-external'}
270 <div class="field">
271 <label for="external-did">{$_('register.externalDid')}</label>
272 <input
273 id="external-did"
274 type="text"
275 bind:value={flow.info.externalDid}
276 placeholder={$_('register.externalDidPlaceholder')}
277 disabled={flow.state.submitting}
278 required
279 />
280 <p class="hint">{$_('register.externalDidHint')}</p>
281 </div>
282 {/if}
283 </fieldset>
284
285 <fieldset class="section-fieldset">
286 <legend>{$_('register.contactMethod')}</legend>
287 <div class="contact-fields">
288 <div class="field">
289 <label for="verification-channel">{$_('register.verificationMethod')}</label>
290 <select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
291 <option value="email">{$_('register.email')}</option>
292 <option value="discord" disabled={!isChannelAvailable('discord')}>
293 {$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
294 </option>
295 <option value="telegram" disabled={!isChannelAvailable('telegram')}>
296 {$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`}
297 </option>
298 <option value="signal" disabled={!isChannelAvailable('signal')}>
299 {$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`}
300 </option>
301 </select>
302 </div>
303
304 {#if flow.info.verificationChannel === 'email'}
305 <div class="field">
306 <label for="email">{$_('register.emailAddress')}</label>
307 <input
308 id="email"
309 type="email"
310 bind:value={flow.info.email}
311 placeholder={$_('register.emailPlaceholder')}
312 disabled={flow.state.submitting}
313 required
314 />
315 </div>
316 {:else if flow.info.verificationChannel === 'discord'}
317 <div class="field">
318 <label for="discord-id">{$_('register.discordId')}</label>
319 <input
320 id="discord-id"
321 type="text"
322 bind:value={flow.info.discordId}
323 placeholder={$_('register.discordIdPlaceholder')}
324 disabled={flow.state.submitting}
325 required
326 />
327 <p class="hint">{$_('register.discordIdHint')}</p>
328 </div>
329 {:else if flow.info.verificationChannel === 'telegram'}
330 <div class="field">
331 <label for="telegram-username">{$_('register.telegramUsername')}</label>
332 <input
333 id="telegram-username"
334 type="text"
335 bind:value={flow.info.telegramUsername}
336 placeholder={$_('register.telegramUsernamePlaceholder')}
337 disabled={flow.state.submitting}
338 required
339 />
340 </div>
341 {:else if flow.info.verificationChannel === 'signal'}
342 <div class="field">
343 <label for="signal-number">{$_('register.signalNumber')}</label>
344 <input
345 id="signal-number"
346 type="tel"
347 bind:value={flow.info.signalNumber}
348 placeholder={$_('register.signalNumberPlaceholder')}
349 disabled={flow.state.submitting}
350 required
351 />
352 <p class="hint">{$_('register.signalNumberHint')}</p>
353 </div>
354 {/if}
355 </div>
356 </fieldset>
357
358 {#if serverInfo?.inviteCodeRequired}
359 <div class="field">
360 <label for="invite-code">{$_('register.inviteCode')} <span class="required">{$_('register.inviteCodeRequired')}</span></label>
361 <input
362 id="invite-code"
363 type="text"
364 bind:value={flow.info.inviteCode}
365 placeholder={$_('register.inviteCodePlaceholder')}
366 disabled={flow.state.submitting}
367 required
368 />
369 </div>
370 {/if}
371
372 <button type="submit" disabled={flow.state.submitting}>
373 {flow.state.submitting ? $_('register.creating') : $_('register.createButton')}
374 </button>
375 </form>
376
377 <div class="form-links">
378 <p class="link-text">
379 {$_('register.alreadyHaveAccount')} <a href="#/login">{$_('register.signIn')}</a>
380 </p>
381 <p class="link-text">
382 {$_('register.wantPasswordless')} <a href="#/register-passkey">{$_('register.createPasskeyAccount')}</a>
383 </p>
384 </div>
385 </div>
386
387 <aside class="info-panel">
388 <h3>{$_('register.identityHint')}</h3>
389 <p>{$_('register.infoIdentityDesc')}</p>
390
391 <h3>{$_('register.contactMethodHint')}</h3>
392 <p>{$_('register.infoContactDesc')}</p>
393
394 <h3>{$_('register.infoNextTitle')}</h3>
395 <p>{$_('register.infoNextDesc')}</p>
396 </aside>
397 </div>
398
399 {:else if flow.state.step === 'key-choice'}
400 <KeyChoiceStep {flow} />
401
402 {:else if flow.state.step === 'initial-did-doc'}
403 <DidDocStep
404 {flow}
405 type="initial"
406 onConfirm={handleCreateAccount}
407 onBack={() => flow?.goBack()}
408 />
409
410 {:else if flow.state.step === 'creating'}
411 <p class="loading">{$_('register.creating')}</p>
412
413 {:else if flow.state.step === 'verify'}
414 <VerificationStep {flow} />
415
416 {:else if flow.state.step === 'updated-did-doc'}
417 <DidDocStep
418 {flow}
419 type="updated"
420 onConfirm={() => flow?.activateAccount()}
421 />
422
423 {:else if flow.state.step === 'redirect-to-dashboard'}
424 <p class="loading">{$_('register.redirecting')}</p>
425 {/if}
426</div>
427
428<style>
429 .register-page {
430 max-width: var(--width-lg);
431 margin: var(--space-9) auto;
432 padding: var(--space-7);
433 }
434
435 .page-header {
436 margin-bottom: var(--space-6);
437 }
438
439 .form-section {
440 min-width: 0;
441 }
442
443 .form-links {
444 margin-top: var(--space-6);
445 }
446
447 .migrate-callout {
448 display: flex;
449 gap: var(--space-4);
450 padding: var(--space-5);
451 background: var(--accent-muted);
452 border: 1px solid var(--accent);
453 border-radius: var(--radius-xl);
454 margin-bottom: var(--space-6);
455 }
456
457 .migrate-icon {
458 font-size: var(--text-2xl);
459 line-height: 1;
460 color: var(--accent);
461 }
462
463 .migrate-content {
464 flex: 1;
465 }
466
467 .migrate-content strong {
468 display: block;
469 color: var(--text-primary);
470 margin-bottom: var(--space-2);
471 }
472
473 .migrate-content p {
474 margin: 0 0 var(--space-3) 0;
475 font-size: var(--text-sm);
476 color: var(--text-secondary);
477 line-height: var(--leading-relaxed);
478 }
479
480 .migrate-link {
481 font-size: var(--text-sm);
482 font-weight: var(--font-medium);
483 color: var(--accent);
484 text-decoration: none;
485 }
486
487 .migrate-link:hover {
488 text-decoration: underline;
489 }
490
491 h1 {
492 margin: 0 0 var(--space-3) 0;
493 }
494
495 .subtitle {
496 color: var(--text-secondary);
497 margin: 0 0 var(--space-7) 0;
498 }
499
500 .loading {
501 text-align: center;
502 color: var(--text-secondary);
503 }
504
505 form {
506 display: flex;
507 flex-direction: column;
508 gap: var(--space-5);
509 }
510
511 .required {
512 color: var(--error-text);
513 }
514
515 .section-hint {
516 font-size: var(--text-sm);
517 color: var(--text-secondary);
518 margin: 0 0 var(--space-5) 0;
519 }
520
521 .radio-group {
522 display: flex;
523 flex-direction: column;
524 gap: var(--space-4);
525 }
526
527 .radio-label {
528 display: flex;
529 align-items: flex-start;
530 gap: var(--space-3);
531 cursor: pointer;
532 font-size: var(--text-base);
533 font-weight: var(--font-normal);
534 margin-bottom: 0;
535 }
536
537 .radio-label input[type="radio"] {
538 margin-top: var(--space-1);
539 width: auto;
540 }
541
542 .radio-content {
543 display: flex;
544 flex-direction: column;
545 gap: var(--space-1);
546 }
547
548 .radio-hint {
549 font-size: var(--text-xs);
550 color: var(--text-secondary);
551 }
552
553 .warning-box {
554 margin-top: var(--space-5);
555 padding: var(--space-5);
556 background: var(--warning-bg);
557 border: 1px solid var(--warning-border);
558 border-radius: var(--radius-lg);
559 font-size: var(--text-sm);
560 }
561
562 .warning-box strong {
563 color: var(--warning-text);
564 }
565
566 .warning-box ul {
567 margin: var(--space-4) 0 0 0;
568 padding-left: var(--space-5);
569 }
570
571 .warning-box li {
572 margin-bottom: var(--space-3);
573 line-height: var(--leading-normal);
574 }
575
576 .warning-box li:last-child {
577 margin-bottom: 0;
578 }
579
580 button[type="submit"] {
581 margin-top: var(--space-3);
582 }
583
584 .link-text {
585 text-align: center;
586 margin-top: var(--space-6);
587 color: var(--text-secondary);
588 }
589
590 .link-text a {
591 color: var(--accent);
592 }
593</style>