this repo has no description
1<script lang="ts"> 2 import { navigate } from '../lib/router.svelte' 3 import { api, ApiError, type VerificationChannel, type DidType } from '../lib/api' 4 import { getAuthState, confirmSignup, resendVerification } from '../lib/auth.svelte' 5 import { _ } from '../lib/i18n' 6 7 const auth = getAuthState() 8 9 let step = $state<'info' | 'passkey' | 'app-password' | 'verify' | 'success'>('info') 10 let handle = $state('') 11 let email = $state('') 12 let inviteCode = $state('') 13 let didType = $state<DidType>('plc') 14 let externalDid = $state('') 15 let verificationChannel = $state<VerificationChannel>('email') 16 let discordId = $state('') 17 let telegramUsername = $state('') 18 let signalNumber = $state('') 19 let passkeyName = $state('') 20 let submitting = $state(false) 21 let error = $state<string | null>(null) 22 let serverInfo = $state<{ availableUserDomains: string[]; inviteCodeRequired: boolean } | null>(null) 23 let loadingServerInfo = $state(true) 24 let serverInfoLoaded = false 25 26 let setupData = $state<{ did: string; handle: string; setupToken: string } | null>(null) 27 let appPasswordResult = $state<{ appPassword: string; appPasswordName: string } | null>(null) 28 let appPasswordAcknowledged = $state(false) 29 let appPasswordCopied = $state(false) 30 let verificationCode = $state('') 31 let resendingCode = $state(false) 32 let resendMessage = $state<string | null>(null) 33 34 $effect(() => { 35 if (!serverInfoLoaded) { 36 serverInfoLoaded = true 37 loadServerInfo() 38 } 39 }) 40 41 async function loadServerInfo() { 42 try { 43 serverInfo = await api.describeServer() 44 } catch (e) { 45 console.error('Failed to load server info:', e) 46 } finally { 47 loadingServerInfo = false 48 } 49 } 50 51 function validateInfoStep(): string | null { 52 if (!handle.trim()) return 'Handle is required' 53 if (handle.includes('.')) return 'Handle cannot contain dots. You can set up a custom domain handle after creating your account.' 54 if (serverInfo?.inviteCodeRequired && !inviteCode.trim()) { 55 return 'Invite code is required' 56 } 57 if (didType === 'web-external') { 58 if (!externalDid.trim()) return 'External did:web is required' 59 if (!externalDid.trim().startsWith('did:web:')) return 'External DID must start with did:web:' 60 } 61 switch (verificationChannel) { 62 case 'email': 63 if (!email.trim()) return 'Email is required for email verification' 64 break 65 case 'discord': 66 if (!discordId.trim()) return 'Discord ID is required for Discord verification' 67 break 68 case 'telegram': 69 if (!telegramUsername.trim()) return 'Telegram username is required for Telegram verification' 70 break 71 case 'signal': 72 if (!signalNumber.trim()) return 'Phone number is required for Signal verification' 73 break 74 } 75 return null 76 } 77 78 function arrayBufferToBase64Url(buffer: ArrayBuffer): string { 79 const bytes = new Uint8Array(buffer) 80 let binary = '' 81 for (let i = 0; i < bytes.byteLength; i++) { 82 binary += String.fromCharCode(bytes[i]) 83 } 84 return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') 85 } 86 87 function base64UrlToArrayBuffer(base64url: string): ArrayBuffer { 88 const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') 89 const padded = base64 + '='.repeat((4 - base64.length % 4) % 4) 90 const binary = atob(padded) 91 const bytes = new Uint8Array(binary.length) 92 for (let i = 0; i < binary.length; i++) { 93 bytes[i] = binary.charCodeAt(i) 94 } 95 return bytes.buffer 96 } 97 98 function preparePublicKeyOptions(options: any): PublicKeyCredentialCreationOptions { 99 return { 100 ...options.publicKey, 101 challenge: base64UrlToArrayBuffer(options.publicKey.challenge), 102 user: { 103 ...options.publicKey.user, 104 id: base64UrlToArrayBuffer(options.publicKey.user.id) 105 }, 106 excludeCredentials: options.publicKey.excludeCredentials?.map((cred: any) => ({ 107 ...cred, 108 id: base64UrlToArrayBuffer(cred.id) 109 })) || [] 110 } 111 } 112 113 async function handleInfoSubmit(e: Event) { 114 e.preventDefault() 115 const validationError = validateInfoStep() 116 if (validationError) { 117 error = validationError 118 return 119 } 120 121 if (!window.PublicKeyCredential) { 122 error = 'Passkeys are not supported in this browser. Please use a different browser or register with a password instead.' 123 return 124 } 125 126 submitting = true 127 error = null 128 129 try { 130 const result = await api.createPasskeyAccount({ 131 handle: handle.trim(), 132 email: email.trim() || undefined, 133 inviteCode: inviteCode.trim() || undefined, 134 didType, 135 did: didType === 'web-external' ? externalDid.trim() : undefined, 136 verificationChannel, 137 discordId: discordId.trim() || undefined, 138 telegramUsername: telegramUsername.trim() || undefined, 139 signalNumber: signalNumber.trim() || undefined, 140 }) 141 142 setupData = { 143 did: result.did, 144 handle: result.handle, 145 setupToken: result.setupToken, 146 } 147 148 step = 'passkey' 149 } catch (err) { 150 if (err instanceof ApiError) { 151 error = err.message || 'Registration failed' 152 } else if (err instanceof Error) { 153 error = err.message || 'Registration failed' 154 } else { 155 error = 'Registration failed' 156 } 157 } finally { 158 submitting = false 159 } 160 } 161 162 async function handlePasskeyRegistration() { 163 if (!setupData) return 164 165 submitting = true 166 error = null 167 168 try { 169 const { options } = await api.startPasskeyRegistrationForSetup( 170 setupData.did, 171 setupData.setupToken, 172 passkeyName || undefined 173 ) 174 175 const publicKeyOptions = preparePublicKeyOptions(options) 176 const credential = await navigator.credentials.create({ 177 publicKey: publicKeyOptions 178 }) 179 180 if (!credential) { 181 error = 'Passkey creation was cancelled' 182 submitting = false 183 return 184 } 185 186 const pkCredential = credential as PublicKeyCredential 187 const response = pkCredential.response as AuthenticatorAttestationResponse 188 const credentialResponse = { 189 id: pkCredential.id, 190 type: pkCredential.type, 191 rawId: arrayBufferToBase64Url(pkCredential.rawId), 192 response: { 193 clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON), 194 attestationObject: arrayBufferToBase64Url(response.attestationObject), 195 }, 196 } 197 198 const result = await api.completePasskeySetup( 199 setupData.did, 200 setupData.setupToken, 201 credentialResponse, 202 passkeyName || undefined 203 ) 204 205 appPasswordResult = { 206 appPassword: result.appPassword, 207 appPasswordName: result.appPasswordName, 208 } 209 210 step = 'app-password' 211 } catch (err) { 212 if (err instanceof DOMException && err.name === 'NotAllowedError') { 213 error = 'Passkey creation was cancelled' 214 } else if (err instanceof ApiError) { 215 error = err.message || 'Passkey registration failed' 216 } else if (err instanceof Error) { 217 error = err.message || 'Passkey registration failed' 218 } else { 219 error = 'Passkey registration failed' 220 } 221 } finally { 222 submitting = false 223 } 224 } 225 226 function copyAppPassword() { 227 if (appPasswordResult) { 228 navigator.clipboard.writeText(appPasswordResult.appPassword) 229 appPasswordCopied = true 230 } 231 } 232 233 function handleFinish() { 234 step = 'verify' 235 } 236 237 async function handleVerification() { 238 if (!setupData || !verificationCode.trim()) return 239 240 submitting = true 241 error = null 242 243 try { 244 await confirmSignup(setupData.did, verificationCode.trim()) 245 navigate('/dashboard') 246 } catch (err) { 247 if (err instanceof ApiError) { 248 error = err.message || 'Verification failed' 249 } else if (err instanceof Error) { 250 error = err.message || 'Verification failed' 251 } else { 252 error = 'Verification failed' 253 } 254 } finally { 255 submitting = false 256 } 257 } 258 259 async function handleResendCode() { 260 if (!setupData || resendingCode) return 261 262 resendingCode = true 263 resendMessage = null 264 error = null 265 266 try { 267 await resendVerification(setupData.did) 268 resendMessage = 'Verification code resent!' 269 } catch (err) { 270 if (err instanceof ApiError) { 271 error = err.message || 'Failed to resend code' 272 } else if (err instanceof Error) { 273 error = err.message || 'Failed to resend code' 274 } else { 275 error = 'Failed to resend code' 276 } 277 } finally { 278 resendingCode = false 279 } 280 } 281 282 function channelLabel(ch: string): string { 283 switch (ch) { 284 case 'email': return 'Email' 285 case 'discord': return 'Discord' 286 case 'telegram': return 'Telegram' 287 case 'signal': return 'Signal' 288 default: return ch 289 } 290 } 291 292 function goToLogin() { 293 navigate('/login') 294 } 295 296 let fullHandle = $derived(() => { 297 if (!handle.trim()) return '' 298 if (handle.includes('.')) return handle.trim() 299 const domain = serverInfo?.availableUserDomains?.[0] 300 if (domain) return `${handle.trim()}.${domain}` 301 return handle.trim() 302 }) 303</script> 304 305<div class="register-page"> 306 <h1>Create Passkey Account</h1> 307 <p class="subtitle"> 308 {#if step === 'info'} 309 Create an ultra-secure account using a passkey instead of a password. 310 {:else if step === 'passkey'} 311 Register your passkey to secure your account. 312 {:else if step === 'app-password'} 313 Save your app password for third-party apps. 314 {:else if step === 'verify'} 315 Verify your {channelLabel(verificationChannel)} to complete registration. 316 {:else} 317 Your account has been created successfully! 318 {/if} 319 </p> 320 321 {#if error} 322 <div class="message error">{error}</div> 323 {/if} 324 325 {#if loadingServerInfo} 326 <p class="loading">Loading...</p> 327 {:else if step === 'info'} 328 <form onsubmit={handleInfoSubmit}> 329 <div class="field"> 330 <label for="handle">Handle</label> 331 <input 332 id="handle" 333 type="text" 334 bind:value={handle} 335 placeholder="yourname" 336 disabled={submitting} 337 required 338 /> 339 {#if handle.includes('.')} 340 <p class="hint warning">Custom domain handles can be set up after account creation.</p> 341 {:else if fullHandle()} 342 <p class="hint">Your full handle will be: @{fullHandle()}</p> 343 {/if} 344 </div> 345 346 <fieldset class="section-fieldset"> 347 <legend>Contact Method</legend> 348 <p class="section-hint">Choose how you'd like to verify your account and receive notifications.</p> 349 <div class="field"> 350 <label for="verification-channel">Verification Method</label> 351 <select id="verification-channel" bind:value={verificationChannel} disabled={submitting}> 352 <option value="email">Email</option> 353 <option value="discord">Discord</option> 354 <option value="telegram">Telegram</option> 355 <option value="signal">Signal</option> 356 </select> 357 </div> 358 {#if verificationChannel === 'email'} 359 <div class="field"> 360 <label for="email">Email Address</label> 361 <input id="email" type="email" bind:value={email} placeholder="you@example.com" disabled={submitting} required /> 362 </div> 363 {:else if verificationChannel === 'discord'} 364 <div class="field"> 365 <label for="discord-id">Discord User ID</label> 366 <input id="discord-id" type="text" bind:value={discordId} placeholder="Your Discord user ID" disabled={submitting} required /> 367 <p class="hint">Your numeric Discord user ID (enable Developer Mode to find it)</p> 368 </div> 369 {:else if verificationChannel === 'telegram'} 370 <div class="field"> 371 <label for="telegram-username">Telegram Username</label> 372 <input id="telegram-username" type="text" bind:value={telegramUsername} placeholder="@yourusername" disabled={submitting} required /> 373 </div> 374 {:else if verificationChannel === 'signal'} 375 <div class="field"> 376 <label for="signal-number">Signal Phone Number</label> 377 <input id="signal-number" type="tel" bind:value={signalNumber} placeholder="+1234567890" disabled={submitting} required /> 378 <p class="hint">Include country code (e.g., +1 for US)</p> 379 </div> 380 {/if} 381 </fieldset> 382 383 <fieldset class="section-fieldset"> 384 <legend>Identity Type</legend> 385 <p class="section-hint">Choose how your decentralized identity will be managed.</p> 386 <div class="radio-group"> 387 <label class="radio-label"> 388 <input type="radio" name="didType" value="plc" bind:group={didType} disabled={submitting} /> 389 <span class="radio-content"> 390 <strong>did:plc</strong> (Recommended) 391 <span class="radio-hint">Portable identity managed by PLC Directory</span> 392 </span> 393 </label> 394 <label class="radio-label"> 395 <input type="radio" name="didType" value="web" bind:group={didType} disabled={submitting} /> 396 <span class="radio-content"> 397 <strong>did:web</strong> 398 <span class="radio-hint">Identity hosted on this PDS (read warning below)</span> 399 </span> 400 </label> 401 <label class="radio-label"> 402 <input type="radio" name="didType" value="web-external" bind:group={didType} disabled={submitting} /> 403 <span class="radio-content"> 404 <strong>did:web (BYOD)</strong> 405 <span class="radio-hint">Bring your own domain</span> 406 </span> 407 </label> 408 </div> 409 {#if didType === 'web'} 410 <div class="warning-box"> 411 <strong>Important: Understand the trade-offs</strong> 412 <ul> 413 <li><strong>Permanent tie to this PDS:</strong> Your identity will be <code>did:web:yourhandle.{serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>.</li> 414 <li><strong>No recovery mechanism:</strong> Unlike did:plc, did:web has no rotation keys.</li> 415 <li><strong>We commit to you:</strong> If you migrate away, we will continue serving a minimal DID document.</li> 416 <li><strong>Recommendation:</strong> Choose did:plc unless you have a specific reason to prefer did:web.</li> 417 </ul> 418 </div> 419 {/if} 420 {#if didType === 'web-external'} 421 <div class="field"> 422 <label for="external-did">Your did:web</label> 423 <input id="external-did" type="text" bind:value={externalDid} placeholder="did:web:yourdomain.com" disabled={submitting} required /> 424 <p class="hint">Your domain must serve a valid DID document at /.well-known/did.json pointing to this PDS</p> 425 </div> 426 {/if} 427 </fieldset> 428 429 {#if serverInfo?.inviteCodeRequired} 430 <div class="field"> 431 <label for="invite-code">Invite Code <span class="required">*</span></label> 432 <input id="invite-code" type="text" bind:value={inviteCode} placeholder="Enter your invite code" disabled={submitting} required /> 433 </div> 434 {/if} 435 436 <div class="info-box"> 437 <strong>Why passkey-only?</strong> 438 <p>Passkey accounts are more secure than password-based accounts because they:</p> 439 <ul> 440 <li>Cannot be phished or stolen in data breaches</li> 441 <li>Use hardware-backed cryptographic keys</li> 442 <li>Require your biometric or device PIN to use</li> 443 </ul> 444 </div> 445 446 <button type="submit" disabled={submitting}> 447 {submitting ? 'Creating account...' : 'Continue'} 448 </button> 449 </form> 450 451 <p class="link-text"> 452 Want a traditional password? <a href="#/register">Register with password</a> 453 </p> 454 {:else if step === 'passkey'} 455 <div class="step-content"> 456 <div class="field"> 457 <label for="passkey-name">Passkey Name (optional)</label> 458 <input id="passkey-name" type="text" bind:value={passkeyName} placeholder="e.g., MacBook Touch ID" disabled={submitting} /> 459 <p class="hint">A friendly name to identify this passkey</p> 460 </div> 461 462 <div class="info-box"> 463 <p>Click the button below to create your passkey. You'll be prompted to use:</p> 464 <ul> 465 <li>Touch ID or Face ID</li> 466 <li>Your device PIN or password</li> 467 <li>A security key (if you have one)</li> 468 </ul> 469 </div> 470 471 <button onclick={handlePasskeyRegistration} disabled={submitting} class="passkey-btn"> 472 {submitting ? 'Creating Passkey...' : 'Create Passkey'} 473 </button> 474 475 <button type="button" class="secondary" onclick={() => step = 'info'} disabled={submitting}> 476 Back 477 </button> 478 </div> 479 {:else if step === 'app-password'} 480 <div class="step-content"> 481 <div class="warning-box"> 482 <strong>Important: Save this app password!</strong> 483 <p>This app password is required to sign into apps that don't support passkeys yet (like bsky.app). You will only see this password once.</p> 484 </div> 485 486 <div class="app-password-display"> 487 <div class="app-password-label">App Password for: <strong>{appPasswordResult?.appPasswordName}</strong></div> 488 <code class="app-password-code">{appPasswordResult?.appPassword}</code> 489 <button type="button" class="copy-btn" onclick={copyAppPassword}> 490 {appPasswordCopied ? 'Copied!' : 'Copy to Clipboard'} 491 </button> 492 </div> 493 494 <div class="field"> 495 <label class="checkbox-label"> 496 <input type="checkbox" bind:checked={appPasswordAcknowledged} /> 497 <span>I have saved my app password in a secure location</span> 498 </label> 499 </div> 500 501 <button onclick={handleFinish} disabled={!appPasswordAcknowledged}>Continue</button> 502 </div> 503 {:else if step === 'verify'} 504 <div class="step-content"> 505 <p class="info-text">We've sent a verification code to your {channelLabel(verificationChannel)}. Enter it below to complete your account setup.</p> 506 507 {#if resendMessage} 508 <div class="message success">{resendMessage}</div> 509 {/if} 510 511 <form onsubmit={(e) => { e.preventDefault(); handleVerification(); }}> 512 <div class="field"> 513 <label for="verification-code">Verification Code</label> 514 <input id="verification-code" type="text" bind:value={verificationCode} placeholder="Enter 6-digit code" disabled={submitting} required maxlength="6" inputmode="numeric" autocomplete="one-time-code" /> 515 </div> 516 517 <button type="submit" disabled={submitting || !verificationCode.trim()}> 518 {submitting ? 'Verifying...' : 'Verify Account'} 519 </button> 520 521 <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}> 522 {resendingCode ? 'Resending...' : 'Resend Code'} 523 </button> 524 </form> 525 </div> 526 {:else if step === 'success'} 527 <div class="success-content"> 528 <div class="success-icon">&#x2714;</div> 529 <h2>Account Created!</h2> 530 <p>Your passkey-only account has been created successfully.</p> 531 <p class="handle-display">@{setupData?.handle}</p> 532 <button onclick={goToLogin}>Sign In</button> 533 </div> 534 {/if} 535</div> 536 537<style> 538 .register-page { 539 max-width: var(--width-sm); 540 margin: var(--space-9) auto; 541 padding: var(--space-7); 542 } 543 544 h1, h2 { 545 margin: 0 0 var(--space-3) 0; 546 } 547 548 .subtitle { 549 color: var(--text-secondary); 550 margin: 0 0 var(--space-7) 0; 551 } 552 553 .loading { 554 text-align: center; 555 color: var(--text-secondary); 556 } 557 558 form, .step-content { 559 display: flex; 560 flex-direction: column; 561 gap: var(--space-4); 562 } 563 564 .required { 565 color: var(--error-text); 566 } 567 568 .section-fieldset { 569 border: 1px solid var(--border-color); 570 border-radius: var(--radius-lg); 571 padding: var(--space-5); 572 } 573 574 .section-fieldset legend { 575 font-weight: var(--font-semibold); 576 padding: 0 var(--space-3); 577 } 578 579 .section-hint { 580 font-size: var(--text-sm); 581 color: var(--text-secondary); 582 margin: 0 0 var(--space-5) 0; 583 } 584 585 .radio-group { 586 display: flex; 587 flex-direction: column; 588 gap: var(--space-4); 589 } 590 591 .radio-label { 592 display: flex; 593 align-items: flex-start; 594 gap: var(--space-3); 595 cursor: pointer; 596 font-size: var(--text-base); 597 font-weight: var(--font-normal); 598 margin-bottom: 0; 599 } 600 601 .radio-label input[type="radio"] { 602 margin-top: var(--space-1); 603 width: auto; 604 } 605 606 .radio-content { 607 display: flex; 608 flex-direction: column; 609 gap: var(--space-1); 610 } 611 612 .radio-hint { 613 font-size: var(--text-xs); 614 color: var(--text-secondary); 615 } 616 617 .warning-box { 618 margin-top: var(--space-5); 619 padding: var(--space-5); 620 background: var(--warning-bg); 621 border: 1px solid var(--warning-border); 622 border-radius: var(--radius-lg); 623 font-size: var(--text-sm); 624 } 625 626 .warning-box strong { 627 display: block; 628 margin-bottom: var(--space-3); 629 color: var(--warning-text); 630 } 631 632 .warning-box p { 633 margin: 0; 634 color: var(--warning-text); 635 } 636 637 .warning-box ul { 638 margin: var(--space-4) 0 0 0; 639 padding-left: var(--space-5); 640 } 641 642 .warning-box li { 643 margin-bottom: var(--space-3); 644 line-height: var(--leading-normal); 645 } 646 647 .warning-box li:last-child { 648 margin-bottom: 0; 649 } 650 651 .info-box { 652 background: var(--bg-secondary); 653 border: 1px solid var(--border-color); 654 border-radius: var(--radius-lg); 655 padding: var(--space-5); 656 font-size: var(--text-sm); 657 } 658 659 .info-box strong { 660 display: block; 661 margin-bottom: var(--space-3); 662 } 663 664 .info-box p { 665 margin: 0 0 var(--space-3) 0; 666 color: var(--text-secondary); 667 } 668 669 .info-box ul { 670 margin: 0; 671 padding-left: var(--space-5); 672 color: var(--text-secondary); 673 } 674 675 .info-box li { 676 margin-bottom: var(--space-2); 677 } 678 679 .passkey-btn { 680 padding: var(--space-5); 681 font-size: var(--text-lg); 682 } 683 684 .app-password-display { 685 background: var(--bg-card); 686 border: 2px solid var(--accent); 687 border-radius: var(--radius-xl); 688 padding: var(--space-6); 689 text-align: center; 690 } 691 692 .app-password-label { 693 font-size: var(--text-sm); 694 color: var(--text-secondary); 695 margin-bottom: var(--space-4); 696 } 697 698 .app-password-code { 699 display: block; 700 font-size: var(--text-xl); 701 font-family: ui-monospace, monospace; 702 letter-spacing: 0.1em; 703 padding: var(--space-5); 704 background: var(--bg-input); 705 border-radius: var(--radius-md); 706 margin-bottom: var(--space-4); 707 user-select: all; 708 } 709 710 .copy-btn { 711 margin-top: 0; 712 padding: var(--space-3) var(--space-5); 713 font-size: var(--text-sm); 714 } 715 716 .checkbox-label { 717 display: flex; 718 align-items: center; 719 gap: var(--space-3); 720 cursor: pointer; 721 font-weight: var(--font-normal); 722 } 723 724 .checkbox-label input[type="checkbox"] { 725 width: auto; 726 padding: 0; 727 } 728 729 .success-content { 730 text-align: center; 731 } 732 733 .success-icon { 734 font-size: var(--text-4xl); 735 color: var(--success-text); 736 margin-bottom: var(--space-4); 737 } 738 739 .success-content p { 740 color: var(--text-secondary); 741 } 742 743 .handle-display { 744 font-size: var(--text-xl); 745 font-weight: var(--font-semibold); 746 color: var(--text-primary); 747 margin: var(--space-4) 0; 748 } 749 750 .info-text { 751 color: var(--text-secondary); 752 margin: 0; 753 } 754 755 .link-text { 756 text-align: center; 757 margin-top: var(--space-6); 758 color: var(--text-secondary); 759 } 760 761 .link-text a { 762 color: var(--accent); 763 } 764</style>