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 ? $_('common.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>