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 AppPasswordStep,
11 } from '../lib/registration'
12 import {
13 prepareCreationOptions,
14 serializeAttestationResponse,
15 type PublicKeyCredentialCreationOptionsJSON,
16 } from '../lib/webauthn'
17 import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte'
18
19 let serverInfo = $state<{
20 availableUserDomains: string[]
21 inviteCodeRequired: boolean
22 availableCommsChannels?: string[]
23 selfHostedDidWebEnabled?: boolean
24 } | null>(null)
25 let loadingServerInfo = $state(true)
26 let serverInfoLoaded = false
27
28 let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null)
29 let passkeyName = $state('')
30
31 $effect(() => {
32 if (!serverInfoLoaded) {
33 serverInfoLoaded = true
34 loadServerInfo()
35 }
36 })
37
38 $effect(() => {
39 if (flow?.state.step === 'redirect-to-dashboard') {
40 navigate('/dashboard')
41 }
42 })
43
44 let creatingStarted = false
45 $effect(() => {
46 if (flow?.state.step === 'creating' && !creatingStarted) {
47 creatingStarted = true
48 flow.createPasskeyAccount()
49 }
50 })
51
52 async function loadServerInfo() {
53 try {
54 serverInfo = await api.describeServer()
55 const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname
56 flow = createRegistrationFlow('passkey', hostname)
57 } catch (e) {
58 console.error('Failed to load server info:', e)
59 } finally {
60 loadingServerInfo = false
61 }
62 }
63
64 function validateInfoStep(): string | null {
65 if (!flow) return 'Flow not initialized'
66 const info = flow.info
67 if (!info.handle.trim()) return $_('registerPasskey.errors.handleRequired')
68 if (info.handle.includes('.')) return $_('registerPasskey.errors.handleNoDots')
69 if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) {
70 return $_('registerPasskey.errors.inviteRequired')
71 }
72 if (info.didType === 'web-external') {
73 if (!info.externalDid?.trim()) return $_('registerPasskey.errors.externalDidRequired')
74 if (!info.externalDid.trim().startsWith('did:web:')) return $_('registerPasskey.errors.externalDidFormat')
75 }
76 switch (info.verificationChannel) {
77 case 'email':
78 if (!info.email.trim()) return $_('registerPasskey.errors.emailRequired')
79 break
80 case 'discord':
81 if (!info.discordId?.trim()) return $_('registerPasskey.errors.discordRequired')
82 break
83 case 'telegram':
84 if (!info.telegramUsername?.trim()) return $_('registerPasskey.errors.telegramRequired')
85 break
86 case 'signal':
87 if (!info.signalNumber?.trim()) return $_('registerPasskey.errors.signalRequired')
88 break
89 }
90 return null
91 }
92
93 async function handleInfoSubmit(e: Event) {
94 e.preventDefault()
95 if (!flow) return
96
97 const validationError = validateInfoStep()
98 if (validationError) {
99 flow.setError(validationError)
100 return
101 }
102
103 if (!window.PublicKeyCredential) {
104 flow.setError($_('registerPasskey.errors.passkeysNotSupported'))
105 return
106 }
107
108 flow.clearError()
109 flow.proceedFromInfo()
110 }
111
112 async function handleCreateAccount() {
113 if (!flow) return
114 await flow.createPasskeyAccount()
115 }
116
117 async function handlePasskeyRegistration() {
118 if (!flow || !flow.account) return
119
120 flow.setSubmitting(true)
121 flow.clearError()
122
123 try {
124 const { options } = await api.startPasskeyRegistrationForSetup(
125 flow.account.did,
126 flow.account.setupToken!,
127 passkeyName || undefined
128 )
129
130 const publicKeyOptions = prepareCreationOptions({ publicKey: options as unknown as PublicKeyCredentialCreationOptionsJSON })
131 const credential = await navigator.credentials.create({
132 publicKey: publicKeyOptions
133 })
134
135 if (!credential) {
136 flow.setError($_('registerPasskey.errors.passkeyCancelled'))
137 flow.setSubmitting(false)
138 return
139 }
140
141 const credentialResponse = serializeAttestationResponse(credential as PublicKeyCredential)
142
143 const result = await api.completePasskeySetup(
144 flow.account.did,
145 flow.account.setupToken!,
146 credentialResponse,
147 passkeyName || undefined
148 )
149
150 flow.setPasskeyComplete(result.appPassword, result.appPasswordName)
151 } catch (err) {
152 if (err instanceof DOMException && err.name === 'NotAllowedError') {
153 flow.setError($_('registerPasskey.errors.passkeyCancelled'))
154 } else if (err instanceof ApiError) {
155 flow.setError(err.message || $_('registerPasskey.errors.passkeyFailed'))
156 } else if (err instanceof Error) {
157 flow.setError(err.message || $_('registerPasskey.errors.passkeyFailed'))
158 } else {
159 flow.setError($_('registerPasskey.errors.passkeyFailed'))
160 }
161 } finally {
162 flow.setSubmitting(false)
163 }
164 }
165
166 async function handleComplete() {
167 if (flow) {
168 await flow.finalizeSession()
169 }
170 navigate('/dashboard')
171 }
172
173 function isChannelAvailable(ch: string): boolean {
174 const available = serverInfo?.availableCommsChannels ?? ['email']
175 return available.includes(ch)
176 }
177
178 function channelLabel(ch: string): string {
179 switch (ch) {
180 case 'email': return $_('register.email')
181 case 'discord': return $_('register.discord')
182 case 'telegram': return $_('register.telegram')
183 case 'signal': return $_('register.signal')
184 default: return ch
185 }
186 }
187
188 let fullHandle = $derived(() => {
189 if (!flow?.info.handle.trim()) return ''
190 if (flow.info.handle.includes('.')) return flow.info.handle.trim()
191 const domain = serverInfo?.availableUserDomains?.[0]
192 if (domain) return `${flow.info.handle.trim()}.${domain}`
193 return flow.info.handle.trim()
194 })
195
196 function extractDomain(did: string): string {
197 return did.replace('did:web:', '').replace(/%3A/g, ':')
198 }
199
200 function getSubtitle(): string {
201 if (!flow) return ''
202 switch (flow.state.step) {
203 case 'info': return $_('registerPasskey.subtitle')
204 case 'key-choice': return $_('registerPasskey.subtitleKeyChoice')
205 case 'initial-did-doc': return $_('registerPasskey.subtitleInitialDidDoc')
206 case 'creating': return $_('registerPasskey.subtitleCreating')
207 case 'passkey': return $_('registerPasskey.subtitlePasskey')
208 case 'app-password': return $_('registerPasskey.subtitleAppPassword')
209 case 'verify': return $_('registerPasskey.subtitleVerify', { values: { channel: channelLabel(flow.info.verificationChannel) } })
210 case 'updated-did-doc': return $_('registerPasskey.subtitleUpdatedDidDoc')
211 case 'activating': return $_('registerPasskey.subtitleActivating')
212 case 'redirect-to-dashboard': return $_('registerPasskey.subtitleComplete')
213 default: return ''
214 }
215 }
216</script>
217
218<div class="register-page">
219 <header class="page-header">
220 <h1>{$_('registerPasskey.title')}</h1>
221 <p class="subtitle">{getSubtitle()}</p>
222 </header>
223
224 {#if flow?.state.error}
225 <div class="message error">{flow.state.error}</div>
226 {/if}
227
228 {#if loadingServerInfo || !flow}
229 <div class="loading"></div>
230
231 {:else if flow.state.step === 'info'}
232 <div class="migrate-callout">
233 <div class="migrate-icon">↗</div>
234 <div class="migrate-content">
235 <strong>{$_('register.migrateTitle')}</strong>
236 <p>{$_('register.migrateDescription')}</p>
237 <a href={getFullUrl(routes.migrate)} class="migrate-link">
238 {$_('register.migrateLink')} →
239 </a>
240 </div>
241 </div>
242
243 <AccountTypeSwitcher active="passkey" />
244
245 <div class="split-layout sidebar-right">
246 <div class="form-section">
247 <form onsubmit={handleInfoSubmit}>
248 <div class="field">
249 <label for="handle">{$_('registerPasskey.handle')}</label>
250 <input
251 id="handle"
252 type="text"
253 bind:value={flow.info.handle}
254 placeholder={$_('registerPasskey.handlePlaceholder')}
255 disabled={flow.state.submitting}
256 required
257 />
258 {#if flow.info.handle.includes('.')}
259 <p class="hint warning">{$_('registerPasskey.handleDotWarning')}</p>
260 {:else if fullHandle()}
261 <p class="hint">{$_('registerPasskey.handleHint', { values: { handle: fullHandle() } })}</p>
262 {/if}
263 </div>
264
265 <fieldset class="section-fieldset">
266 <legend>{$_('registerPasskey.contactMethod')}</legend>
267 <p class="section-hint">{$_('registerPasskey.contactMethodHint')}</p>
268 <div class="field">
269 <label for="verification-channel">{$_('registerPasskey.verificationMethod')}</label>
270 <select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
271 <option value="email">{$_('register.email')}</option>
272 <option value="discord" disabled={!isChannelAvailable('discord')}>
273 {$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
274 </option>
275 <option value="telegram" disabled={!isChannelAvailable('telegram')}>
276 {$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`}
277 </option>
278 <option value="signal" disabled={!isChannelAvailable('signal')}>
279 {$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`}
280 </option>
281 </select>
282 </div>
283 {#if flow.info.verificationChannel === 'email'}
284 <div class="field">
285 <label for="email">{$_('registerPasskey.email')}</label>
286 <input id="email" type="email" bind:value={flow.info.email} placeholder={$_('registerPasskey.emailPlaceholder')} disabled={flow.state.submitting} required />
287 </div>
288 {:else if flow.info.verificationChannel === 'discord'}
289 <div class="field">
290 <label for="discord-id">{$_('register.discordId')}</label>
291 <input id="discord-id" type="text" bind:value={flow.info.discordId} placeholder={$_('register.discordIdPlaceholder')} disabled={flow.state.submitting} required />
292 <p class="hint">{$_('register.discordIdHint')}</p>
293 </div>
294 {:else if flow.info.verificationChannel === 'telegram'}
295 <div class="field">
296 <label for="telegram-username">{$_('register.telegramUsername')}</label>
297 <input id="telegram-username" type="text" bind:value={flow.info.telegramUsername} placeholder={$_('register.telegramUsernamePlaceholder')} disabled={flow.state.submitting} required />
298 </div>
299 {:else if flow.info.verificationChannel === 'signal'}
300 <div class="field">
301 <label for="signal-number">{$_('register.signalNumber')}</label>
302 <input id="signal-number" type="tel" bind:value={flow.info.signalNumber} placeholder={$_('register.signalNumberPlaceholder')} disabled={flow.state.submitting} required />
303 <p class="hint">{$_('register.signalNumberHint')}</p>
304 </div>
305 {/if}
306 </fieldset>
307
308 <fieldset class="section-fieldset">
309 <legend>{$_('registerPasskey.identityType')}</legend>
310 <p class="section-hint">{$_('registerPasskey.identityTypeHint')}</p>
311 <div class="radio-group">
312 <label class="radio-label">
313 <input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
314 <span class="radio-content">
315 <strong>{$_('registerPasskey.didPlcRecommended')}</strong>
316 <span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span>
317 </span>
318 </label>
319 <label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}>
320 <input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting || serverInfo?.selfHostedDidWebEnabled === false} />
321 <span class="radio-content">
322 <strong>{$_('registerPasskey.didWeb')}</strong>
323 {#if serverInfo?.selfHostedDidWebEnabled === false}
324 <span class="radio-hint disabled-hint">{$_('registerPasskey.didWebDisabledHint')}</span>
325 {:else}
326 <span class="radio-hint">{$_('registerPasskey.didWebHint')}</span>
327 {/if}
328 </span>
329 </label>
330 <label class="radio-label">
331 <input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} />
332 <span class="radio-content">
333 <strong>{$_('registerPasskey.didWebBYOD')}</strong>
334 <span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span>
335 </span>
336 </label>
337 </div>
338 {#if flow.info.didType === 'web'}
339 <div class="warning-box">
340 <strong>{$_('registerPasskey.didWebWarningTitle')}</strong>
341 <ul>
342 <li><strong>{$_('registerPasskey.didWebWarning1')}</strong> {@html $_('registerPasskey.didWebWarning1Detail', { values: { did: `<code>did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>` } })}</li>
343 <li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li>
344 <li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li>
345 <li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li>
346 </ul>
347 </div>
348 {/if}
349 {#if flow.info.didType === 'web-external'}
350 <div class="field">
351 <label for="external-did">{$_('registerPasskey.externalDid')}</label>
352 <input id="external-did" type="text" bind:value={flow.info.externalDid} placeholder={$_('registerPasskey.externalDidPlaceholder')} disabled={flow.state.submitting} required />
353 <p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{flow.info.externalDid ? extractDomain(flow.info.externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p>
354 </div>
355 {/if}
356 </fieldset>
357
358 {#if serverInfo?.inviteCodeRequired}
359 <div class="field">
360 <label for="invite-code">{$_('registerPasskey.inviteCode')} <span class="required">*</span></label>
361 <input id="invite-code" type="text" bind:value={flow.info.inviteCode} placeholder={$_('registerPasskey.inviteCodePlaceholder')} disabled={flow.state.submitting} required />
362 </div>
363 {/if}
364
365 <button type="submit" disabled={flow.state.submitting}>
366 {flow.state.submitting ? $_('common.creating') : $_('registerPasskey.continue')}
367 </button>
368 </form>
369
370 <div class="form-links">
371 <p class="link-text">
372 {$_('register.alreadyHaveAccount')} <a href="/app/login">{$_('register.signIn')}</a>
373 </p>
374 </div>
375 </div>
376
377 <aside class="info-panel">
378 <h3>{$_('registerPasskey.infoWhyPasskey')}</h3>
379 <p>{$_('registerPasskey.infoWhyPasskeyDesc')}</p>
380
381 <h3>{$_('registerPasskey.infoHowItWorks')}</h3>
382 <p>{$_('registerPasskey.infoHowItWorksDesc')}</p>
383
384 <h3>{$_('registerPasskey.infoAppAccess')}</h3>
385 <p>{$_('registerPasskey.infoAppAccessDesc')}</p>
386 </aside>
387 </div>
388
389
390 {:else if flow.state.step === 'key-choice'}
391 <KeyChoiceStep {flow} />
392
393 {:else if flow.state.step === 'initial-did-doc'}
394 <DidDocStep
395 {flow}
396 type="initial"
397 onConfirm={handleCreateAccount}
398 onBack={() => flow?.goBack()}
399 />
400
401 {:else if flow.state.step === 'creating'}
402 <p class="loading">{$_('registerPasskey.subtitleCreating')}</p>
403
404 {:else if flow.state.step === 'passkey'}
405 <div class="step-content">
406 <div class="field">
407 <label for="passkey-name">{$_('registerPasskey.passkeyNameLabel')}</label>
408 <input id="passkey-name" type="text" bind:value={passkeyName} placeholder={$_('registerPasskey.passkeyNamePlaceholder')} disabled={flow.state.submitting} />
409 <p class="hint">{$_('registerPasskey.passkeyNameHint')}</p>
410 </div>
411
412 <div class="info-box">
413 <p>{$_('registerPasskey.passkeyPrompt')}</p>
414 <ul>
415 <li>{$_('registerPasskey.passkeyPromptBullet1')}</li>
416 <li>{$_('registerPasskey.passkeyPromptBullet2')}</li>
417 <li>{$_('registerPasskey.passkeyPromptBullet3')}</li>
418 </ul>
419 </div>
420
421 <button onclick={handlePasskeyRegistration} disabled={flow.state.submitting} class="passkey-btn">
422 {flow.state.submitting ? $_('registerPasskey.creatingPasskey') : $_('registerPasskey.createPasskey')}
423 </button>
424
425 <button type="button" class="secondary" onclick={() => flow?.goBack()} disabled={flow.state.submitting}>
426 {$_('registerPasskey.back')}
427 </button>
428 </div>
429
430 {:else if flow.state.step === 'app-password'}
431 <AppPasswordStep {flow} />
432
433 {:else if flow.state.step === 'verify'}
434 <VerificationStep {flow} />
435
436 {:else if flow.state.step === 'updated-did-doc'}
437 <DidDocStep
438 {flow}
439 type="updated"
440 onConfirm={() => flow?.activateAccount()}
441 />
442
443 {:else if flow.state.step === 'redirect-to-dashboard'}
444 <p class="loading">{$_('registerPasskey.redirecting')}</p>
445 {/if}
446</div>
447
448<style>
449 .register-page {
450 max-width: var(--width-lg);
451 margin: var(--space-9) auto;
452 padding: var(--space-7);
453 }
454
455 .page-header {
456 margin-bottom: var(--space-6);
457 }
458
459 .form-section {
460 min-width: 0;
461 }
462
463 .form-links {
464 margin-top: var(--space-6);
465 }
466
467 .link-text {
468 text-align: center;
469 color: var(--text-secondary);
470 }
471
472 .link-text a {
473 color: var(--accent);
474 }
475
476 .migrate-callout {
477 display: flex;
478 gap: var(--space-4);
479 padding: var(--space-5);
480 background: var(--accent-muted);
481 border: 1px solid var(--accent);
482 border-radius: var(--radius-xl);
483 margin-bottom: var(--space-6);
484 }
485
486 .migrate-icon {
487 font-size: var(--text-2xl);
488 line-height: 1;
489 color: var(--accent);
490 }
491
492 .migrate-content {
493 flex: 1;
494 }
495
496 .migrate-content strong {
497 display: block;
498 color: var(--text-primary);
499 margin-bottom: var(--space-2);
500 }
501
502 .migrate-content p {
503 margin: 0 0 var(--space-3) 0;
504 font-size: var(--text-sm);
505 color: var(--text-secondary);
506 line-height: var(--leading-relaxed);
507 }
508
509 .migrate-link {
510 font-size: var(--text-sm);
511 font-weight: var(--font-medium);
512 color: var(--accent);
513 text-decoration: none;
514 }
515
516 .migrate-link:hover {
517 text-decoration: underline;
518 }
519
520 h1 {
521 margin: 0 0 var(--space-3) 0;
522 }
523
524 .subtitle {
525 color: var(--text-secondary);
526 margin: 0 0 var(--space-7) 0;
527 }
528
529 .loading {
530 text-align: center;
531 color: var(--text-secondary);
532 }
533
534 form, .step-content {
535 display: flex;
536 flex-direction: column;
537 gap: var(--space-4);
538 }
539
540 .required {
541 color: var(--error-text);
542 }
543
544 .section-hint {
545 font-size: var(--text-sm);
546 color: var(--text-secondary);
547 margin: 0 0 var(--space-5) 0;
548 }
549
550 .radio-group {
551 display: flex;
552 flex-direction: column;
553 gap: var(--space-4);
554 }
555
556 .radio-label {
557 display: flex;
558 align-items: flex-start;
559 gap: var(--space-3);
560 cursor: pointer;
561 font-size: var(--text-base);
562 font-weight: var(--font-normal);
563 margin-bottom: 0;
564 }
565
566 .radio-label input[type="radio"] {
567 margin-top: var(--space-1);
568 width: auto;
569 }
570
571 .radio-content {
572 display: flex;
573 flex-direction: column;
574 gap: var(--space-1);
575 }
576
577 .radio-hint {
578 font-size: var(--text-xs);
579 color: var(--text-secondary);
580 }
581
582 .radio-label.disabled {
583 opacity: 0.5;
584 cursor: not-allowed;
585 }
586
587 .radio-hint.disabled-hint {
588 color: var(--warning-text);
589 }
590
591 .warning-box {
592 margin-top: var(--space-5);
593 padding: var(--space-5);
594 background: var(--warning-bg);
595 border: 1px solid var(--warning-border);
596 border-radius: var(--radius-lg);
597 font-size: var(--text-sm);
598 }
599
600 .warning-box strong {
601 display: block;
602 margin-bottom: var(--space-3);
603 color: var(--warning-text);
604 }
605
606 .warning-box ul {
607 margin: var(--space-4) 0 0 0;
608 padding-left: var(--space-5);
609 }
610
611 .warning-box li {
612 margin-bottom: var(--space-3);
613 line-height: var(--leading-normal);
614 }
615
616 .warning-box li:last-child {
617 margin-bottom: 0;
618 }
619
620 .info-box {
621 background: var(--bg-secondary);
622 border: 1px solid var(--border-color);
623 border-radius: var(--radius-lg);
624 padding: var(--space-5);
625 font-size: var(--text-sm);
626 }
627
628 .info-box p {
629 margin: 0 0 var(--space-3) 0;
630 color: var(--text-secondary);
631 }
632
633 .info-box ul {
634 margin: 0;
635 padding-left: var(--space-5);
636 color: var(--text-secondary);
637 }
638
639 .info-box li {
640 margin-bottom: var(--space-2);
641 }
642
643 .passkey-btn {
644 padding: var(--space-5);
645 font-size: var(--text-lg);
646 }
647</style>