Add passkey login for protected UI (#845)
* feat(auth): add passkey login for protected UI * fix: polish passkey setup and correct WebAuthn user IDs * feat: improve passkey login management * build: align passkey deps with upstream main * refactor: move ui auth out of opencode module --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
37cf7d9c79
commit
75a10ea66c
@@ -1,14 +1,27 @@
|
||||
import React from 'react';
|
||||
import { RiLockLine, RiLockUnlockLine, RiLoader4Line } from '@remixicon/react';
|
||||
import { browserSupportsWebAuthn } from '@simplewebauthn/browser';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { toast } from '@/components/ui';
|
||||
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { syncDesktopSettings, initializeAppearancePreferences } from '@/lib/persistence';
|
||||
import { applyPersistedDirectoryPreferences } from '@/lib/directoryPersistence';
|
||||
import { DesktopHostSwitcherInline } from '@/components/desktop/DesktopHostSwitcher';
|
||||
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||
import {
|
||||
authenticateWithPasskey,
|
||||
cancelPasskeyCeremony,
|
||||
defaultPasskeyStatus,
|
||||
fetchPasskeyStatus,
|
||||
isPasskeyCeremonyAbort,
|
||||
type PasskeyStatus,
|
||||
registerCurrentDevicePasskey,
|
||||
} from '@/lib/passkeys';
|
||||
|
||||
const STATUS_CHECK_ENDPOINT = '/auth/session';
|
||||
const TRUST_DEVICE_STORAGE_KEY = 'openchamber.uiAuth.trustDevice';
|
||||
|
||||
const fetchSessionStatus = async (): Promise<Response> => {
|
||||
console.log('[Frontend Auth] Checking session status...');
|
||||
@@ -23,7 +36,14 @@ const fetchSessionStatus = async (): Promise<Response> => {
|
||||
return response;
|
||||
};
|
||||
|
||||
const submitPassword = async (password: string): Promise<Response> => {
|
||||
const readStoredTrustDevice = (): boolean => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
return window.localStorage.getItem(TRUST_DEVICE_STORAGE_KEY) === 'true';
|
||||
};
|
||||
|
||||
const submitPassword = async (password: string, trustDevice: boolean): Promise<Response> => {
|
||||
console.log('[Frontend Auth] Submitting password...');
|
||||
const response = await fetch(STATUS_CHECK_ENDPOINT, {
|
||||
method: 'POST',
|
||||
@@ -32,7 +52,7 @@ const submitPassword = async (password: string): Promise<Response> => {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ password }),
|
||||
body: JSON.stringify({ password, trustDevice }),
|
||||
});
|
||||
console.log('[Frontend Auth] Password submit response:', response.status, response.statusText);
|
||||
return response;
|
||||
@@ -115,9 +135,66 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
const [errorMessage, setErrorMessage] = React.useState('');
|
||||
const [retryAfter, setRetryAfter] = React.useState<number | undefined>(undefined);
|
||||
const [isTunnelLocked, setIsTunnelLocked] = React.useState(false);
|
||||
const [passkeyStatus, setPasskeyStatus] = React.useState<PasskeyStatus>(defaultPasskeyStatus);
|
||||
const [supportsPasskeys, setSupportsPasskeys] = React.useState(false);
|
||||
const [isPasskeyBusy, setIsPasskeyBusy] = React.useState(false);
|
||||
const [trustDevice, setTrustDevice] = React.useState<boolean>(() => readStoredTrustDevice());
|
||||
const [activePasskeyAction, setActivePasskeyAction] = React.useState<'auth' | 'register' | null>(null);
|
||||
const passwordInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const hasResyncedRef = React.useRef(skipAuth);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(TRUST_DEVICE_STORAGE_KEY, trustDevice ? 'true' : 'false');
|
||||
}, [trustDevice]);
|
||||
|
||||
const refreshPasskeyStatus = React.useCallback(async () => {
|
||||
if (skipAuth) {
|
||||
return defaultPasskeyStatus;
|
||||
}
|
||||
|
||||
try {
|
||||
const nextStatus = await fetchPasskeyStatus();
|
||||
setPasskeyStatus(nextStatus);
|
||||
return nextStatus;
|
||||
} catch {
|
||||
setPasskeyStatus(defaultPasskeyStatus);
|
||||
return defaultPasskeyStatus;
|
||||
}
|
||||
}, [skipAuth]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (skipAuth) {
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
if (!window.isSecureContext || !browserSupportsWebAuthn()) {
|
||||
if (!cancelled) {
|
||||
setSupportsPasskeys(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!cancelled) {
|
||||
setSupportsPasskeys(true);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setSupportsPasskeys(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [skipAuth]);
|
||||
|
||||
const checkStatus = React.useCallback(async () => {
|
||||
if (skipAuth) {
|
||||
console.log('[Frontend Auth] VSCode runtime, skipping auth');
|
||||
@@ -125,16 +202,12 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查 cookie 是否存在
|
||||
const cookies = document.cookie;
|
||||
const hasAccessToken = cookies.includes('oc_ui_session=');
|
||||
const hasRefreshToken = cookies.includes('oc_ui_refresh=');
|
||||
console.log('[Frontend Auth] Cookies check - access:', hasAccessToken, 'refresh:', hasRefreshToken);
|
||||
console.log('[Frontend Auth] All cookies:', cookies.split(';').map(c => c.trim().split('=')[0]));
|
||||
|
||||
setState((prev) => (prev === 'authenticated' ? prev : 'pending'));
|
||||
try {
|
||||
const response = await fetchSessionStatus();
|
||||
const [response, latestPasskeyStatus] = await Promise.all([
|
||||
fetchSessionStatus(),
|
||||
refreshPasskeyStatus(),
|
||||
]);
|
||||
const responseText = await response.text();
|
||||
console.log('[Frontend Auth] Raw response:', response.status, responseText);
|
||||
|
||||
@@ -158,6 +231,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
console.warn('[Frontend Auth] Debug info:', data.debug);
|
||||
}
|
||||
setIsTunnelLocked(data.tunnelLocked === true);
|
||||
setPasskeyStatus(latestPasskeyStatus);
|
||||
setState('locked');
|
||||
setRetryAfter(undefined);
|
||||
return;
|
||||
@@ -182,7 +256,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
setState('error');
|
||||
setIsTunnelLocked(false);
|
||||
}
|
||||
}, [skipAuth]);
|
||||
}, [refreshPasskeyStatus, skipAuth]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (skipAuth) {
|
||||
@@ -220,6 +294,28 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
await handlePasswordUnlock(false);
|
||||
};
|
||||
|
||||
const registerPasskeyForCurrentSession = React.useCallback(async () => {
|
||||
setActivePasskeyAction('register');
|
||||
setIsPasskeyBusy(true);
|
||||
try {
|
||||
await registerCurrentDevicePasskey();
|
||||
} finally {
|
||||
setActivePasskeyAction(null);
|
||||
setIsPasskeyBusy(false);
|
||||
}
|
||||
await refreshPasskeyStatus();
|
||||
}, [refreshPasskeyStatus]);
|
||||
|
||||
const cancelActivePasskey = React.useCallback(() => {
|
||||
cancelPasskeyCeremony();
|
||||
setActivePasskeyAction(null);
|
||||
setIsPasskeyBusy(false);
|
||||
}, []);
|
||||
|
||||
const handlePasswordUnlock = React.useCallback(async (enrollPasskey: boolean) => {
|
||||
if (isTunnelLocked) {
|
||||
return;
|
||||
}
|
||||
@@ -227,21 +323,36 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPasskeyBusy) {
|
||||
cancelActivePasskey();
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
const response = await submitPassword(password);
|
||||
const response = await submitPassword(password, trustDevice);
|
||||
if (response.ok) {
|
||||
console.log('[Frontend Auth] Login successful');
|
||||
// 检查登录后 cookie 是否被设置
|
||||
const cookies = document.cookie;
|
||||
const hasAccessToken = cookies.includes('oc_ui_session=');
|
||||
const hasRefreshToken = cookies.includes('oc_ui_refresh=');
|
||||
console.log('[Frontend Auth] After login - access:', hasAccessToken, 'refresh:', hasRefreshToken);
|
||||
console.log('[Frontend Auth] All cookies after login:', cookies.split(';').map(c => c.trim().split('=')[0]).filter(Boolean));
|
||||
setPassword('');
|
||||
setIsTunnelLocked(false);
|
||||
if (enrollPasskey && supportsPasskeys) {
|
||||
try {
|
||||
await registerPasskeyForCurrentSession();
|
||||
toast.success('Passkey added');
|
||||
setState('authenticated');
|
||||
return;
|
||||
} catch (error) {
|
||||
if (isPasskeyCeremonyAbort(error)) {
|
||||
toast.message('Passkey setup canceled');
|
||||
} else {
|
||||
const message = error instanceof Error ? error.message : 'Passkey setup failed.';
|
||||
toast.error(message);
|
||||
}
|
||||
setState('authenticated');
|
||||
return;
|
||||
}
|
||||
}
|
||||
setState('authenticated');
|
||||
return;
|
||||
}
|
||||
@@ -275,7 +386,75 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
}, [cancelActivePasskey, isPasskeyBusy, isSubmitting, isTunnelLocked, password, registerPasskeyForCurrentSession, supportsPasskeys, trustDevice]);
|
||||
|
||||
const handlePasskeyUnlock = React.useCallback(async () => {
|
||||
if (isSubmitting || !supportsPasskeys) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPasskeyBusy) {
|
||||
cancelActivePasskey();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPasskeyBusy(true);
|
||||
setActivePasskeyAction('auth');
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
await authenticateWithPasskey(trustDevice);
|
||||
|
||||
setPassword('');
|
||||
setState('authenticated');
|
||||
} catch (error) {
|
||||
if (isPasskeyCeremonyAbort(error)) {
|
||||
setErrorMessage('');
|
||||
} else {
|
||||
const message = error instanceof Error ? error.message : 'Passkey sign-in was cancelled.';
|
||||
setErrorMessage(message);
|
||||
}
|
||||
} finally {
|
||||
setActivePasskeyAction(null);
|
||||
setIsPasskeyBusy(false);
|
||||
}
|
||||
}, [cancelActivePasskey, isPasskeyBusy, isSubmitting, supportsPasskeys, trustDevice]);
|
||||
|
||||
const handlePasskeySetupOnly = React.useCallback(async () => {
|
||||
if (isSubmitting || isTunnelLocked || !supportsPasskeys) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPasskeyBusy) {
|
||||
cancelActivePasskey();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state !== 'authenticated') {
|
||||
if (!password) {
|
||||
setErrorMessage('Enter your password to add a passkey.');
|
||||
return;
|
||||
}
|
||||
await handlePasswordUnlock(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage('');
|
||||
try {
|
||||
await registerPasskeyForCurrentSession();
|
||||
toast.success('Passkey added');
|
||||
} catch (error) {
|
||||
if (isPasskeyCeremonyAbort(error)) {
|
||||
toast.message('Passkey setup canceled');
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Passkey setup failed.';
|
||||
toast.error(message);
|
||||
}
|
||||
}, [cancelActivePasskey, handlePasswordUnlock, isPasskeyBusy, isSubmitting, isTunnelLocked, password, registerPasskeyForCurrentSession, state, supportsPasskeys]);
|
||||
|
||||
const canOfferPasskeySetup = supportsPasskeys && passkeyStatus.enabled;
|
||||
const canUsePasskey = canOfferPasskeySetup && passkeyStatus.hasPasskeys;
|
||||
|
||||
if (state === 'pending') {
|
||||
return <LoadingScreen />;
|
||||
@@ -306,6 +485,22 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
|
||||
{!isTunnelLocked && (
|
||||
<form onSubmit={handleSubmit} className="w-full space-y-2" data-keyboard-avoid="true">
|
||||
{canUsePasskey && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => void handlePasskeyUnlock()}
|
||||
disabled={isSubmitting || (isPasskeyBusy && activePasskeyAction !== 'auth')}
|
||||
>
|
||||
{isPasskeyBusy ? (
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RiLockUnlockLine className="h-4 w-4" />
|
||||
)}
|
||||
<span>{isPasskeyBusy && activePasskeyAction === 'auth' ? 'Cancel passkey' : 'Use passkey'}</span>
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<RiLockLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground/60" />
|
||||
@@ -341,6 +536,43 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{canOfferPasskeySetup ? (
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<label className="flex items-center gap-2 text-center typography-micro text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={trustDevice}
|
||||
onChange={setTrustDevice}
|
||||
disabled={isSubmitting}
|
||||
ariaLabel="Trust this device"
|
||||
className="size-4"
|
||||
iconClassName="size-4"
|
||||
/>
|
||||
<span>Trust this device</span>
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => void handlePasskeySetupOnly()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isPasskeyBusy && activePasskeyAction === 'register' ? 'Cancel passkey setup' : 'Add passkey'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<label className="flex items-center justify-center gap-2 pt-1 text-center typography-micro text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={trustDevice}
|
||||
onChange={setTrustDevice}
|
||||
disabled={isSubmitting}
|
||||
ariaLabel="Trust this device"
|
||||
className="size-4"
|
||||
iconClassName="size-4"
|
||||
/>
|
||||
<span>Trust this device</span>
|
||||
</label>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<p id="oc-ui-auth-error" className="typography-meta text-destructive">
|
||||
{errorMessage}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { OpenChamberVisualSettings } from './OpenChamberVisualSettings';
|
||||
import { AboutSettings } from './AboutSettings';
|
||||
import { SessionRetentionSettings } from './SessionRetentionSettings';
|
||||
import { PasskeySettings } from './PasskeySettings';
|
||||
import { DefaultsSettings } from './DefaultsSettings';
|
||||
import { GitSettings } from './GitSettings';
|
||||
import { NotificationSettings } from './NotificationSettings';
|
||||
@@ -46,6 +47,9 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
|
||||
<div className="border-t border-border/40 pt-6">
|
||||
<SessionRetentionSettings />
|
||||
</div>
|
||||
<div className="border-t border-border/40 pt-6">
|
||||
<PasskeySettings />
|
||||
</div>
|
||||
{showAbout && (
|
||||
<div className="border-t border-border/40 pt-6">
|
||||
<AboutSettings />
|
||||
@@ -133,6 +137,9 @@ const SessionsSectionContent: React.FC = () => {
|
||||
<div className="border-t border-border/40 pt-6">
|
||||
<SessionRetentionSettings />
|
||||
</div>
|
||||
<div className="border-t border-border/40 pt-6">
|
||||
<PasskeySettings />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import React from 'react';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
cancelPasskeyCeremony,
|
||||
defaultPasskeyStatus,
|
||||
fetchPasskeyStatus,
|
||||
fetchStoredPasskeys,
|
||||
getPasskeySupportState,
|
||||
isPasskeyCeremonyAbort,
|
||||
registerCurrentDevicePasskey,
|
||||
resetAllAuth,
|
||||
revokeStoredPasskey,
|
||||
type PasskeyStatus,
|
||||
type StoredPasskey,
|
||||
} from '@/lib/passkeys';
|
||||
|
||||
const formatTimestamp = (timestamp: number | null) => {
|
||||
if (!timestamp || !Number.isFinite(timestamp)) {
|
||||
return 'Never used';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(timestamp);
|
||||
};
|
||||
|
||||
export const PasskeySettings: React.FC = () => {
|
||||
const [supportsPasskeys, setSupportsPasskeys] = React.useState(false);
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
const [isRegistering, setIsRegistering] = React.useState(false);
|
||||
const [revokingId, setRevokingId] = React.useState<string | null>(null);
|
||||
const [isResetting, setIsResetting] = React.useState(false);
|
||||
const [passkeys, setPasskeys] = React.useState<StoredPasskey[]>([]);
|
||||
const [status, setStatus] = React.useState<PasskeyStatus>(defaultPasskeyStatus);
|
||||
const [errorMessage, setErrorMessage] = React.useState('');
|
||||
const supportState = React.useMemo(() => getPasskeySupportState(), []);
|
||||
|
||||
const loadPasskeys = React.useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
const nextPasskeys = await fetchStoredPasskeys();
|
||||
setPasskeys(nextPasskeys);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Could not load passkeys.';
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
if (!supportState.supported) {
|
||||
if (!cancelled) {
|
||||
setSupportsPasskeys(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!cancelled) {
|
||||
setSupportsPasskeys(true);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setSupportsPasskeys(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
const nextStatus = await fetchPasskeyStatus();
|
||||
setStatus(nextStatus);
|
||||
if (!nextStatus.enabled) {
|
||||
setPasskeys([]);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
await loadPasskeys();
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelPasskeyCeremony();
|
||||
};
|
||||
}, [loadPasskeys, supportState.supported]);
|
||||
|
||||
const handleRegisterPasskey = React.useCallback(async () => {
|
||||
if (!status.enabled) {
|
||||
const message = 'Enable the UI password lock before adding passkeys.';
|
||||
setErrorMessage(message);
|
||||
toast.message(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!supportsPasskeys) {
|
||||
setErrorMessage(supportState.reason);
|
||||
toast.message(supportState.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRegistering) {
|
||||
cancelPasskeyCeremony();
|
||||
setIsRegistering(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage('');
|
||||
setIsRegistering(true);
|
||||
|
||||
try {
|
||||
await registerCurrentDevicePasskey();
|
||||
setStatus(await fetchPasskeyStatus());
|
||||
await loadPasskeys();
|
||||
toast.success('Passkey added');
|
||||
} catch (error) {
|
||||
if (isPasskeyCeremonyAbort(error)) {
|
||||
toast.message('Passkey setup canceled');
|
||||
return;
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : 'Could not add passkey.';
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsRegistering(false);
|
||||
}
|
||||
}, [isRegistering, loadPasskeys, status.enabled, supportState.reason, supportsPasskeys]);
|
||||
|
||||
const handleRevokePasskey = React.useCallback(async (id: string) => {
|
||||
setRevokingId(id);
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
await revokeStoredPasskey(id);
|
||||
setStatus(await fetchPasskeyStatus());
|
||||
await loadPasskeys();
|
||||
toast.success('Passkey removed');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Could not remove passkey.';
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setRevokingId(null);
|
||||
}
|
||||
}, [loadPasskeys]);
|
||||
|
||||
const handleResetAllAuth = React.useCallback(async () => {
|
||||
setIsResetting(true);
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
await resetAllAuth();
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Could not clear saved authentication.';
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
setIsResetting(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Passkeys</h3>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0 space-y-2">
|
||||
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">Current device</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:w-fit">
|
||||
<Button
|
||||
type="button"
|
||||
variant={isRegistering ? 'secondary' : 'outline'}
|
||||
size="xs"
|
||||
onClick={() => void handleRegisterPasskey()}
|
||||
disabled={isLoading || isResetting}
|
||||
className="!font-normal"
|
||||
>
|
||||
{isRegistering ? 'Cancel passkey setup' : 'Add passkey'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => void handleResetAllAuth()}
|
||||
disabled={isLoading || isRegistering || isResetting}
|
||||
className="!font-normal text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{isResetting ? 'Signing out…' : 'Sign out everywhere'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!status.enabled && (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Passkeys are available only when the UI password lock is enabled.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status.enabled && !supportsPasskeys && (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{supportState.reason}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className="typography-meta text-muted-foreground">Loading passkeys…</p>
|
||||
) : passkeys.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground">No passkeys saved for this host yet.</p>
|
||||
) : (
|
||||
<div className="space-y-1 pt-1">
|
||||
{passkeys.map((passkey) => (
|
||||
<div key={passkey.id} className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground truncate">{passkey.label}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
|
||||
<span className="typography-meta text-muted-foreground truncate">
|
||||
{passkey.lastUsedAt ? `Last used ${formatTimestamp(passkey.lastUsedAt)}` : `Added ${formatTimestamp(passkey.createdAt)}`}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => void handleRevokePasskey(passkey.id)}
|
||||
disabled={revokingId === passkey.id}
|
||||
className="!font-normal text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{revokingId === passkey.id ? 'Removing…' : 'Remove'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{errorMessage && (
|
||||
<div className="mt-1 px-2 py-1.5">
|
||||
<p className="typography-meta text-[var(--status-error)]">{errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user