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