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 async function loadServerInfo() {
38 try {
39 serverInfo = await api.describeServer()
40 const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname
41 flow = createRegistrationFlow('passkey', hostname)
42 } catch (e) {
43 console.error('Failed to load server info:', e)
44 } finally {
45 loadingServerInfo = false
46 }
47 }
48
49 function validateInfoStep(): string | null {
50 if (!flow) return 'Flow not initialized'
51 const info = flow.info
52 if (!info.handle.trim()) return 'Handle is required'
53 if (info.handle.includes('.')) return 'Handle cannot contain dots. You can set up a custom domain handle after creating your account.'
54 if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) {
55 return 'Invite code is required'
56 }
57 if (info.didType === 'web-external') {
58 if (!info.externalDid?.trim()) return 'External did:web is required'
59 if (!info.externalDid.trim().startsWith('did:web:')) return 'External DID must start with did:web:'
60 }
61 switch (info.verificationChannel) {
62 case 'email':
63 if (!info.email.trim()) return 'Email is required for email verification'
64 break
65 case 'discord':
66 if (!info.discordId?.trim()) return 'Discord ID is required for Discord verification'
67 break
68 case 'telegram':
69 if (!info.telegramUsername?.trim()) return 'Telegram username is required for Telegram verification'
70 break
71 case 'signal':
72 if (!info.signalNumber?.trim()) return 'Phone number is required for Signal verification'
73 break
74 }
75 return null
76 }
77
78 function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
79 const bytes = new Uint8Array(buffer)
80 let binary = ''
81 for (let i = 0; i < bytes.byteLength; i++) {
82 binary += String.fromCharCode(bytes[i])
83 }
84 return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
85 }
86
87 function base64UrlToArrayBuffer(base64url: string): ArrayBuffer {
88 const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
89 const padded = base64 + '='.repeat((4 - base64.length % 4) % 4)
90 const binary = atob(padded)
91 const bytes = new Uint8Array(binary.length)
92 for (let i = 0; i < binary.length; i++) {
93 bytes[i] = binary.charCodeAt(i)
94 }
95 return bytes.buffer
96 }
97
98 function preparePublicKeyOptions(options: any): PublicKeyCredentialCreationOptions {
99 return {
100 ...options.publicKey,
101 challenge: base64UrlToArrayBuffer(options.publicKey.challenge),
102 user: {
103 ...options.publicKey.user,
104 id: base64UrlToArrayBuffer(options.publicKey.user.id)
105 },
106 excludeCredentials: options.publicKey.excludeCredentials?.map((cred: any) => ({
107 ...cred,
108 id: base64UrlToArrayBuffer(cred.id)
109 })) || []
110 }
111 }
112
113 async function handleInfoSubmit(e: Event) {
114 e.preventDefault()
115 if (!flow) return
116
117 const validationError = validateInfoStep()
118 if (validationError) {
119 flow.setError(validationError)
120 return
121 }
122
123 if (!window.PublicKeyCredential) {
124 flow.setError('Passkeys are not supported in this browser. Please use a different browser or register with a password instead.')
125 return
126 }
127
128 flow.clearError()
129 flow.proceedFromInfo()
130 }
131
132 async function handleCreateAccount() {
133 if (!flow) return
134 await flow.createPasskeyAccount()
135 }
136
137 async function handlePasskeyRegistration() {
138 if (!flow || !flow.account) return
139
140 flow.setSubmitting(true)
141 flow.clearError()
142
143 try {
144 const { options } = await api.startPasskeyRegistrationForSetup(
145 flow.account.did,
146 flow.account.setupToken!,
147 passkeyName || undefined
148 )
149
150 const publicKeyOptions = preparePublicKeyOptions(options)
151 const credential = await navigator.credentials.create({
152 publicKey: publicKeyOptions
153 })
154
155 if (!credential) {
156 flow.setError('Passkey creation was cancelled')
157 flow.setSubmitting(false)
158 return
159 }
160
161 const pkCredential = credential as PublicKeyCredential
162 const response = pkCredential.response as AuthenticatorAttestationResponse
163 const credentialResponse = {
164 id: pkCredential.id,
165 type: pkCredential.type,
166 rawId: arrayBufferToBase64Url(pkCredential.rawId),
167 response: {
168 clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
169 attestationObject: arrayBufferToBase64Url(response.attestationObject),
170 },
171 }
172
173 const result = await api.completePasskeySetup(
174 flow.account.did,
175 flow.account.setupToken!,
176 credentialResponse,
177 passkeyName || undefined
178 )
179
180 flow.setPasskeyComplete(result.appPassword, result.appPasswordName)
181 } catch (err) {
182 if (err instanceof DOMException && err.name === 'NotAllowedError') {
183 flow.setError('Passkey creation was cancelled')
184 } else if (err instanceof ApiError) {
185 flow.setError(err.message || 'Passkey registration failed')
186 } else if (err instanceof Error) {
187 flow.setError(err.message || 'Passkey registration failed')
188 } else {
189 flow.setError('Passkey registration failed')
190 }
191 } finally {
192 flow.setSubmitting(false)
193 }
194 }
195
196 async function handleComplete() {
197 if (flow) {
198 await flow.finalizeSession()
199 }
200 navigate('/dashboard')
201 }
202
203 function isChannelAvailable(ch: string): boolean {
204 const available = serverInfo?.availableCommsChannels ?? ['email']
205 return available.includes(ch)
206 }
207
208 function channelLabel(ch: string): string {
209 switch (ch) {
210 case 'email': return 'Email'
211 case 'discord': return 'Discord'
212 case 'telegram': return 'Telegram'
213 case 'signal': return 'Signal'
214 default: return ch
215 }
216 }
217
218 let fullHandle = $derived(() => {
219 if (!flow?.info.handle.trim()) return ''
220 if (flow.info.handle.includes('.')) return flow.info.handle.trim()
221 const domain = serverInfo?.availableUserDomains?.[0]
222 if (domain) return `${flow.info.handle.trim()}.${domain}`
223 return flow.info.handle.trim()
224 })
225
226 function extractDomain(did: string): string {
227 return did.replace('did:web:', '').replace(/%3A/g, ':')
228 }
229
230 function getSubtitle(): string {
231 if (!flow) return ''
232 switch (flow.state.step) {
233 case 'info': return 'Create an ultra-secure account using a passkey instead of a password.'
234 case 'key-choice': return 'Choose how to set up your external did:web identity.'
235 case 'initial-did-doc': return 'Upload your DID document to continue.'
236 case 'creating': return 'Creating your account...'
237 case 'passkey': return 'Register your passkey to secure your account.'
238 case 'app-password': return 'Save your app password for third-party apps.'
239 case 'verify': return `Verify your ${channelLabel(flow.info.verificationChannel)} to continue.`
240 case 'updated-did-doc': return 'Update your DID document with the PDS signing key.'
241 case 'activating': return 'Activating your account...'
242 case 'complete': return 'Your account has been created successfully!'
243 default: return ''
244 }
245 }
246</script>
247
248<div class="register-page">
249 {#if flow?.state.step === 'info'}
250 <div class="migrate-callout">
251 <div class="migrate-icon">↗</div>
252 <div class="migrate-content">
253 <strong>{$_('register.migrateTitle')}</strong>
254 <p>{$_('register.migrateDescription')}</p>
255 <a href="https://pdsmoover.com/moover" target="_blank" rel="noopener" class="migrate-link">
256 {$_('register.migrateLink')} →
257 </a>
258 </div>
259 </div>
260 {/if}
261
262 <h1>Create Passkey Account</h1>
263 <p class="subtitle">{getSubtitle()}</p>
264
265 {#if flow?.state.error}
266 <div class="message error">{flow.state.error}</div>
267 {/if}
268
269 {#if loadingServerInfo || !flow}
270 <p class="loading">Loading...</p>
271
272 {:else if flow.state.step === 'info'}
273 <form onsubmit={handleInfoSubmit}>
274 <div class="field">
275 <label for="handle">Handle</label>
276 <input
277 id="handle"
278 type="text"
279 bind:value={flow.info.handle}
280 placeholder="yourname"
281 disabled={flow.state.submitting}
282 required
283 />
284 {#if flow.info.handle.includes('.')}
285 <p class="hint warning">Custom domain handles can be set up after account creation.</p>
286 {:else if fullHandle()}
287 <p class="hint">Your full handle will be: @{fullHandle()}</p>
288 {/if}
289 </div>
290
291 <fieldset class="section-fieldset">
292 <legend>Contact Method</legend>
293 <p class="section-hint">Choose how you'd like to verify your account and receive notifications.</p>
294 <div class="field">
295 <label for="verification-channel">Verification Method</label>
296 <select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
297 <option value="email">Email</option>
298 <option value="discord" disabled={!isChannelAvailable('discord')}>
299 Discord{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
300 </option>
301 <option value="telegram" disabled={!isChannelAvailable('telegram')}>
302 Telegram{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`}
303 </option>
304 <option value="signal" disabled={!isChannelAvailable('signal')}>
305 Signal{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`}
306 </option>
307 </select>
308 </div>
309 {#if flow.info.verificationChannel === 'email'}
310 <div class="field">
311 <label for="email">Email Address</label>
312 <input id="email" type="email" bind:value={flow.info.email} placeholder="you@example.com" disabled={flow.state.submitting} required />
313 </div>
314 {:else if flow.info.verificationChannel === 'discord'}
315 <div class="field">
316 <label for="discord-id">Discord User ID</label>
317 <input id="discord-id" type="text" bind:value={flow.info.discordId} placeholder="Your Discord user ID" disabled={flow.state.submitting} required />
318 <p class="hint">Your numeric Discord user ID (enable Developer Mode to find it)</p>
319 </div>
320 {:else if flow.info.verificationChannel === 'telegram'}
321 <div class="field">
322 <label for="telegram-username">Telegram Username</label>
323 <input id="telegram-username" type="text" bind:value={flow.info.telegramUsername} placeholder="@yourusername" disabled={flow.state.submitting} required />
324 </div>
325 {:else if flow.info.verificationChannel === 'signal'}
326 <div class="field">
327 <label for="signal-number">Signal Phone Number</label>
328 <input id="signal-number" type="tel" bind:value={flow.info.signalNumber} placeholder="+1234567890" disabled={flow.state.submitting} required />
329 <p class="hint">Include country code (e.g., +1 for US)</p>
330 </div>
331 {/if}
332 </fieldset>
333
334 <fieldset class="section-fieldset">
335 <legend>Identity Type</legend>
336 <p class="section-hint">Choose how your decentralized identity will be managed.</p>
337 <div class="radio-group">
338 <label class="radio-label">
339 <input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
340 <span class="radio-content">
341 <strong>did:plc</strong> (Recommended)
342 <span class="radio-hint">Portable identity managed by PLC Directory</span>
343 </span>
344 </label>
345 <label class="radio-label">
346 <input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting} />
347 <span class="radio-content">
348 <strong>did:web</strong>
349 <span class="radio-hint">Identity hosted on this PDS (read warning below)</span>
350 </span>
351 </label>
352 <label class="radio-label">
353 <input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} />
354 <span class="radio-content">
355 <strong>did:web (BYOD)</strong>
356 <span class="radio-hint">Bring your own domain</span>
357 </span>
358 </label>
359 </div>
360 {#if flow.info.didType === 'web'}
361 <div class="warning-box">
362 <strong>Important: Understand the trade-offs</strong>
363 <ul>
364 <li><strong>Permanent tie to this PDS:</strong> Your identity will be <code>did:web:yourhandle.{serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>.</li>
365 <li><strong>No recovery mechanism:</strong> Unlike did:plc, did:web has no rotation keys.</li>
366 <li><strong>We commit to you:</strong> If you migrate away, we will continue serving a minimal DID document.</li>
367 <li><strong>Recommendation:</strong> Choose did:plc unless you have a specific reason to prefer did:web.</li>
368 </ul>
369 </div>
370 {/if}
371 {#if flow.info.didType === 'web-external'}
372 <div class="field">
373 <label for="external-did">Your did:web</label>
374 <input id="external-did" type="text" bind:value={flow.info.externalDid} placeholder="did:web:yourdomain.com" disabled={flow.state.submitting} required />
375 <p class="hint">You'll need to serve a DID document at <code>https://{flow.info.externalDid ? extractDomain(flow.info.externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p>
376 </div>
377 {/if}
378 </fieldset>
379
380 {#if serverInfo?.inviteCodeRequired}
381 <div class="field">
382 <label for="invite-code">Invite Code <span class="required">*</span></label>
383 <input id="invite-code" type="text" bind:value={flow.info.inviteCode} placeholder="Enter your invite code" disabled={flow.state.submitting} required />
384 </div>
385 {/if}
386
387 <div class="info-box">
388 <strong>Why passkey-only?</strong>
389 <p>Passkey accounts are more secure than password-based accounts because they:</p>
390 <ul>
391 <li>Cannot be phished or stolen in data breaches</li>
392 <li>Use hardware-backed cryptographic keys</li>
393 <li>Require your biometric or device PIN to use</li>
394 </ul>
395 </div>
396
397 <button type="submit" disabled={flow.state.submitting}>
398 {flow.state.submitting ? 'Creating account...' : 'Continue'}
399 </button>
400 </form>
401
402 <p class="link-text">
403 Want a traditional password? <a href="#/register">Register with password</a>
404 </p>
405
406 {:else if flow.state.step === 'key-choice'}
407 <KeyChoiceStep {flow} />
408
409 {:else if flow.state.step === 'initial-did-doc'}
410 <DidDocStep
411 {flow}
412 type="initial"
413 onConfirm={handleCreateAccount}
414 onBack={() => flow?.goBack()}
415 />
416
417 {:else if flow.state.step === 'creating'}
418 {#await flow.createPasskeyAccount()}
419 <p class="loading">Creating your account...</p>
420 {/await}
421
422 {:else if flow.state.step === 'passkey'}
423 <div class="step-content">
424 <div class="field">
425 <label for="passkey-name">Passkey Name (optional)</label>
426 <input id="passkey-name" type="text" bind:value={passkeyName} placeholder="e.g., MacBook Touch ID" disabled={flow.state.submitting} />
427 <p class="hint">A friendly name to identify this passkey</p>
428 </div>
429
430 <div class="info-box">
431 <p>Click the button below to create your passkey. You'll be prompted to use:</p>
432 <ul>
433 <li>Touch ID or Face ID</li>
434 <li>Your device PIN or password</li>
435 <li>A security key (if you have one)</li>
436 </ul>
437 </div>
438
439 <button onclick={handlePasskeyRegistration} disabled={flow.state.submitting} class="passkey-btn">
440 {flow.state.submitting ? 'Creating Passkey...' : 'Create Passkey'}
441 </button>
442
443 <button type="button" class="secondary" onclick={() => flow?.goBack()} disabled={flow.state.submitting}>
444 Back
445 </button>
446 </div>
447
448 {:else if flow.state.step === 'app-password'}
449 <AppPasswordStep {flow} />
450
451 {:else if flow.state.step === 'verify'}
452 <VerificationStep {flow} />
453
454 {:else if flow.state.step === 'updated-did-doc'}
455 <DidDocStep
456 {flow}
457 type="updated"
458 onConfirm={() => flow?.activateAccount()}
459 />
460
461 {:else if flow.state.step === 'redirect-to-dashboard'}
462 <p class="loading">Redirecting to dashboard...</p>
463 {/if}
464</div>
465
466<style>
467 .register-page {
468 max-width: var(--width-sm);
469 margin: var(--space-9) auto;
470 padding: var(--space-7);
471 }
472
473 .migrate-callout {
474 display: flex;
475 gap: var(--space-4);
476 padding: var(--space-5);
477 background: var(--accent-muted);
478 border: 1px solid var(--accent);
479 border-radius: var(--radius-xl);
480 margin-bottom: var(--space-6);
481 }
482
483 .migrate-icon {
484 font-size: var(--text-2xl);
485 line-height: 1;
486 color: var(--accent);
487 }
488
489 .migrate-content {
490 flex: 1;
491 }
492
493 .migrate-content strong {
494 display: block;
495 color: var(--text-primary);
496 margin-bottom: var(--space-2);
497 }
498
499 .migrate-content p {
500 margin: 0 0 var(--space-3) 0;
501 font-size: var(--text-sm);
502 color: var(--text-secondary);
503 line-height: var(--leading-relaxed);
504 }
505
506 .migrate-link {
507 font-size: var(--text-sm);
508 font-weight: var(--font-medium);
509 color: var(--accent);
510 text-decoration: none;
511 }
512
513 .migrate-link:hover {
514 text-decoration: underline;
515 }
516
517 h1 {
518 margin: 0 0 var(--space-3) 0;
519 }
520
521 .subtitle {
522 color: var(--text-secondary);
523 margin: 0 0 var(--space-7) 0;
524 }
525
526 .loading {
527 text-align: center;
528 color: var(--text-secondary);
529 }
530
531 form, .step-content {
532 display: flex;
533 flex-direction: column;
534 gap: var(--space-4);
535 }
536
537 .required {
538 color: var(--error-text);
539 }
540
541 .section-fieldset {
542 border: 1px solid var(--border-color);
543 border-radius: var(--radius-lg);
544 padding: var(--space-5);
545 }
546
547 .section-fieldset legend {
548 font-weight: var(--font-semibold);
549 padding: 0 var(--space-3);
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 .warning-box {
591 margin-top: var(--space-5);
592 padding: var(--space-5);
593 background: var(--warning-bg);
594 border: 1px solid var(--warning-border);
595 border-radius: var(--radius-lg);
596 font-size: var(--text-sm);
597 }
598
599 .warning-box strong {
600 display: block;
601 margin-bottom: var(--space-3);
602 color: var(--warning-text);
603 }
604
605 .warning-box ul {
606 margin: var(--space-4) 0 0 0;
607 padding-left: var(--space-5);
608 }
609
610 .warning-box li {
611 margin-bottom: var(--space-3);
612 line-height: var(--leading-normal);
613 }
614
615 .warning-box li:last-child {
616 margin-bottom: 0;
617 }
618
619 .info-box {
620 background: var(--bg-secondary);
621 border: 1px solid var(--border-color);
622 border-radius: var(--radius-lg);
623 padding: var(--space-5);
624 font-size: var(--text-sm);
625 }
626
627 .info-box strong {
628 display: block;
629 margin-bottom: var(--space-3);
630 }
631
632 .info-box p {
633 margin: 0 0 var(--space-3) 0;
634 color: var(--text-secondary);
635 }
636
637 .info-box ul {
638 margin: 0;
639 padding-left: var(--space-5);
640 color: var(--text-secondary);
641 }
642
643 .info-box li {
644 margin-bottom: var(--space-2);
645 }
646
647 .passkey-btn {
648 padding: var(--space-5);
649 font-size: var(--text-lg);
650 }
651
652 .link-text {
653 text-align: center;
654 margin-top: var(--space-6);
655 color: var(--text-secondary);
656 }
657
658 .link-text a {
659 color: var(--accent);
660 }
661</style>