diff --git a/src/background/account/Account.ts b/src/background/account/Account.ts index 7d1aeddaf8..5dc9b220c3 100644 --- a/src/background/account/Account.ts +++ b/src/background/account/Account.ts @@ -1,7 +1,12 @@ import type { EventsMap } from 'nanoevents'; import { createNanoEvents } from 'nanoevents'; import { nanoid } from 'nanoid'; -import { createSalt, createCryptoKey } from 'src/modules/crypto'; +import { + createSalt, + createCryptoKey, + encrypt, + decrypt, +} from 'src/modules/crypto'; import { getSHA256HexDigest } from 'src/modules/crypto/getSHA256HexDigest'; import { BrowserStorage, @@ -10,9 +15,11 @@ import { } from 'src/background/webapis/storage'; import { validate } from 'src/shared/validation/user-input'; import { eraseAndUpdateToLatestVersion } from 'src/shared/core/version/shared'; -import { currentUserKey } from 'src/shared/getCurrentUser'; -import type { PublicUser, User } from 'src/shared/types/User'; +import { currentUserKey, getCurrentUser } from 'src/shared/getCurrentUser'; +import type { Passkey, PublicUser, User } from 'src/shared/types/User'; import { payloadId } from '@walletconnect/jsonrpc-utils'; +import { sha256 } from 'src/modules/crypto/sha256'; +import { produce } from 'immer'; import { Wallet } from '../Wallet/Wallet'; import { peakSavedWalletState } from '../Wallet/persistence'; import type { NotificationWindow } from '../NotificationWindow/NotificationWindow'; @@ -21,10 +28,6 @@ import { credentialsKey } from './storage-keys'; const TEMPORARY_ID = 'temporary'; -async function sha256({ password, salt }: { password: string; salt: string }) { - return await getSHA256HexDigest(`${salt}:${password}`); -} - class EventEmitter { private emitter = createNanoEvents(); @@ -77,6 +80,34 @@ export class Account extends EventEmitter { await BrowserStorage.remove(currentUserKey); } + async getEncryptedPassword() { + return (await Account.readCurrentUser())?.passkey ?? null; + } + + async setEncryptedPassword(passkey: Passkey) { + const user = await getCurrentUser(); + if (!user) { + throw new Error('No user found'); + } + await Account.writeCurrentUser( + produce(user, (draft) => { + draft.passkey = passkey; + }) + ); + } + + async removeEncryptedPassword() { + const user = await Account.readCurrentUser(); + if (!user) { + throw new Error('No user found'); + } + await Account.writeCurrentUser( + produce(user, (draft) => { + draft.passkey = null; + }) + ); + } + private static async writeCredentials(credentials: Credentials) { const preferences = await globalPreferences.getPreferences(); if (preferences.autoLockTimeout === 'none') { @@ -395,4 +426,52 @@ export class AccountPublicRPC { await eraseAndUpdateToLatestVersion(); await this.account.logout(); // reset account after erasing storage } + + async setPasskey({ + params: { encryptionKey, password, salt, id }, + }: PublicMethodParams<{ + encryptionKey: string; + password: string; + salt: string; + id: string; + }>) { + const encrypted = await encrypt(encryptionKey, { password }); + return this.account.setEncryptedPassword({ + encryptedPassword: encrypted, + salt, + id, + }); + } + + async getPasskeyMeta() { + const data = await this.account.getEncryptedPassword(); + if (!data) { + throw new Error('No passkey found'); + } + const { id, salt } = data; + return { id, salt }; + } + + async getPassword({ + params: { encryptionKey }, + }: PublicMethodParams<{ encryptionKey: string }>) { + const data = await this.account.getEncryptedPassword(); + if (!data) { + throw new Error('No passkey found'); + } + const decrypted = await decrypt<{ password: string }>( + encryptionKey, + data.encryptedPassword + ); + return decrypted.password; + } + + async getPasskeyEnabled(): Promise { + const data = await this.account.getEncryptedPassword(); + return Boolean(data); + } + + async removePasskey() { + return this.account.removeEncryptedPassword(); + } } diff --git a/src/modules/crypto/hkdf.ts b/src/modules/crypto/hkdf.ts new file mode 100644 index 0000000000..311d6fa816 --- /dev/null +++ b/src/modules/crypto/hkdf.ts @@ -0,0 +1,86 @@ +import { utf8ToUint8Array } from './convert'; + +/** + * HKDF (HMAC-based Key Derivation Function) implementation using Web Crypto API + * RFC 5869: https://tools.ietf.org/html/rfc5869 + * + * This provides a cryptographically secure way to derive keys from high-entropy input + * (like PRF output) without the computational cost of PBKDF2. + */ + +/** + * Derives a key using HKDF-SHA256 + * + * @param ikm - Input Key Material (the high-entropy source, e.g., PRF output) + * @param salt - Salt value (should be random and unique per credential) + * @param info - Optional context and application specific information + * @param length - Desired output length in bytes (default: 32 for SHA-256) + * @returns Derived key as hex string + */ +async function hkdf({ + ikm, + salt, + info, + length = 32, +}: { + ikm: string | ArrayBuffer; + salt: string; + info: string; + length?: number; +}): Promise { + // Convert inputs to Uint8Array + const ikmArray = + typeof ikm === 'string' ? utf8ToUint8Array(ikm) : new Uint8Array(ikm); + const saltArray = utf8ToUint8Array(salt); + const infoArray = utf8ToUint8Array(info); + + // Import IKM as a CryptoKey for HKDF + const ikmKey = await crypto.subtle.importKey( + 'raw', + ikmArray, + { name: 'HKDF' }, + false, + ['deriveBits'] + ); + + // Derive bits using HKDF + const derivedBits = await crypto.subtle.deriveBits( + { + name: 'HKDF', + hash: 'SHA-256', + salt: saltArray, + info: infoArray, + }, + ikmKey, + length * 8 // Convert bytes to bits + ); + + // Convert to hex string for consistency with existing sha256 function + const hashArray = Array.from(new Uint8Array(derivedBits)); + const hashHex = hashArray + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + + return hashHex; +} + +/** + * Derives an encryption key from PRF output using HKDF + * This adds defense-in-depth by ensuring even if the salt is compromised, + * the attacker still needs the PRF output from the authenticator. + * + * @param prfOutput - The PRF output from WebAuthn authenticator + * @param salt - Random salt (stored with the passkey) + * @returns Encryption key as hex string + */ +export async function deriveEncryptionKeyFromPRF( + prfOutput: ArrayBuffer, + salt: string +): Promise { + return hkdf({ + ikm: prfOutput, + salt, + length: 32, + info: 'zerion-passkey-v1', + }); +} diff --git a/src/modules/crypto/index.ts b/src/modules/crypto/index.ts index 29ed2fee6b..7dedb4e897 100644 --- a/src/modules/crypto/index.ts +++ b/src/modules/crypto/index.ts @@ -11,3 +11,4 @@ export { export { createSalt, createCryptoKey } from './key'; export { encrypt, decrypt } from './aes'; export { stableEncrypt, stableDecrypt } from './aesStable'; +export { deriveEncryptionKeyFromPRF } from './hkdf'; diff --git a/src/modules/crypto/sha256.ts b/src/modules/crypto/sha256.ts new file mode 100644 index 0000000000..536fa5705b --- /dev/null +++ b/src/modules/crypto/sha256.ts @@ -0,0 +1,11 @@ +import { getSHA256HexDigest } from './getSHA256HexDigest'; + +export async function sha256({ + password, + salt, +}: { + password: string; + salt: string; +}) { + return await getSHA256HexDigest(`${salt}:${password}`); +} diff --git a/src/shared/types/User.ts b/src/shared/types/User.ts index cd2c266ad8..67d531a890 100644 --- a/src/shared/types/User.ts +++ b/src/shared/types/User.ts @@ -1,6 +1,12 @@ +export type Passkey = { + encryptedPassword: string; + salt: string; + id: string; +}; export interface User { id: string; salt: string; + passkey?: Passkey | null; } export interface PublicUser { diff --git a/src/ui/assets/touch-id.svg b/src/ui/assets/touch-id.svg new file mode 100644 index 0000000000..015f6236d1 --- /dev/null +++ b/src/ui/assets/touch-id.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/ui/pages/Login/Login.tsx b/src/ui/pages/Login/Login.tsx index 2aec90ac03..2eb0a745cb 100644 --- a/src/ui/pages/Login/Login.tsx +++ b/src/ui/pages/Login/Login.tsx @@ -28,6 +28,7 @@ import { ZStack } from 'src/ui/ui-kit/ZStack'; import { walletPort } from 'src/ui/shared/channels'; import { invariant } from 'src/shared/invariant'; import { DelayedRender } from 'src/ui/components/DelayedRender'; +import { TouchIdLogin } from '../Security/TouchIdLogin'; import { LayersAnimationLottie } from './LayersAnimationLottie'; import { type LottieComponentHandle } from './LayersAnimationLottie'; @@ -119,6 +120,14 @@ export function Login() { return walletPort.request('getLastUsedAddress', { userId }); }, }); + const handleSuccess = useCallback(() => { + navigate(params.get('next') || '/', { + // If user clicks "back" when we redirect them, + // we should take them to overview, not back to the login view + replace: true, + }); + }, [navigate, params]); + const loginMutation = useMutation({ mutationFn: async ({ user, @@ -134,11 +143,7 @@ export function Login() { // There's a rare weird bug when logging in reloads login page instead of redirecting to overview. // Maybe this will fix it? If not, then remove this delay await new Promise((r) => setTimeout(r, 100)); - navigate(params.get('next') || '/', { - // If user clicks "back" when we redirect them, - // we should take them to overview, not back to the login view - replace: true, - }); + handleSuccess(); }, }); @@ -178,29 +183,39 @@ export function Login() { } loginMutation.mutate({ user, password }); }} + style={{ flexGrow: 1, display: 'flex' }} > - - - Welcome Back! - - - - {loginMutation.error ? ( - - {(loginMutation.error as Error).message || 'unknown error'} - + + + + Welcome Back! + + + + {loginMutation.error ? ( + + {(loginMutation.error as Error).message || 'unknown error'} + + ) : null} + + {user ? ( + ) : null} - + (null); + const [userValue, setUserValue] = useState(null); + const passkeyTitle = getPasskeyTitle(); + + // Check if passkeys are supported on this device + const passkeyAvailabilityQuery = useQuery({ + queryKey: ['passkey/isSupported'], + queryFn: async () => { + if (!window.PublicKeyCredential) { + return false; + } + try { + const available = + await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); + return available; + } catch { + return false; + } + }, + staleTime: Infinity, + cacheTime: Infinity, + }); + + const defaultValueQuery = useQuery({ + queryKey: ['account/getPasskeyEnabled'], + queryFn: () => { + return accountPublicRPCPort.request('getPasskeyEnabled'); + }, + useErrorBoundary: true, + suspense: false, + }); + const dialogRef = useRef(null); + + const userQuery = useQuery({ + queryKey: ['account/getExistingUser'], + queryFn: () => { + return accountPublicRPCPort.request('getExistingUser'); + }, + useErrorBoundary: true, + }); + + const handleSetupClick = useCallback(() => { + invariant(dialogRef.current, 'Dialog element must be mounted'); + setUserValue(true); + showConfirmDialog(dialogRef.current) + .then(() => setUserValue(true)) + .catch(() => setUserValue(false)); + }, []); + + const setupTouchIdMutation = useMutation({ + mutationFn: async (password: string) => { + invariant(userQuery.data, 'User must be defined'); + await accountPublicRPCPort.request('login', { + user: userQuery.data, + password, + }); + return setupAccountPasskey(password); + }, + onSuccess: () => { + zeroizeAfterSubmission(); + toastRef.current?.showToast(); + if (!dialogRef.current) { + return; + } + dialogRef.current.returnValue = 'confirm'; + dialogRef.current.close(); + }, + }); + + const removeTouchIdMutation = useMutation({ + mutationFn: async () => { + await accountPublicRPCPort.request('removePasskey'); + setUserValue(false); + }, + }); + + const checked = userValue ?? defaultValueQuery.data ?? false; + const disabled = + userQuery.isLoading || + setupTouchIdMutation.isLoading || + removeTouchIdMutation.isLoading || + defaultValueQuery.isLoading; + + // Hide the setting if passkeys are not supported + if (!passkeyAvailabilityQuery.data) { + return null; + } + + return ( + <> + { + if (event.target.checked) { + handleSetupClick(); + } else { + removeTouchIdMutation.mutate(); + } + }} + detailText={`Use biometrics (${passkeyTitle}) to securely sign in without typing in your password`} + /> + { + return ( + <> + + + + Enter Password + + Verification is required to enable login via {passkeyTitle} + + +
{ + event.preventDefault(); + const password = new FormData(event.currentTarget).get( + 'password' + ) as string | undefined; + if (!password) { + return; + } + setupTouchIdMutation.mutate(password); + }} + > + + + + {setupTouchIdMutation.error ? ( + + {(setupTouchIdMutation.error as Error).message || + 'unknown error'} + + ) : null} + + + +
+
+ + ); + }} + /> + + {passkeyTitle} is enabled. + + + ); +} function SecurityMain() { const { globalPreferences } = useGlobalPreferences(); @@ -23,6 +221,7 @@ function SecurityMain() { + void; + style?: React.CSSProperties; +}) { + const defaultValueQuery = useQuery({ + queryKey: ['account/getPasskeyEnabled'], + queryFn: () => { + return accountPublicRPCPort.request('getPasskeyEnabled'); + }, + useErrorBoundary: true, + }); + + const loginMutation = useMutation({ + mutationFn: async () => { + const password = await getPasswordWithPasskey(); + return accountPublicRPCPort.request('login', { user, password }); + }, + onSuccess, + }); + + const passkeyEnabled = defaultValueQuery.data; + const navigationType = useNavigationType(); + const autologinRef = useRef(false); + const passkeyTitle = getPasskeyTitle(); + + useEffect(() => { + // Automatically trigger passkey login if the user navigated here via a replace action + // This happens when user is redirected to the login page when opening the extension popup + const showSuggestPasskey = navigationType === 'REPLACE'; + if (showSuggestPasskey && passkeyEnabled && !autologinRef.current) { + autologinRef.current = true; + loginMutation.mutate(); + } + }, [navigationType, passkeyEnabled, loginMutation]); + + if (!passkeyEnabled) { + return null; + } + + return ( + + loginMutation.mutate()} + className={styles.touchId} + disabled={loginMutation.isLoading} + title={`Unlock with ${passkeyTitle}`} + > + {loginMutation.isLoading ? ( + + ) : ( + + )} + + {loginMutation.isLoading ? null : ( + + Unlock with {passkeyTitle} + + )} + {loginMutation.error ? ( + + {(loginMutation.error as Error).message || 'unknown error'} + + ) : null} + + ); +} diff --git a/src/ui/pages/Security/passkey.ts b/src/ui/pages/Security/passkey.ts new file mode 100644 index 0000000000..2cae9ab686 --- /dev/null +++ b/src/ui/pages/Security/passkey.ts @@ -0,0 +1,250 @@ +import { + arrayBufferToBase64, + base64ToArrayBuffer, + createSalt, + deriveEncryptionKeyFromPRF, + getRandomUint8Array, + utf8ToUint8Array, +} from 'src/modules/crypto'; +import { accountPublicRPCPort } from 'src/ui/shared/channels'; + +interface PRFExtensionResult { + prf?: { + enabled?: boolean; + results?: { + first?: ArrayBuffer; + }; + }; +} + +/** + * Detects if the current platform is macOS + */ +export function isMacOS(): boolean { + if (typeof window === 'undefined') { + return false; + } + // Use userAgent as navigator.platform is deprecated + const userAgent = window.navigator.userAgent; + return /Mac|iPhone|iPad|iPod/.test(userAgent); +} + +/** + * Gets the platform-specific title for passkey unlock + */ +export function getPasskeyTitle(): string { + return isMacOS() ? 'Touch ID' : 'Passkey Unlock'; +} + +/** + * Checks if the current browser and authenticator support the PRF extension. + * This is critical for passkey-based password encryption. + */ +async function checkPRFSupport(): Promise { + try { + // Check if WebAuthn is available + if (!window.PublicKeyCredential) { + return false; + } + + // Check if platform authenticator is available + const available = + await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); + if (!available) { + return false; + } + + // PRF extension support cannot be directly detected, but we can verify + // the browser supports the necessary WebAuthn features + return true; + } catch (error) { + // PRF support check failed, return false without logging + // Error will be shown to user if they attempt to set up passkey + return false; + } +} + +/** + * Type guard to validate PRF extension results + */ +function isPRFResultValid( + result: unknown +): result is { prf: { results: { first: ArrayBuffer } } } { + if (!result || typeof result !== 'object') { + return false; + } + const prfResult = result as PRFExtensionResult; + return !!( + prfResult.prf?.results?.first instanceof ArrayBuffer && + prfResult.prf.results.first.byteLength > 0 + ); +} + +/** + * Safely extracts PRF result from credential extension results + */ +function extractPRFResult(cred: unknown): ArrayBuffer { + if (!cred || typeof cred !== 'object') { + throw new Error( + 'Invalid credential object. Passkey authentication failed.' + ); + } + + const credential = cred as PublicKeyCredential; + if (typeof credential.getClientExtensionResults !== 'function') { + throw new Error( + 'Browser does not support WebAuthn extensions. Please update your browser.' + ); + } + + const result = credential.getClientExtensionResults(); + + if (!isPRFResultValid(result)) { + throw new Error( + 'PRF extension is not supported by your authenticator. ' + + 'This feature requires a compatible device with biometric authentication (Touch ID, Face ID, Windows Hello, etc.). ' + + 'Please try a different device or use password login instead.' + ); + } + + // Type guard ensures this is safe + return result.prf.results.first; +} + +export async function setupAccountPasskey(password: string) { + // Check PRF support before attempting setup + const prfSupported = await checkPRFSupport(); + if (!prfSupported) { + throw new Error( + 'Your device does not support passkey-based password encryption. ' + + 'Please ensure you are using a compatible browser and have platform authentication (Touch ID, Face ID, or Windows Hello) enabled.' + ); + } + + const salt = createSalt(); + let cred: Credential | null; + + try { + cred = await navigator.credentials.create({ + publicKey: { + rp: { name: 'Zerion' }, + user: { + id: getRandomUint8Array(32), + name: 'zerion', + displayName: 'Zerion Wallet', + }, + pubKeyCredParams: [{ alg: -7, type: 'public-key' }], + challenge: getRandomUint8Array(32), + // Use platform authenticator (Apple Keychain on macOS, Windows Hello on Windows, etc.) + authenticatorSelection: { + authenticatorAttachment: 'platform', + userVerification: 'required', + }, + extensions: { + prf: { + eval: { + first: utf8ToUint8Array(salt), + }, + }, + }, + }, + }); + } catch (error) { + // Handle user cancellation or other creation errors + if (error instanceof Error) { + if ( + error.name === 'NotAllowedError' || + error.message.includes('cancelled') + ) { + throw new Error('Passkey setup was cancelled by user'); + } + throw new Error(`Failed to create passkey: ${error.message}`); + } + throw new Error('Failed to create passkey due to an unknown error'); + } + + if (!cred) { + throw new Error('Failed to create passkey: No credential returned'); + } + + const rawId = (cred as PublicKeyCredential | undefined)?.rawId; + const passkeyId = rawId ? arrayBufferToBase64(rawId) : null; + if (!passkeyId) { + throw new Error('Failed to get passkey ID from credential'); + } + + // Use the safe PRF extraction with proper type guards + const prf = extractPRFResult(cred); + + // Derive encryption key using HKDF for defense-in-depth + // This ensures that even if the salt is compromised, the attacker still needs + // the PRF output from the authenticator to derive the encryption key + const encryptionKey = await deriveEncryptionKeyFromPRF(prf, salt); + + return accountPublicRPCPort.request('setPasskey', { + password, + encryptionKey, + id: passkeyId, + salt, + }); +} + +export async function getPasswordWithPasskey() { + const data = await accountPublicRPCPort.request('getPasskeyMeta'); + if (!data) { + throw new Error('No passkey found'); + } + const { id: passkeyId, salt } = data; + + let cred: Credential | null; + + try { + cred = await navigator.credentials.get({ + publicKey: { + challenge: getRandomUint8Array(32), + allowCredentials: [ + { + id: base64ToArrayBuffer(passkeyId), + type: 'public-key', + }, + ], + extensions: { + prf: { + eval: { + first: utf8ToUint8Array(salt), + }, + }, + }, + }, + }); + } catch (error) { + // Handle user cancellation or authentication errors + if (error instanceof Error) { + if ( + error.name === 'NotAllowedError' || + error.message.includes('cancelled') + ) { + throw new Error('Authentication was cancelled by user'); + } + if (error.name === 'InvalidStateError') { + throw new Error( + 'Passkey not found on this device. Please use password login or set up passkey again.' + ); + } + throw new Error(`Authentication failed: ${error.message}`); + } + throw new Error('Authentication failed due to an unknown error'); + } + + if (!cred) { + throw new Error('Authentication failed: No credential returned'); + } + + // Use the safe PRF extraction with proper type guards + const prf = extractPRFResult(cred); + + // Derive encryption key using HKDF (same method as setup) + const encryptionKey = await deriveEncryptionKeyFromPRF(prf, salt); + + return accountPublicRPCPort.request('getPassword', { encryptionKey }); +} diff --git a/src/ui/pages/Security/styles.module.css b/src/ui/pages/Security/styles.module.css new file mode 100644 index 0000000000..a678b368d2 --- /dev/null +++ b/src/ui/pages/Security/styles.module.css @@ -0,0 +1,35 @@ +.touchId { + width: 56px; + height: 56px; + padding: 7px; + border-radius: 50%; + border: 1px solid var(--neutral-300); + color: var(--neutral-500); +} + +.touchIdPopup { + display: none; + position: absolute; + top: 64px; + left: 50%; + transform: translateX(-50%); + padding: 4px 8px; + background-color: var(--black); + border-radius: 4px; + white-space: nowrap; +} + +@media (hover: hover) { + .touchId:hover { + color: var(--primary); + } + + .touchId:hover + .touchIdPopup { + display: block; + } +} + +.touchId:focus-visible { + color: var(--primary); + outline: none; +} diff --git a/src/ui/pages/Settings/Settings.tsx b/src/ui/pages/Settings/Settings.tsx index d12da199a2..23fd18b741 100644 --- a/src/ui/pages/Settings/Settings.tsx +++ b/src/ui/pages/Settings/Settings.tsx @@ -8,7 +8,6 @@ import { PageTop } from 'src/ui/components/PageTop'; import { ViewSuspense } from 'src/ui/components/ViewSuspense'; import { accountPublicRPCPort, walletPort } from 'src/ui/shared/channels'; import { HStack } from 'src/ui/ui-kit/HStack'; -import { Toggle } from 'src/ui/ui-kit/Toggle'; import { UIText } from 'src/ui/ui-kit/UIText'; import { VStack } from 'src/ui/ui-kit/VStack'; import WalletIcon from 'jsx:src/ui/assets/wallet.svg'; @@ -65,6 +64,7 @@ import { BackupFlowSettingsSection } from './BackupFlowSettingsSection'; import { PreferencesPage } from './Preferences'; import type { PopoverToastHandle } from './PopoverToast'; import { PopoverToast } from './PopoverToast'; +import { ToggleSettingLine } from './ToggleSettingsLine'; const ZERION_ORIGIN = 'https://app.zerion.io'; @@ -371,32 +371,6 @@ function SettingsMain() { ); } -function ToggleSettingLine({ - checked, - onChange, - text, - detailText, -}: { - checked: boolean; - onChange: (event: React.ChangeEvent) => void; - text: NonNullable; - detailText: React.ReactNode | null; -}) { - return ( - - - {text} - {detailText ? ( - - {detailText} - - ) : null} - - - - ); -} - function ClearPendingTransactionsLine() { const toastRef = useRef(null); const { mutate: clearPendingTransactions, ...mutation } = useMutation({ diff --git a/src/ui/pages/Settings/ToggleSettingsLine.tsx b/src/ui/pages/Settings/ToggleSettingsLine.tsx new file mode 100644 index 0000000000..5cc653020a --- /dev/null +++ b/src/ui/pages/Settings/ToggleSettingsLine.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { HStack } from 'src/ui/ui-kit/HStack'; +import { Toggle } from 'src/ui/ui-kit/Toggle'; +import { UIText } from 'src/ui/ui-kit/UIText'; +import { VStack } from 'src/ui/ui-kit/VStack'; + +export function ToggleSettingLine({ + checked, + onChange, + text, + detailText, + disabled, +}: { + checked: boolean; + onChange: (event: React.ChangeEvent) => void; + text: NonNullable; + detailText: React.ReactNode | null; + disabled?: boolean; +}) { + return ( + + + {text} + {detailText ? ( + + {detailText} + + ) : null} + + + + ); +}