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