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