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 {#if step === 'info'} 307 <div class="migrate-callout"> 308 <div class="migrate-icon"></div> 309 <div class="migrate-content"> 310 <strong>{$_('register.migrateTitle')}</strong> 311 <p>{$_('register.migrateDescription')}</p> 312 <a href="https://pdsmoover.com/moover" target="_blank" rel="noopener" class="migrate-link"> 313 {$_('register.migrateLink')}314 </a> 315 </div> 316 </div> 317 {/if} 318 319 <h1>Create Passkey Account</h1> 320 <p class="subtitle"> 321 {#if step === 'info'} 322 Create an ultra-secure account using a passkey instead of a password. 323 {:else if step === 'passkey'} 324 Register your passkey to secure your account. 325 {:else if step === 'app-password'} 326 Save your app password for third-party apps. 327 {:else if step === 'verify'} 328 Verify your {channelLabel(verificationChannel)} to complete registration. 329 {:else} 330 Your account has been created successfully! 331 {/if} 332 </p> 333 334 {#if error} 335 <div class="message error">{error}</div> 336 {/if} 337 338 {#if loadingServerInfo} 339 <p class="loading">Loading...</p> 340 {:else if step === 'info'} 341 <form onsubmit={handleInfoSubmit}> 342 <div class="field"> 343 <label for="handle">Handle</label> 344 <input 345 id="handle" 346 type="text" 347 bind:value={handle} 348 placeholder="yourname" 349 disabled={submitting} 350 required 351 /> 352 {#if handle.includes('.')} 353 <p class="hint warning">Custom domain handles can be set up after account creation.</p> 354 {:else if fullHandle()} 355 <p class="hint">Your full handle will be: @{fullHandle()}</p> 356 {/if} 357 </div> 358 359 <fieldset class="section-fieldset"> 360 <legend>Contact Method</legend> 361 <p class="section-hint">Choose how you'd like to verify your account and receive notifications.</p> 362 <div class="field"> 363 <label for="verification-channel">Verification Method</label> 364 <select id="verification-channel" bind:value={verificationChannel} disabled={submitting}> 365 <option value="email">Email</option> 366 <option value="discord">Discord</option> 367 <option value="telegram">Telegram</option> 368 <option value="signal">Signal</option> 369 </select> 370 </div> 371 {#if verificationChannel === 'email'} 372 <div class="field"> 373 <label for="email">Email Address</label> 374 <input id="email" type="email" bind:value={email} placeholder="you@example.com" disabled={submitting} required /> 375 </div> 376 {:else if verificationChannel === 'discord'} 377 <div class="field"> 378 <label for="discord-id">Discord User ID</label> 379 <input id="discord-id" type="text" bind:value={discordId} placeholder="Your Discord user ID" disabled={submitting} required /> 380 <p class="hint">Your numeric Discord user ID (enable Developer Mode to find it)</p> 381 </div> 382 {:else if verificationChannel === 'telegram'} 383 <div class="field"> 384 <label for="telegram-username">Telegram Username</label> 385 <input id="telegram-username" type="text" bind:value={telegramUsername} placeholder="@yourusername" disabled={submitting} required /> 386 </div> 387 {:else if verificationChannel === 'signal'} 388 <div class="field"> 389 <label for="signal-number">Signal Phone Number</label> 390 <input id="signal-number" type="tel" bind:value={signalNumber} placeholder="+1234567890" disabled={submitting} required /> 391 <p class="hint">Include country code (e.g., +1 for US)</p> 392 </div> 393 {/if} 394 </fieldset> 395 396 <fieldset class="section-fieldset"> 397 <legend>Identity Type</legend> 398 <p class="section-hint">Choose how your decentralized identity will be managed.</p> 399 <div class="radio-group"> 400 <label class="radio-label"> 401 <input type="radio" name="didType" value="plc" bind:group={didType} disabled={submitting} /> 402 <span class="radio-content"> 403 <strong>did:plc</strong> (Recommended) 404 <span class="radio-hint">Portable identity managed by PLC Directory</span> 405 </span> 406 </label> 407 <label class="radio-label"> 408 <input type="radio" name="didType" value="web" bind:group={didType} disabled={submitting} /> 409 <span class="radio-content"> 410 <strong>did:web</strong> 411 <span class="radio-hint">Identity hosted on this PDS (read warning below)</span> 412 </span> 413 </label> 414 <label class="radio-label"> 415 <input type="radio" name="didType" value="web-external" bind:group={didType} disabled={submitting} /> 416 <span class="radio-content"> 417 <strong>did:web (BYOD)</strong> 418 <span class="radio-hint">Bring your own domain</span> 419 </span> 420 </label> 421 </div> 422 {#if didType === 'web'} 423 <div class="warning-box"> 424 <strong>Important: Understand the trade-offs</strong> 425 <ul> 426 <li><strong>Permanent tie to this PDS:</strong> Your identity will be <code>did:web:yourhandle.{serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>.</li> 427 <li><strong>No recovery mechanism:</strong> Unlike did:plc, did:web has no rotation keys.</li> 428 <li><strong>We commit to you:</strong> If you migrate away, we will continue serving a minimal DID document.</li> 429 <li><strong>Recommendation:</strong> Choose did:plc unless you have a specific reason to prefer did:web.</li> 430 </ul> 431 </div> 432 {/if} 433 {#if didType === 'web-external'} 434 <div class="field"> 435 <label for="external-did">Your did:web</label> 436 <input id="external-did" type="text" bind:value={externalDid} placeholder="did:web:yourdomain.com" disabled={submitting} required /> 437 <p class="hint">Your domain must serve a valid DID document at /.well-known/did.json pointing to this PDS</p> 438 </div> 439 {/if} 440 </fieldset> 441 442 {#if serverInfo?.inviteCodeRequired} 443 <div class="field"> 444 <label for="invite-code">Invite Code <span class="required">*</span></label> 445 <input id="invite-code" type="text" bind:value={inviteCode} placeholder="Enter your invite code" disabled={submitting} required /> 446 </div> 447 {/if} 448 449 <div class="info-box"> 450 <strong>Why passkey-only?</strong> 451 <p>Passkey accounts are more secure than password-based accounts because they:</p> 452 <ul> 453 <li>Cannot be phished or stolen in data breaches</li> 454 <li>Use hardware-backed cryptographic keys</li> 455 <li>Require your biometric or device PIN to use</li> 456 </ul> 457 </div> 458 459 <button type="submit" disabled={submitting}> 460 {submitting ? 'Creating account...' : 'Continue'} 461 </button> 462 </form> 463 464 <p class="link-text"> 465 Want a traditional password? <a href="#/register">Register with password</a> 466 </p> 467 {:else if step === 'passkey'} 468 <div class="step-content"> 469 <div class="field"> 470 <label for="passkey-name">Passkey Name (optional)</label> 471 <input id="passkey-name" type="text" bind:value={passkeyName} placeholder="e.g., MacBook Touch ID" disabled={submitting} /> 472 <p class="hint">A friendly name to identify this passkey</p> 473 </div> 474 475 <div class="info-box"> 476 <p>Click the button below to create your passkey. You'll be prompted to use:</p> 477 <ul> 478 <li>Touch ID or Face ID</li> 479 <li>Your device PIN or password</li> 480 <li>A security key (if you have one)</li> 481 </ul> 482 </div> 483 484 <button onclick={handlePasskeyRegistration} disabled={submitting} class="passkey-btn"> 485 {submitting ? 'Creating Passkey...' : 'Create Passkey'} 486 </button> 487 488 <button type="button" class="secondary" onclick={() => step = 'info'} disabled={submitting}> 489 Back 490 </button> 491 </div> 492 {:else if step === 'app-password'} 493 <div class="step-content"> 494 <div class="warning-box"> 495 <strong>Important: Save this app password!</strong> 496 <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> 497 </div> 498 499 <div class="app-password-display"> 500 <div class="app-password-label">App Password for: <strong>{appPasswordResult?.appPasswordName}</strong></div> 501 <code class="app-password-code">{appPasswordResult?.appPassword}</code> 502 <button type="button" class="copy-btn" onclick={copyAppPassword}> 503 {appPasswordCopied ? 'Copied!' : 'Copy to Clipboard'} 504 </button> 505 </div> 506 507 <div class="field"> 508 <label class="checkbox-label"> 509 <input type="checkbox" bind:checked={appPasswordAcknowledged} /> 510 <span>I have saved my app password in a secure location</span> 511 </label> 512 </div> 513 514 <button onclick={handleFinish} disabled={!appPasswordAcknowledged}>Continue</button> 515 </div> 516 {:else if step === 'verify'} 517 <div class="step-content"> 518 <p class="info-text">We've sent a verification code to your {channelLabel(verificationChannel)}. Enter it below to complete your account setup.</p> 519 520 {#if resendMessage} 521 <div class="message success">{resendMessage}</div> 522 {/if} 523 524 <form onsubmit={(e) => { e.preventDefault(); handleVerification(); }}> 525 <div class="field"> 526 <label for="verification-code">Verification Code</label> 527 <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" /> 528 </div> 529 530 <button type="submit" disabled={submitting || !verificationCode.trim()}> 531 {submitting ? 'Verifying...' : 'Verify Account'} 532 </button> 533 534 <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}> 535 {resendingCode ? 'Resending...' : 'Resend Code'} 536 </button> 537 </form> 538 </div> 539 {:else if step === 'success'} 540 <div class="success-content"> 541 <div class="success-icon">&#x2714;</div> 542 <h2>Account Created!</h2> 543 <p>Your passkey-only account has been created successfully.</p> 544 <p class="handle-display">@{setupData?.handle}</p> 545 <button onclick={goToLogin}>Sign In</button> 546 </div> 547 {/if} 548</div> 549 550<style> 551 .register-page { 552 max-width: var(--width-sm); 553 margin: var(--space-9) auto; 554 padding: var(--space-7); 555 } 556 557 .migrate-callout { 558 display: flex; 559 gap: var(--space-4); 560 padding: var(--space-5); 561 background: var(--accent-muted); 562 border: 1px solid var(--accent); 563 border-radius: var(--radius-xl); 564 margin-bottom: var(--space-6); 565 } 566 567 .migrate-icon { 568 font-size: var(--text-2xl); 569 line-height: 1; 570 color: var(--accent); 571 } 572 573 .migrate-content { 574 flex: 1; 575 } 576 577 .migrate-content strong { 578 display: block; 579 color: var(--text-primary); 580 margin-bottom: var(--space-2); 581 } 582 583 .migrate-content p { 584 margin: 0 0 var(--space-3) 0; 585 font-size: var(--text-sm); 586 color: var(--text-secondary); 587 line-height: var(--leading-relaxed); 588 } 589 590 .migrate-link { 591 font-size: var(--text-sm); 592 font-weight: var(--font-medium); 593 color: var(--accent); 594 text-decoration: none; 595 } 596 597 .migrate-link:hover { 598 text-decoration: underline; 599 } 600 601 h1, h2 { 602 margin: 0 0 var(--space-3) 0; 603 } 604 605 .subtitle { 606 color: var(--text-secondary); 607 margin: 0 0 var(--space-7) 0; 608 } 609 610 .loading { 611 text-align: center; 612 color: var(--text-secondary); 613 } 614 615 form, .step-content { 616 display: flex; 617 flex-direction: column; 618 gap: var(--space-4); 619 } 620 621 .required { 622 color: var(--error-text); 623 } 624 625 .section-fieldset { 626 border: 1px solid var(--border-color); 627 border-radius: var(--radius-lg); 628 padding: var(--space-5); 629 } 630 631 .section-fieldset legend { 632 font-weight: var(--font-semibold); 633 padding: 0 var(--space-3); 634 } 635 636 .section-hint { 637 font-size: var(--text-sm); 638 color: var(--text-secondary); 639 margin: 0 0 var(--space-5) 0; 640 } 641 642 .radio-group { 643 display: flex; 644 flex-direction: column; 645 gap: var(--space-4); 646 } 647 648 .radio-label { 649 display: flex; 650 align-items: flex-start; 651 gap: var(--space-3); 652 cursor: pointer; 653 font-size: var(--text-base); 654 font-weight: var(--font-normal); 655 margin-bottom: 0; 656 } 657 658 .radio-label input[type="radio"] { 659 margin-top: var(--space-1); 660 width: auto; 661 } 662 663 .radio-content { 664 display: flex; 665 flex-direction: column; 666 gap: var(--space-1); 667 } 668 669 .radio-hint { 670 font-size: var(--text-xs); 671 color: var(--text-secondary); 672 } 673 674 .warning-box { 675 margin-top: var(--space-5); 676 padding: var(--space-5); 677 background: var(--warning-bg); 678 border: 1px solid var(--warning-border); 679 border-radius: var(--radius-lg); 680 font-size: var(--text-sm); 681 } 682 683 .warning-box strong { 684 display: block; 685 margin-bottom: var(--space-3); 686 color: var(--warning-text); 687 } 688 689 .warning-box p { 690 margin: 0; 691 color: var(--warning-text); 692 } 693 694 .warning-box ul { 695 margin: var(--space-4) 0 0 0; 696 padding-left: var(--space-5); 697 } 698 699 .warning-box li { 700 margin-bottom: var(--space-3); 701 line-height: var(--leading-normal); 702 } 703 704 .warning-box li:last-child { 705 margin-bottom: 0; 706 } 707 708 .info-box { 709 background: var(--bg-secondary); 710 border: 1px solid var(--border-color); 711 border-radius: var(--radius-lg); 712 padding: var(--space-5); 713 font-size: var(--text-sm); 714 } 715 716 .info-box strong { 717 display: block; 718 margin-bottom: var(--space-3); 719 } 720 721 .info-box p { 722 margin: 0 0 var(--space-3) 0; 723 color: var(--text-secondary); 724 } 725 726 .info-box ul { 727 margin: 0; 728 padding-left: var(--space-5); 729 color: var(--text-secondary); 730 } 731 732 .info-box li { 733 margin-bottom: var(--space-2); 734 } 735 736 .passkey-btn { 737 padding: var(--space-5); 738 font-size: var(--text-lg); 739 } 740 741 .app-password-display { 742 background: var(--bg-card); 743 border: 2px solid var(--accent); 744 border-radius: var(--radius-xl); 745 padding: var(--space-6); 746 text-align: center; 747 } 748 749 .app-password-label { 750 font-size: var(--text-sm); 751 color: var(--text-secondary); 752 margin-bottom: var(--space-4); 753 } 754 755 .app-password-code { 756 display: block; 757 font-size: var(--text-xl); 758 font-family: ui-monospace, monospace; 759 letter-spacing: 0.1em; 760 padding: var(--space-5); 761 background: var(--bg-input); 762 border-radius: var(--radius-md); 763 margin-bottom: var(--space-4); 764 user-select: all; 765 } 766 767 .copy-btn { 768 margin-top: 0; 769 padding: var(--space-3) var(--space-5); 770 font-size: var(--text-sm); 771 } 772 773 .checkbox-label { 774 display: flex; 775 align-items: center; 776 gap: var(--space-3); 777 cursor: pointer; 778 font-weight: var(--font-normal); 779 } 780 781 .checkbox-label input[type="checkbox"] { 782 width: auto; 783 padding: 0; 784 } 785 786 .success-content { 787 text-align: center; 788 } 789 790 .success-icon { 791 font-size: var(--text-4xl); 792 color: var(--success-text); 793 margin-bottom: var(--space-4); 794 } 795 796 .success-content p { 797 color: var(--text-secondary); 798 } 799 800 .handle-display { 801 font-size: var(--text-xl); 802 font-weight: var(--font-semibold); 803 color: var(--text-primary); 804 margin: var(--space-4) 0; 805 } 806 807 .info-text { 808 color: var(--text-secondary); 809 margin: 0; 810 } 811 812 .link-text { 813 text-align: center; 814 margin-top: var(--space-6); 815 color: var(--text-secondary); 816 } 817 818 .link-text a { 819 color: var(--accent); 820 } 821</style>