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