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