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