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