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>