Our Personal Data Server from scratch!
0

Configure Feed

Select the types of activity you want to include in your feed.

tranquil-pds / frontend / src / routes / OAuthRegister.svelte
24 kB 779 lines
1<script lang="ts"> 2 import { navigate, routes, getFullUrl } from '../lib/router.svelte' 3 import { api } from '../lib/api' 4 import { _ } from '../lib/i18n' 5 import { 6 createRegistrationFlow, 7 restoreRegistrationFlow, 8 VerificationStep, 9 KeyChoiceStep, 10 DidDocStep, 11 AppPasswordStep, 12 } from '../lib/registration' 13 import { 14 prepareCreationOptions, 15 serializeAttestationResponse, 16 type WebAuthnCreationOptionsResponse, 17 } from '../lib/webauthn' 18 import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte' 19 20 let serverInfo = $state<{ 21 availableUserDomains: string[] 22 inviteCodeRequired: boolean 23 availableCommsChannels?: string[] 24 selfHostedDidWebEnabled?: boolean 25 } | null>(null) 26 let loadingServerInfo = $state(true) 27 let serverInfoLoaded = false 28 let ssoAvailable = $state(false) 29 30 let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null) 31 let passkeyName = $state('') 32 let clientName = $state<string | null>(null) 33 34 function getRequestUri(): string | null { 35 const params = new URLSearchParams(window.location.search) 36 return params.get('request_uri') 37 } 38 39 $effect(() => { 40 if (!serverInfoLoaded) { 41 serverInfoLoaded = true 42 loadServerInfo() 43 fetchClientName() 44 checkSsoAvailable() 45 } 46 }) 47 48 async function checkSsoAvailable() { 49 try { 50 const response = await fetch('/oauth/sso/providers') 51 if (response.ok) { 52 const data = await response.json() 53 ssoAvailable = (data.providers?.length ?? 0) > 0 54 } 55 } catch { 56 ssoAvailable = false 57 } 58 } 59 60 async function fetchClientName() { 61 const requestUri = getRequestUri() 62 if (!requestUri) return 63 64 try { 65 const response = await fetch(`/oauth/authorize?request_uri=${encodeURIComponent(requestUri)}`, { 66 headers: { 'Accept': 'application/json' } 67 }) 68 if (response.ok) { 69 const data = await response.json() 70 clientName = data.client_name || null 71 } 72 } catch { 73 clientName = null 74 } 75 } 76 77 $effect(() => { 78 if (flow?.state.step === 'redirect-to-dashboard') { 79 completeOAuthRegistration() 80 } 81 }) 82 83 let creatingStarted = false 84 $effect(() => { 85 if (flow?.state.step === 'creating' && !creatingStarted) { 86 creatingStarted = true 87 flow.createPasskeyAccount() 88 } 89 }) 90 91 async function loadServerInfo() { 92 try { 93 const restored = restoreRegistrationFlow() 94 if (restored && restored.state.mode === 'passkey') { 95 flow = restored 96 serverInfo = await api.describeServer() 97 } else { 98 serverInfo = await api.describeServer() 99 const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname 100 flow = createRegistrationFlow('passkey', hostname) 101 } 102 } catch (e) { 103 console.error('Failed to load server info:', e) 104 } finally { 105 loadingServerInfo = false 106 } 107 } 108 109 function validateInfoStep(): string | null { 110 if (!flow) return 'Flow not initialized' 111 const info = flow.info 112 if (!info.handle.trim()) return $_('registerPasskey.errors.handleRequired') 113 if (info.handle.includes('.')) return $_('registerPasskey.errors.handleNoDots') 114 if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) { 115 return $_('registerPasskey.errors.inviteRequired') 116 } 117 if (info.didType === 'web-external') { 118 if (!info.externalDid?.trim()) return $_('registerPasskey.errors.externalDidRequired') 119 if (!info.externalDid.trim().startsWith('did:web:')) return $_('registerPasskey.errors.externalDidFormat') 120 } 121 switch (info.verificationChannel) { 122 case 'email': 123 if (!info.email.trim()) return $_('registerPasskey.errors.emailRequired') 124 break 125 case 'discord': 126 if (!info.discordUsername?.trim()) return $_('registerPasskey.errors.discordRequired') 127 break 128 case 'telegram': 129 if (!info.telegramUsername?.trim()) return $_('registerPasskey.errors.telegramRequired') 130 break 131 case 'signal': 132 if (!info.signalUsername?.trim()) return $_('registerPasskey.errors.signalRequired') 133 break 134 } 135 return null 136 } 137 138 async function handleInfoSubmit(e: Event) { 139 e.preventDefault() 140 if (!flow) return 141 142 const validationError = validateInfoStep() 143 if (validationError) { 144 flow.setError(validationError) 145 return 146 } 147 148 if (!window.PublicKeyCredential) { 149 flow.setError($_('registerPasskey.errors.passkeysNotSupported')) 150 return 151 } 152 153 flow.clearError() 154 flow.proceedFromInfo() 155 } 156 157 async function handlePasskeyRegistration() { 158 if (!flow || !flow.account) return 159 160 flow.setSubmitting(true) 161 flow.clearError() 162 163 try { 164 const { options } = await api.startPasskeyRegistrationForSetup( 165 flow.account.did, 166 flow.account.setupToken!, 167 passkeyName || undefined 168 ) 169 170 const publicKeyOptions = prepareCreationOptions(options as unknown as WebAuthnCreationOptionsResponse) 171 const credential = await navigator.credentials.create({ 172 publicKey: publicKeyOptions 173 }) 174 175 if (!credential) { 176 flow.setError($_('registerPasskey.errors.passkeyCancelled')) 177 flow.setSubmitting(false) 178 return 179 } 180 181 const credentialResponse = serializeAttestationResponse(credential as PublicKeyCredential) 182 183 const result = await api.completePasskeySetup( 184 flow.account.did, 185 flow.account.setupToken!, 186 credentialResponse, 187 passkeyName || undefined 188 ) 189 190 flow.setPasskeyComplete(result.appPassword, result.appPasswordName) 191 } catch (err) { 192 if (err instanceof DOMException && err.name === 'NotAllowedError') { 193 flow.setError($_('registerPasskey.errors.passkeyCancelled')) 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 completeOAuthRegistration() { 205 const requestUri = getRequestUri() 206 if (!requestUri || !flow?.account) { 207 navigate(routes.dashboard) 208 return 209 } 210 211 try { 212 const response = await fetch('/oauth/register/complete', { 213 method: 'POST', 214 headers: { 215 'Content-Type': 'application/json', 216 'Accept': 'application/json', 217 }, 218 body: JSON.stringify({ 219 request_uri: requestUri, 220 did: flow.account.did, 221 app_password: flow.account.appPassword, 222 }), 223 }) 224 225 const data = await response.json() 226 227 if (!response.ok) { 228 flow.setError(data.error_description || data.error || $_('common.error')) 229 return 230 } 231 232 if (data.redirect_uri) { 233 window.location.href = data.redirect_uri 234 return 235 } 236 237 navigate(routes.dashboard) 238 } catch { 239 flow.setError($_('common.error')) 240 } 241 } 242 243 function isChannelAvailable(ch: string): boolean { 244 const available = serverInfo?.availableCommsChannels ?? ['email'] 245 return available.includes(ch) 246 } 247 248 function channelLabel(ch: string): string { 249 switch (ch) { 250 case 'email': 251 return $_('register.email') 252 case 'discord': 253 return $_('register.discord') 254 case 'telegram': 255 return $_('register.telegram') 256 case 'signal': 257 return $_('register.signal') 258 default: 259 return ch 260 } 261 } 262 263 let fullHandle = $derived(() => { 264 if (!flow?.info.handle.trim()) return '' 265 return `${flow.info.handle.trim()}.${flow.state.pdsHostname}` 266 }) 267 268 async function handleCancel() { 269 const requestUri = getRequestUri() 270 if (!requestUri) { 271 window.history.back() 272 return 273 } 274 275 try { 276 const response = await fetch('/oauth/authorize/deny', { 277 method: 'POST', 278 headers: { 279 'Content-Type': 'application/json', 280 'Accept': 'application/json' 281 }, 282 body: JSON.stringify({ request_uri: requestUri }) 283 }) 284 285 const data = await response.json() 286 if (data.redirect_uri) { 287 window.location.href = data.redirect_uri 288 } 289 } catch { 290 window.history.back() 291 } 292 } 293 294 function goToLogin() { 295 const requestUri = getRequestUri() 296 if (requestUri) { 297 navigate(routes.oauthLogin, { params: { request_uri: requestUri } }) 298 } else { 299 navigate(routes.login) 300 } 301 } 302</script> 303 304<div class="oauth-register-container"> 305 {#if loadingServerInfo} 306 <div class="loading"> 307 <div class="spinner"></div> 308 <p>{$_('common.loading')}</p> 309 </div> 310 {:else if flow} 311 <header class="page-header"> 312 <h1>{$_('oauth.register.title')}</h1> 313 <p class="subtitle"> 314 {#if clientName} 315 {$_('oauth.register.subtitle')} <strong>{clientName}</strong> 316 {:else} 317 {$_('oauth.register.subtitleGeneric')} 318 {/if} 319 </p> 320 </header> 321 322 {#if flow.state.error} 323 <div class="error">{flow.state.error}</div> 324 {/if} 325 326 {#if flow.state.step === 'info'} 327 <div class="migrate-callout"> 328 <div class="migrate-icon"></div> 329 <div class="migrate-content"> 330 <strong>{$_('register.migrateTitle')}</strong> 331 <p>{$_('register.migrateDescription')}</p> 332 <a href={getFullUrl(routes.migrate)} class="migrate-link"> 333 {$_('register.migrateLink')}334 </a> 335 </div> 336 </div> 337 338 <AccountTypeSwitcher active="passkey" {ssoAvailable} oauthRequestUri={getRequestUri()} /> 339 340 <div class="split-layout"> 341 <div class="form-section"> 342 <form onsubmit={handleInfoSubmit}> 343 <div class="field"> 344 <label for="handle">{$_('register.handle')}</label> 345 <input 346 id="handle" 347 type="text" 348 bind:value={flow.info.handle} 349 placeholder={$_('register.handlePlaceholder')} 350 disabled={flow.state.submitting} 351 required 352 autocomplete="off" 353 /> 354 {#if fullHandle()} 355 <p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p> 356 {/if} 357 </div> 358 359 <fieldset> 360 <legend>{$_('register.contactMethod')}</legend> 361 <div class="contact-fields"> 362 <div class="field"> 363 <label for="verification-channel">{$_('register.verificationMethod')}</label> 364 <select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}> 365 <option value="email">{channelLabel('email')}</option> 366 {#if isChannelAvailable('discord')} 367 <option value="discord">{channelLabel('discord')}</option> 368 {/if} 369 {#if isChannelAvailable('telegram')} 370 <option value="telegram">{channelLabel('telegram')}</option> 371 {/if} 372 {#if isChannelAvailable('signal')} 373 <option value="signal">{channelLabel('signal')}</option> 374 {/if} 375 </select> 376 </div> 377 378 {#if flow.info.verificationChannel === 'email'} 379 <div class="field"> 380 <label for="email">{$_('register.emailAddress')}</label> 381 <input 382 id="email" 383 type="email" 384 bind:value={flow.info.email} 385 placeholder={$_('register.emailPlaceholder')} 386 disabled={flow.state.submitting} 387 required 388 /> 389 </div> 390 {:else if flow.info.verificationChannel === 'discord'} 391 <div class="field"> 392 <label for="discord-username">{$_('register.discordUsername')}</label> 393 <input 394 id="discord-username" 395 type="text" 396 bind:value={flow.info.discordUsername} 397 placeholder={$_('register.discordUsernamePlaceholder')} 398 disabled={flow.state.submitting} 399 required 400 /> 401 </div> 402 {:else if flow.info.verificationChannel === 'telegram'} 403 <div class="field"> 404 <label for="telegram-username">{$_('register.telegramUsername')}</label> 405 <input 406 id="telegram-username" 407 type="text" 408 bind:value={flow.info.telegramUsername} 409 placeholder={$_('register.telegramUsernamePlaceholder')} 410 disabled={flow.state.submitting} 411 required 412 /> 413 </div> 414 {:else if flow.info.verificationChannel === 'signal'} 415 <div class="field"> 416 <label for="signal-number">{$_('register.signalUsername')}</label> 417 <input 418 id="signal-number" 419 type="tel" 420 bind:value={flow.info.signalUsername} 421 placeholder={$_('register.signalUsernamePlaceholder')} 422 disabled={flow.state.submitting} 423 required 424 /> 425 <p class="hint">{$_('register.signalUsernameHint')}</p> 426 </div> 427 {/if} 428 </div> 429 </fieldset> 430 431 <fieldset> 432 <legend>{$_('registerPasskey.identityType')}</legend> 433 <p class="section-hint">{$_('registerPasskey.identityTypeHint')}</p> 434 <div class="radio-group"> 435 <label class="radio-label"> 436 <input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 437 <span class="radio-content"> 438 <strong>{$_('registerPasskey.didPlcRecommended')}</strong> 439 <span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span> 440 </span> 441 </label> 442 <label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}> 443 <input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting || serverInfo?.selfHostedDidWebEnabled === false} /> 444 <span class="radio-content"> 445 <strong>{$_('registerPasskey.didWeb')}</strong> 446 {#if serverInfo?.selfHostedDidWebEnabled === false} 447 <span class="radio-hint disabled-hint">{$_('registerPasskey.didWebDisabledHint')}</span> 448 {:else} 449 <span class="radio-hint">{$_('registerPasskey.didWebHint')}</span> 450 {/if} 451 </span> 452 </label> 453 <label class="radio-label"> 454 <input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 455 <span class="radio-content"> 456 <strong>{$_('registerPasskey.didWebBYOD')}</strong> 457 <span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span> 458 </span> 459 </label> 460 </div> 461 {#if flow.info.didType === 'web'} 462 <div class="warning-box"> 463 <strong>{$_('registerPasskey.didWebWarningTitle')}</strong> 464 <ul> 465 <li><strong>{$_('registerPasskey.didWebWarning1')}</strong> {@html $_('registerPasskey.didWebWarning1Detail', { values: { did: `<code>did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>` } })}</li> 466 <li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li> 467 <li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li> 468 <li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li> 469 </ul> 470 </div> 471 {/if} 472 {#if flow.info.didType === 'web-external'} 473 <div class="field"> 474 <label for="external-did">{$_('registerPasskey.externalDid')}</label> 475 <input id="external-did" type="text" bind:value={flow.info.externalDid} placeholder={$_('registerPasskey.externalDidPlaceholder')} disabled={flow.state.submitting} required /> 476 <p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{flow.info.externalDid ? flow.extractDomain(flow.info.externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p> 477 </div> 478 {/if} 479 </fieldset> 480 481 {#if serverInfo?.inviteCodeRequired} 482 <div class="field"> 483 <label for="invite-code">{$_('register.inviteCode')} <span class="required">*</span></label> 484 <input 485 id="invite-code" 486 type="text" 487 bind:value={flow.info.inviteCode} 488 placeholder={$_('register.inviteCodePlaceholder')} 489 disabled={flow.state.submitting} 490 required 491 /> 492 </div> 493 {/if} 494 495 <div class="actions"> 496 <button type="submit" class="primary" disabled={flow.state.submitting}> 497 {flow.state.submitting ? $_('common.loading') : $_('common.continue')} 498 </button> 499 </div> 500 501 <div class="secondary-actions"> 502 <button type="button" class="link-btn" onclick={goToLogin}> 503 {$_('oauth.register.haveAccount')} 504 </button> 505 <button type="button" class="link-btn" onclick={handleCancel}> 506 {$_('common.cancel')} 507 </button> 508 </div> 509 </form> 510 511 <div class="form-links"> 512 <p class="link-text"> 513 {$_('register.alreadyHaveAccount')} <a href="/app/login">{$_('register.signIn')}</a> 514 </p> 515 </div> 516 </div> 517 518 <aside class="info-panel"> 519 <h3>{$_('registerPasskey.infoWhyPasskey')}</h3> 520 <p>{$_('registerPasskey.infoWhyPasskeyDesc')}</p> 521 522 <h3>{$_('registerPasskey.infoHowItWorks')}</h3> 523 <p>{$_('registerPasskey.infoHowItWorksDesc')}</p> 524 525 <h3>{$_('registerPasskey.infoAppAccess')}</h3> 526 <p>{$_('registerPasskey.infoAppAccessDesc')}</p> 527 </aside> 528 </div> 529 530 {:else if flow.state.step === 'key-choice'} 531 <KeyChoiceStep {flow} /> 532 533 {:else if flow.state.step === 'initial-did-doc'} 534 <DidDocStep {flow} type="initial" onConfirm={() => flow?.createPasskeyAccount()} onBack={() => flow?.goBack()} /> 535 536 {:else if flow.state.step === 'creating'} 537 <div class="creating"> 538 <div class="spinner"></div> 539 <p>{$_('registerPasskey.creatingAccount')}</p> 540 </div> 541 542 {:else if flow.state.step === 'passkey'} 543 <div class="passkey-step"> 544 <h2>{$_('registerPasskey.setupPasskey')}</h2> 545 <p>{$_('registerPasskey.passkeyDescription')}</p> 546 547 <div class="field"> 548 <label for="passkey-name">{$_('registerPasskey.passkeyName')}</label> 549 <input 550 id="passkey-name" 551 type="text" 552 bind:value={passkeyName} 553 placeholder={$_('registerPasskey.passkeyNamePlaceholder')} 554 disabled={flow.state.submitting} 555 /> 556 <p class="hint">{$_('registerPasskey.passkeyNameHint')}</p> 557 </div> 558 559 <button 560 type="button" 561 class="primary" 562 onclick={handlePasskeyRegistration} 563 disabled={flow.state.submitting} 564 > 565 {flow.state.submitting ? $_('common.loading') : $_('registerPasskey.registerPasskey')} 566 </button> 567 </div> 568 569 {:else if flow.state.step === 'app-password'} 570 <AppPasswordStep {flow} /> 571 572 {:else if flow.state.step === 'verify'} 573 <VerificationStep {flow} /> 574 575 {:else if flow.state.step === 'updated-did-doc'} 576 <DidDocStep {flow} type="updated" onConfirm={() => flow?.activateAccount()} /> 577 578 {:else if flow.state.step === 'activating'} 579 <div class="creating"> 580 <div class="spinner"></div> 581 <p>{$_('registerPasskey.activatingAccount')}</p> 582 </div> 583 {/if} 584 {/if} 585</div> 586 587<style> 588 .oauth-register-container { 589 max-width: var(--width-lg); 590 margin: var(--space-9) auto; 591 padding: var(--space-7); 592 } 593 594 .loading, .creating { 595 display: flex; 596 flex-direction: column; 597 align-items: center; 598 gap: var(--space-4); 599 padding: var(--space-8); 600 } 601 602 .loading p, .creating p { 603 color: var(--text-secondary); 604 } 605 606 .page-header { 607 margin-bottom: var(--space-6); 608 } 609 610 .page-header h1 { 611 margin: 0 0 var(--space-2) 0; 612 } 613 614 .subtitle { 615 color: var(--text-secondary); 616 margin: 0; 617 } 618 619 .form-section { 620 min-width: 0; 621 } 622 623 .form-links { 624 margin-top: var(--space-6); 625 } 626 627 .link-text { 628 text-align: center; 629 color: var(--text-secondary); 630 } 631 632 .link-text a { 633 color: var(--accent); 634 } 635 636 form { 637 display: flex; 638 flex-direction: column; 639 gap: var(--space-5); 640 } 641 642 .field { 643 display: flex; 644 flex-direction: column; 645 gap: var(--space-1); 646 } 647 648 label { 649 font-size: var(--text-sm); 650 font-weight: var(--font-medium); 651 } 652 653 input, select { 654 padding: var(--space-3); 655 border: 1px solid var(--border-color); 656 border-radius: var(--radius-md); 657 font-size: var(--text-base); 658 background: var(--bg-input); 659 color: var(--text-primary); 660 } 661 662 input:focus, select:focus { 663 outline: none; 664 border-color: var(--accent); 665 } 666 667 .hint { 668 font-size: var(--text-xs); 669 color: var(--text-muted); 670 margin: var(--space-1) 0 0 0; 671 } 672 673 .error { 674 padding: var(--space-3); 675 background: var(--error-bg); 676 border: 1px solid var(--error-border); 677 border-radius: var(--radius-md); 678 color: var(--error-text); 679 margin-bottom: var(--space-4); 680 } 681 682 .actions { 683 display: flex; 684 gap: var(--space-4); 685 margin-top: var(--space-2); 686 } 687 688 button.primary { 689 flex: 1; 690 padding: var(--space-3); 691 background: var(--accent); 692 color: var(--text-inverse); 693 border: none; 694 border-radius: var(--radius-md); 695 font-size: var(--text-base); 696 cursor: pointer; 697 transition: background-color var(--transition-fast); 698 } 699 700 button.primary:hover:not(:disabled) { 701 background: var(--accent-hover); 702 } 703 704 button.primary:disabled { 705 opacity: 0.6; 706 cursor: not-allowed; 707 } 708 709 .secondary-actions { 710 display: flex; 711 justify-content: center; 712 gap: var(--space-4); 713 margin-top: var(--space-4); 714 } 715 716 .link-btn { 717 background: none; 718 border: none; 719 color: var(--accent); 720 cursor: pointer; 721 font-size: var(--text-sm); 722 padding: var(--space-2); 723 } 724 725 .link-btn:hover { 726 text-decoration: underline; 727 } 728 729 .contact-fields { 730 display: flex; 731 flex-direction: column; 732 gap: var(--space-4); 733 } 734 735 .required { 736 color: var(--error-text); 737 } 738 739 .passkey-step { 740 display: flex; 741 flex-direction: column; 742 gap: var(--space-4); 743 } 744 745 .passkey-step h2 { 746 margin: 0; 747 } 748 749 .passkey-step p { 750 color: var(--text-secondary); 751 margin: 0; 752 } 753 754 fieldset { 755 border: 1px solid var(--border-color); 756 border-radius: var(--radius-md); 757 padding: var(--space-4); 758 } 759 760 legend { 761 padding: 0 var(--space-2); 762 font-weight: var(--font-medium); 763 } 764 765 .spinner { 766 width: 32px; 767 height: 32px; 768 border: 3px solid var(--border-color); 769 border-top-color: var(--accent); 770 border-radius: 50%; 771 animation: spin 1s linear infinite; 772 } 773 774 @keyframes spin { 775 to { 776 transform: rotate(360deg); 777 } 778 } 779</style>