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