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 import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte'
12
13 let serverInfo = $state<{
14 availableUserDomains: string[]
15 inviteCodeRequired: boolean
16 availableCommsChannels?: string[]
17 selfHostedDidWebEnabled?: boolean
18 } | null>(null)
19 let loadingServerInfo = $state(true)
20 let serverInfoLoaded = false
21
22 let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null)
23 let confirmPassword = $state('')
24
25 $effect(() => {
26 if (!serverInfoLoaded) {
27 serverInfoLoaded = true
28 loadServerInfo()
29 }
30 })
31
32 $effect(() => {
33 if (flow?.state.step === 'redirect-to-dashboard') {
34 navigate(routes.dashboard)
35 }
36 })
37
38 let creatingStarted = false
39 $effect(() => {
40 if (flow?.state.step === 'creating' && !creatingStarted) {
41 creatingStarted = true
42 flow.createPasswordAccount()
43 }
44 })
45
46 async function loadServerInfo() {
47 try {
48 serverInfo = await api.describeServer()
49 const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname
50 flow = createRegistrationFlow('password', hostname)
51 } catch (e) {
52 console.error('Failed to load server info:', e)
53 } finally {
54 loadingServerInfo = false
55 }
56 }
57
58 function validateInfoStep(): string | null {
59 if (!flow) return 'Flow not initialized'
60 const info = flow.info
61 if (!info.handle.trim()) return $_('register.validation.handleRequired')
62 if (info.handle.includes('.')) return $_('register.validation.handleNoDots')
63 if (!info.password) return $_('register.validation.passwordRequired')
64 if (info.password.length < 8) return $_('register.validation.passwordLength')
65 if (info.password !== confirmPassword) return $_('register.validation.passwordsMismatch')
66 if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) {
67 return $_('register.validation.inviteCodeRequired')
68 }
69 if (info.didType === 'web-external') {
70 if (!info.externalDid?.trim()) return $_('register.validation.externalDidRequired')
71 if (!info.externalDid.trim().startsWith('did:web:')) return $_('register.validation.externalDidFormat')
72 }
73 switch (info.verificationChannel) {
74 case 'email':
75 if (!info.email.trim()) return $_('register.validation.emailRequired')
76 break
77 case 'discord':
78 if (!info.discordId?.trim()) return $_('register.validation.discordIdRequired')
79 break
80 case 'telegram':
81 if (!info.telegramUsername?.trim()) return $_('register.validation.telegramRequired')
82 break
83 case 'signal':
84 if (!info.signalNumber?.trim()) return $_('register.validation.signalRequired')
85 break
86 }
87 return null
88 }
89
90 async function handleInfoSubmit(e: Event) {
91 e.preventDefault()
92 if (!flow) return
93
94 const validationError = validateInfoStep()
95 if (validationError) {
96 flow.setError(validationError)
97 return
98 }
99
100 flow.clearError()
101 flow.proceedFromInfo()
102 }
103
104 async function handleCreateAccount() {
105 if (!flow) return
106 await flow.createPasswordAccount()
107 }
108
109 async function handleComplete() {
110 if (flow) {
111 await flow.finalizeSession()
112 }
113 navigate(routes.dashboard)
114 }
115
116 function isChannelAvailable(ch: string): boolean {
117 const available = serverInfo?.availableCommsChannels ?? ['email']
118 return available.includes(ch)
119 }
120
121 function channelLabel(ch: string): string {
122 switch (ch) {
123 case 'email': return $_('register.email')
124 case 'discord': return $_('register.discord')
125 case 'telegram': return $_('register.telegram')
126 case 'signal': return $_('register.signal')
127 default: return ch
128 }
129 }
130
131 let fullHandle = $derived(() => {
132 if (!flow?.info.handle.trim()) return ''
133 if (flow.info.handle.includes('.')) return flow.info.handle.trim()
134 const domain = serverInfo?.availableUserDomains?.[0]
135 if (domain) return `${flow.info.handle.trim()}.${domain}`
136 return flow.info.handle.trim()
137 })
138
139 function extractDomain(did: string): string {
140 return did.replace('did:web:', '').replace(/%3A/g, ':')
141 }
142
143 function getSubtitle(): string {
144 if (!flow) return ''
145 switch (flow.state.step) {
146 case 'info': return $_('register.subtitle')
147 case 'key-choice': return $_('register.subtitleKeyChoice')
148 case 'initial-did-doc': return $_('register.subtitleInitialDidDoc')
149 case 'creating': return $_('common.creating')
150 case 'verify': return $_('register.subtitleVerify', { values: { channel: channelLabel(flow.info.verificationChannel) } })
151 case 'updated-did-doc': return $_('register.subtitleUpdatedDidDoc')
152 case 'activating': return $_('register.subtitleActivating')
153 case 'redirect-to-dashboard': return $_('register.subtitleComplete')
154 default: return ''
155 }
156 }
157</script>
158
159<div class="register-page">
160 <header class="page-header">
161 <h1>{$_('register.title')}</h1>
162 <p class="subtitle">{getSubtitle()}</p>
163 </header>
164
165 {#if flow?.state.error}
166 <div class="message error">{flow.state.error}</div>
167 {/if}
168
169 {#if loadingServerInfo || !flow}
170 <div class="loading"></div>
171 {:else if flow.state.step === 'info'}
172 <div class="migrate-callout">
173 <div class="migrate-icon">↗</div>
174 <div class="migrate-content">
175 <strong>{$_('register.migrateTitle')}</strong>
176 <p>{$_('register.migrateDescription')}</p>
177 <a href={getFullUrl(routes.migrate)} class="migrate-link">
178 {$_('register.migrateLink')} →
179 </a>
180 </div>
181 </div>
182
183 <AccountTypeSwitcher active="password" />
184
185 <div class="split-layout sidebar-right">
186 <div class="form-section">
187 <form onsubmit={handleInfoSubmit}>
188 <div class="field">
189 <label for="handle">{$_('register.handle')}</label>
190 <input
191 id="handle"
192 type="text"
193 bind:value={flow.info.handle}
194 placeholder={$_('register.handlePlaceholder')}
195 disabled={flow.state.submitting}
196 required
197 />
198 {#if flow.info.handle.includes('.')}
199 <p class="hint warning">{$_('register.handleDotWarning')}</p>
200 {:else if fullHandle()}
201 <p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
202 {/if}
203 </div>
204
205 <div class="form-row">
206 <div class="field">
207 <label for="password">{$_('register.password')}</label>
208 <input
209 id="password"
210 type="password"
211 bind:value={flow.info.password}
212 placeholder={$_('register.passwordPlaceholder')}
213 disabled={flow.state.submitting}
214 required
215 minlength="8"
216 />
217 </div>
218
219 <div class="field">
220 <label for="confirm-password">{$_('register.confirmPassword')}</label>
221 <input
222 id="confirm-password"
223 type="password"
224 bind:value={confirmPassword}
225 placeholder={$_('register.confirmPasswordPlaceholder')}
226 disabled={flow.state.submitting}
227 required
228 />
229 </div>
230 </div>
231
232 <fieldset class="section-fieldset">
233 <legend>{$_('register.identityType')}</legend>
234 <div class="radio-group">
235 <label class="radio-label">
236 <input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
237 <span class="radio-content">
238 <strong>{$_('register.didPlc')}</strong> {$_('register.didPlcRecommended')}
239 <span class="radio-hint">{$_('register.didPlcHint')}</span>
240 </span>
241 </label>
242
243 <label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}>
244 <input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting || serverInfo?.selfHostedDidWebEnabled === false} />
245 <span class="radio-content">
246 <strong>{$_('register.didWeb')}</strong>
247 {#if serverInfo?.selfHostedDidWebEnabled === false}
248 <span class="radio-hint disabled-hint">{$_('register.didWebDisabledHint')}</span>
249 {:else}
250 <span class="radio-hint">{$_('register.didWebHint')}</span>
251 {/if}
252 </span>
253 </label>
254
255 <label class="radio-label">
256 <input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} />
257 <span class="radio-content">
258 <strong>{$_('register.didWebBYOD')}</strong>
259 <span class="radio-hint">{$_('register.didWebBYODHint')}</span>
260 </span>
261 </label>
262 </div>
263
264 {#if flow.info.didType === 'web'}
265 <div class="warning-box">
266 <strong>{$_('register.didWebWarningTitle')}</strong>
267 <ul>
268 <li><strong>{$_('register.didWebWarning1')}</strong> {$_('register.didWebWarning1Detail', { values: { did: `did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}` } })}</li>
269 <li><strong>{$_('register.didWebWarning2')}</strong> {$_('register.didWebWarning2Detail')}</li>
270 <li><strong>{$_('register.didWebWarning3')}</strong> {$_('register.didWebWarning3Detail')}</li>
271 <li><strong>{$_('register.didWebWarning4')}</strong> {$_('register.didWebWarning4Detail')}</li>
272 </ul>
273 </div>
274 {/if}
275
276 {#if flow.info.didType === 'web-external'}
277 <div class="field">
278 <label for="external-did">{$_('register.externalDid')}</label>
279 <input
280 id="external-did"
281 type="text"
282 bind:value={flow.info.externalDid}
283 placeholder={$_('register.externalDidPlaceholder')}
284 disabled={flow.state.submitting}
285 required
286 />
287 <p class="hint">{$_('register.externalDidHint')}</p>
288 </div>
289 {/if}
290 </fieldset>
291
292 <fieldset class="section-fieldset">
293 <legend>{$_('register.contactMethod')}</legend>
294 <div class="contact-fields">
295 <div class="field">
296 <label for="verification-channel">{$_('register.verificationMethod')}</label>
297 <select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
298 <option value="email">{$_('register.email')}</option>
299 <option value="discord" disabled={!isChannelAvailable('discord')}>
300 {$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
301 </option>
302 <option value="telegram" disabled={!isChannelAvailable('telegram')}>
303 {$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`}
304 </option>
305 <option value="signal" disabled={!isChannelAvailable('signal')}>
306 {$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`}
307 </option>
308 </select>
309 </div>
310
311 {#if flow.info.verificationChannel === 'email'}
312 <div class="field">
313 <label for="email">{$_('register.emailAddress')}</label>
314 <input
315 id="email"
316 type="email"
317 bind:value={flow.info.email}
318 placeholder={$_('register.emailPlaceholder')}
319 disabled={flow.state.submitting}
320 required
321 />
322 </div>
323 {:else if flow.info.verificationChannel === 'discord'}
324 <div class="field">
325 <label for="discord-id">{$_('register.discordId')}</label>
326 <input
327 id="discord-id"
328 type="text"
329 bind:value={flow.info.discordId}
330 placeholder={$_('register.discordIdPlaceholder')}
331 disabled={flow.state.submitting}
332 required
333 />
334 <p class="hint">{$_('register.discordIdHint')}</p>
335 </div>
336 {:else if flow.info.verificationChannel === 'telegram'}
337 <div class="field">
338 <label for="telegram-username">{$_('register.telegramUsername')}</label>
339 <input
340 id="telegram-username"
341 type="text"
342 bind:value={flow.info.telegramUsername}
343 placeholder={$_('register.telegramUsernamePlaceholder')}
344 disabled={flow.state.submitting}
345 required
346 />
347 </div>
348 {:else if flow.info.verificationChannel === 'signal'}
349 <div class="field">
350 <label for="signal-number">{$_('register.signalNumber')}</label>
351 <input
352 id="signal-number"
353 type="tel"
354 bind:value={flow.info.signalNumber}
355 placeholder={$_('register.signalNumberPlaceholder')}
356 disabled={flow.state.submitting}
357 required
358 />
359 <p class="hint">{$_('register.signalNumberHint')}</p>
360 </div>
361 {/if}
362 </div>
363 </fieldset>
364
365 {#if serverInfo?.inviteCodeRequired}
366 <div class="field">
367 <label for="invite-code">{$_('register.inviteCode')} <span class="required">{$_('register.inviteCodeRequired')}</span></label>
368 <input
369 id="invite-code"
370 type="text"
371 bind:value={flow.info.inviteCode}
372 placeholder={$_('register.inviteCodePlaceholder')}
373 disabled={flow.state.submitting}
374 required
375 />
376 </div>
377 {/if}
378
379 <button type="submit" disabled={flow.state.submitting}>
380 {flow.state.submitting ? $_('common.creating') : $_('register.createButton')}
381 </button>
382 </form>
383
384 <div class="form-links">
385 <p class="link-text">
386 {$_('register.alreadyHaveAccount')} <a href={getFullUrl(routes.login)}>{$_('register.signIn')}</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 .radio-group {
520 display: flex;
521 flex-direction: column;
522 gap: var(--space-4);
523 }
524
525 .radio-label {
526 display: flex;
527 align-items: flex-start;
528 gap: var(--space-3);
529 cursor: pointer;
530 font-size: var(--text-base);
531 font-weight: var(--font-normal);
532 margin-bottom: 0;
533 }
534
535 .radio-label input[type="radio"] {
536 margin-top: var(--space-1);
537 width: auto;
538 }
539
540 .radio-content {
541 display: flex;
542 flex-direction: column;
543 gap: var(--space-1);
544 }
545
546 .radio-hint {
547 font-size: var(--text-xs);
548 color: var(--text-secondary);
549 }
550
551 .radio-label.disabled {
552 opacity: 0.5;
553 cursor: not-allowed;
554 }
555
556 .radio-hint.disabled-hint {
557 color: var(--warning-text);
558 }
559
560 .warning-box {
561 margin-top: var(--space-5);
562 padding: var(--space-5);
563 background: var(--warning-bg);
564 border: 1px solid var(--warning-border);
565 border-radius: var(--radius-lg);
566 font-size: var(--text-sm);
567 }
568
569 .warning-box strong {
570 color: var(--warning-text);
571 }
572
573 .warning-box ul {
574 margin: var(--space-4) 0 0 0;
575 padding-left: var(--space-5);
576 }
577
578 .warning-box li {
579 margin-bottom: var(--space-3);
580 line-height: var(--leading-normal);
581 }
582
583 .warning-box li:last-child {
584 margin-bottom: 0;
585 }
586
587 button[type="submit"] {
588 margin-top: var(--space-3);
589 }
590
591 .link-text {
592 text-align: center;
593 margin-top: var(--space-6);
594 color: var(--text-secondary);
595 }
596
597 .link-text a {
598 color: var(--accent);
599 }
600</style>