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