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