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