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>