this repo has no description
1<script lang="ts">
2 import { navigate, routes, getFullUrl } 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 selfHostedDidWebEnabled?: boolean
17 } | null>(null)
18 let loadingServerInfo = $state(true)
19 let serverInfoLoaded = false
20
21 let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null)
22 let confirmPassword = $state('')
23
24 $effect(() => {
25 if (!serverInfoLoaded) {
26 serverInfoLoaded = true
27 loadServerInfo()
28 }
29 })
30
31 $effect(() => {
32 if (flow?.state.step === 'redirect-to-dashboard') {
33 navigate(routes.dashboard)
34 }
35 })
36
37 let creatingStarted = false
38 $effect(() => {
39 if (flow?.state.step === 'creating' && !creatingStarted) {
40 creatingStarted = true
41 flow.createPasswordAccount()
42 }
43 })
44
45 async function loadServerInfo() {
46 try {
47 serverInfo = await api.describeServer()
48 const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname
49 flow = createRegistrationFlow('password', hostname)
50 } catch (e) {
51 console.error('Failed to load server info:', e)
52 } finally {
53 loadingServerInfo = false
54 }
55 }
56
57 function validateInfoStep(): string | null {
58 if (!flow) return 'Flow not initialized'
59 const info = flow.info
60 if (!info.handle.trim()) return $_('register.validation.handleRequired')
61 if (info.handle.includes('.')) return $_('register.validation.handleNoDots')
62 if (!info.password) return $_('register.validation.passwordRequired')
63 if (info.password.length < 8) return $_('register.validation.passwordLength')
64 if (info.password !== confirmPassword) return $_('register.validation.passwordsMismatch')
65 if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) {
66 return $_('register.validation.inviteCodeRequired')
67 }
68 if (info.didType === 'web-external') {
69 if (!info.externalDid?.trim()) return $_('register.validation.externalDidRequired')
70 if (!info.externalDid.trim().startsWith('did:web:')) return $_('register.validation.externalDidFormat')
71 }
72 switch (info.verificationChannel) {
73 case 'email':
74 if (!info.email.trim()) return $_('register.validation.emailRequired')
75 break
76 case 'discord':
77 if (!info.discordId?.trim()) return $_('register.validation.discordIdRequired')
78 break
79 case 'telegram':
80 if (!info.telegramUsername?.trim()) return $_('register.validation.telegramRequired')
81 break
82 case 'signal':
83 if (!info.signalNumber?.trim()) return $_('register.validation.signalRequired')
84 break
85 }
86 return null
87 }
88
89 async function handleInfoSubmit(e: Event) {
90 e.preventDefault()
91 if (!flow) return
92
93 const validationError = validateInfoStep()
94 if (validationError) {
95 flow.setError(validationError)
96 return
97 }
98
99 flow.clearError()
100 flow.proceedFromInfo()
101 }
102
103 async function handleCreateAccount() {
104 if (!flow) return
105 await flow.createPasswordAccount()
106 }
107
108 async function handleComplete() {
109 if (flow) {
110 await flow.finalizeSession()
111 }
112 navigate(routes.dashboard)
113 }
114
115 function isChannelAvailable(ch: string): boolean {
116 const available = serverInfo?.availableCommsChannels ?? ['email']
117 return available.includes(ch)
118 }
119
120 function channelLabel(ch: string): string {
121 switch (ch) {
122 case 'email': return $_('register.email')
123 case 'discord': return $_('register.discord')
124 case 'telegram': return $_('register.telegram')
125 case 'signal': return $_('register.signal')
126 default: return ch
127 }
128 }
129
130 let fullHandle = $derived(() => {
131 if (!flow?.info.handle.trim()) return ''
132 if (flow.info.handle.includes('.')) return flow.info.handle.trim()
133 const domain = serverInfo?.availableUserDomains?.[0]
134 if (domain) return `${flow.info.handle.trim()}.${domain}`
135 return flow.info.handle.trim()
136 })
137
138 function extractDomain(did: string): string {
139 return did.replace('did:web:', '').replace(/%3A/g, ':')
140 }
141
142 function getSubtitle(): string {
143 if (!flow) return ''
144 switch (flow.state.step) {
145 case 'info': return $_('register.subtitle')
146 case 'key-choice': return $_('register.subtitleKeyChoice')
147 case 'initial-did-doc': return $_('register.subtitleInitialDidDoc')
148 case 'creating': return $_('common.creating')
149 case 'verify': return $_('register.subtitleVerify', { values: { channel: channelLabel(flow.info.verificationChannel) } })
150 case 'updated-did-doc': return $_('register.subtitleUpdatedDidDoc')
151 case 'activating': return $_('register.subtitleActivating')
152 case 'redirect-to-dashboard': return $_('register.subtitleComplete')
153 default: return ''
154 }
155 }
156</script>
157
158<div class="register-page">
159 <header class="page-header">
160 <h1>{$_('register.title')}</h1>
161 <p class="subtitle">{getSubtitle()}</p>
162 </header>
163
164 {#if flow?.state.error}
165 <div class="message error">{flow.state.error}</div>
166 {/if}
167
168 {#if loadingServerInfo || !flow}
169 <div class="loading"></div>
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={getFullUrl(routes.migrate)} 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" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}>
241 <input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting || serverInfo?.selfHostedDidWebEnabled === false} />
242 <span class="radio-content">
243 <strong>{$_('register.didWeb')}</strong>
244 {#if serverInfo?.selfHostedDidWebEnabled === false}
245 <span class="radio-hint disabled-hint">{$_('register.didWebDisabledHint')}</span>
246 {:else}
247 <span class="radio-hint">{$_('register.didWebHint')}</span>
248 {/if}
249 </span>
250 </label>
251
252 <label class="radio-label">
253 <input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} />
254 <span class="radio-content">
255 <strong>{$_('register.didWebBYOD')}</strong>
256 <span class="radio-hint">{$_('register.didWebBYODHint')}</span>
257 </span>
258 </label>
259 </div>
260
261 {#if flow.info.didType === 'web'}
262 <div class="warning-box">
263 <strong>{$_('register.didWebWarningTitle')}</strong>
264 <ul>
265 <li><strong>{$_('register.didWebWarning1')}</strong> {$_('register.didWebWarning1Detail', { values: { did: `did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}` } })}</li>
266 <li><strong>{$_('register.didWebWarning2')}</strong> {$_('register.didWebWarning2Detail')}</li>
267 <li><strong>{$_('register.didWebWarning3')}</strong> {$_('register.didWebWarning3Detail')}</li>
268 <li><strong>{$_('register.didWebWarning4')}</strong> {$_('register.didWebWarning4Detail')}</li>
269 </ul>
270 </div>
271 {/if}
272
273 {#if flow.info.didType === 'web-external'}
274 <div class="field">
275 <label for="external-did">{$_('register.externalDid')}</label>
276 <input
277 id="external-did"
278 type="text"
279 bind:value={flow.info.externalDid}
280 placeholder={$_('register.externalDidPlaceholder')}
281 disabled={flow.state.submitting}
282 required
283 />
284 <p class="hint">{$_('register.externalDidHint')}</p>
285 </div>
286 {/if}
287 </fieldset>
288
289 <fieldset class="section-fieldset">
290 <legend>{$_('register.contactMethod')}</legend>
291 <div class="contact-fields">
292 <div class="field">
293 <label for="verification-channel">{$_('register.verificationMethod')}</label>
294 <select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
295 <option value="email">{$_('register.email')}</option>
296 <option value="discord" disabled={!isChannelAvailable('discord')}>
297 {$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
298 </option>
299 <option value="telegram" disabled={!isChannelAvailable('telegram')}>
300 {$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`}
301 </option>
302 <option value="signal" disabled={!isChannelAvailable('signal')}>
303 {$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`}
304 </option>
305 </select>
306 </div>
307
308 {#if flow.info.verificationChannel === 'email'}
309 <div class="field">
310 <label for="email">{$_('register.emailAddress')}</label>
311 <input
312 id="email"
313 type="email"
314 bind:value={flow.info.email}
315 placeholder={$_('register.emailPlaceholder')}
316 disabled={flow.state.submitting}
317 required
318 />
319 </div>
320 {:else if flow.info.verificationChannel === 'discord'}
321 <div class="field">
322 <label for="discord-id">{$_('register.discordId')}</label>
323 <input
324 id="discord-id"
325 type="text"
326 bind:value={flow.info.discordId}
327 placeholder={$_('register.discordIdPlaceholder')}
328 disabled={flow.state.submitting}
329 required
330 />
331 <p class="hint">{$_('register.discordIdHint')}</p>
332 </div>
333 {:else if flow.info.verificationChannel === 'telegram'}
334 <div class="field">
335 <label for="telegram-username">{$_('register.telegramUsername')}</label>
336 <input
337 id="telegram-username"
338 type="text"
339 bind:value={flow.info.telegramUsername}
340 placeholder={$_('register.telegramUsernamePlaceholder')}
341 disabled={flow.state.submitting}
342 required
343 />
344 </div>
345 {:else if flow.info.verificationChannel === 'signal'}
346 <div class="field">
347 <label for="signal-number">{$_('register.signalNumber')}</label>
348 <input
349 id="signal-number"
350 type="tel"
351 bind:value={flow.info.signalNumber}
352 placeholder={$_('register.signalNumberPlaceholder')}
353 disabled={flow.state.submitting}
354 required
355 />
356 <p class="hint">{$_('register.signalNumberHint')}</p>
357 </div>
358 {/if}
359 </div>
360 </fieldset>
361
362 {#if serverInfo?.inviteCodeRequired}
363 <div class="field">
364 <label for="invite-code">{$_('register.inviteCode')} <span class="required">{$_('register.inviteCodeRequired')}</span></label>
365 <input
366 id="invite-code"
367 type="text"
368 bind:value={flow.info.inviteCode}
369 placeholder={$_('register.inviteCodePlaceholder')}
370 disabled={flow.state.submitting}
371 required
372 />
373 </div>
374 {/if}
375
376 <button type="submit" disabled={flow.state.submitting}>
377 {flow.state.submitting ? $_('common.creating') : $_('register.createButton')}
378 </button>
379 </form>
380
381 <div class="form-links">
382 <p class="link-text">
383 {$_('register.alreadyHaveAccount')} <a href={getFullUrl(routes.login)}>{$_('register.signIn')}</a>
384 </p>
385 <p class="link-text">
386 {$_('register.wantPasswordless')} <a href={getFullUrl(routes.registerPasskey)}>{$_('register.createPasskeyAccount')}</a>
387 </p>
388 </div>
389 </div>
390
391 <aside class="info-panel">
392 <h3>{$_('register.identityHint')}</h3>
393 <p>{$_('register.infoIdentityDesc')}</p>
394
395 <h3>{$_('register.contactMethodHint')}</h3>
396 <p>{$_('register.infoContactDesc')}</p>
397
398 <h3>{$_('register.infoNextTitle')}</h3>
399 <p>{$_('register.infoNextDesc')}</p>
400 </aside>
401 </div>
402
403 {:else if flow.state.step === 'key-choice'}
404 <KeyChoiceStep {flow} />
405
406 {:else if flow.state.step === 'initial-did-doc'}
407 <DidDocStep
408 {flow}
409 type="initial"
410 onConfirm={handleCreateAccount}
411 onBack={() => flow?.goBack()}
412 />
413
414 {:else if flow.state.step === 'creating'}
415 <p class="loading">{$_('common.creating')}</p>
416
417 {:else if flow.state.step === 'verify'}
418 <VerificationStep {flow} />
419
420 {:else if flow.state.step === 'updated-did-doc'}
421 <DidDocStep
422 {flow}
423 type="updated"
424 onConfirm={() => flow?.activateAccount()}
425 />
426
427 {:else if flow.state.step === 'redirect-to-dashboard'}
428 <p class="loading">{$_('register.redirecting')}</p>
429 {/if}
430</div>
431
432<style>
433 .register-page {
434 max-width: var(--width-lg);
435 margin: var(--space-9) auto;
436 padding: var(--space-7);
437 }
438
439 .page-header {
440 margin-bottom: var(--space-6);
441 }
442
443 .form-section {
444 min-width: 0;
445 }
446
447 .form-links {
448 margin-top: var(--space-6);
449 }
450
451 .migrate-callout {
452 display: flex;
453 gap: var(--space-4);
454 padding: var(--space-5);
455 background: var(--accent-muted);
456 border: 1px solid var(--accent);
457 border-radius: var(--radius-xl);
458 margin-bottom: var(--space-6);
459 }
460
461 .migrate-icon {
462 font-size: var(--text-2xl);
463 line-height: 1;
464 color: var(--accent);
465 }
466
467 .migrate-content {
468 flex: 1;
469 }
470
471 .migrate-content strong {
472 display: block;
473 color: var(--text-primary);
474 margin-bottom: var(--space-2);
475 }
476
477 .migrate-content p {
478 margin: 0 0 var(--space-3) 0;
479 font-size: var(--text-sm);
480 color: var(--text-secondary);
481 line-height: var(--leading-relaxed);
482 }
483
484 .migrate-link {
485 font-size: var(--text-sm);
486 font-weight: var(--font-medium);
487 color: var(--accent);
488 text-decoration: none;
489 }
490
491 .migrate-link:hover {
492 text-decoration: underline;
493 }
494
495 h1 {
496 margin: 0 0 var(--space-3) 0;
497 }
498
499 .subtitle {
500 color: var(--text-secondary);
501 margin: 0 0 var(--space-7) 0;
502 }
503
504 .loading {
505 text-align: center;
506 color: var(--text-secondary);
507 }
508
509 form {
510 display: flex;
511 flex-direction: column;
512 gap: var(--space-5);
513 }
514
515 .required {
516 color: var(--error-text);
517 }
518
519 .section-hint {
520 font-size: var(--text-sm);
521 color: var(--text-secondary);
522 margin: 0 0 var(--space-5) 0;
523 }
524
525 .radio-group {
526 display: flex;
527 flex-direction: column;
528 gap: var(--space-4);
529 }
530
531 .radio-label {
532 display: flex;
533 align-items: flex-start;
534 gap: var(--space-3);
535 cursor: pointer;
536 font-size: var(--text-base);
537 font-weight: var(--font-normal);
538 margin-bottom: 0;
539 }
540
541 .radio-label input[type="radio"] {
542 margin-top: var(--space-1);
543 width: auto;
544 }
545
546 .radio-content {
547 display: flex;
548 flex-direction: column;
549 gap: var(--space-1);
550 }
551
552 .radio-hint {
553 font-size: var(--text-xs);
554 color: var(--text-secondary);
555 }
556
557 .radio-label.disabled {
558 opacity: 0.5;
559 cursor: not-allowed;
560 }
561
562 .radio-hint.disabled-hint {
563 color: var(--warning-text);
564 }
565
566 .warning-box {
567 margin-top: var(--space-5);
568 padding: var(--space-5);
569 background: var(--warning-bg);
570 border: 1px solid var(--warning-border);
571 border-radius: var(--radius-lg);
572 font-size: var(--text-sm);
573 }
574
575 .warning-box strong {
576 color: var(--warning-text);
577 }
578
579 .warning-box ul {
580 margin: var(--space-4) 0 0 0;
581 padding-left: var(--space-5);
582 }
583
584 .warning-box li {
585 margin-bottom: var(--space-3);
586 line-height: var(--leading-normal);
587 }
588
589 .warning-box li:last-child {
590 margin-bottom: 0;
591 }
592
593 button[type="submit"] {
594 margin-top: var(--space-3);
595 }
596
597 .link-text {
598 text-align: center;
599 margin-top: var(--space-6);
600 color: var(--text-secondary);
601 }
602
603 .link-text a {
604 color: var(--accent);
605 }
606</style>