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">✔</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>