feat(auth): add webauthn authentication support

This commit is contained in:
BiFangKNT
2026-09-12 12:17:26 +08:00
parent 45d77c3926
commit b594cf23e4
18 changed files with 1370 additions and 2 deletions
@@ -12,7 +12,17 @@ import {
} from '@/components/ui/item';
import { httpClient } from '@/app/infra/http/HttpClient';
import { systemInfo } from '@/app/infra/http';
import { Loader2, ExternalLink, KeyRound, Layers } from 'lucide-react';
import {
Loader2,
ExternalLink,
KeyRound,
Layers,
Fingerprint,
Plus,
Trash2,
Pencil,
} from 'lucide-react';
import { startRegistration } from '@simplewebauthn/browser';
import PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog';
import { PanelBody } from '../settings-dialog/panel-layout';
@@ -22,6 +32,16 @@ interface AccountSettingsPanelProps {
onEmailResolved?: (email: string) => void;
}
interface PasskeyItem {
uuid: string;
name: string;
aaguid?: string;
transports?: string;
backed_up?: boolean;
created_at?: string;
last_used_at?: string;
}
export default function AccountSettingsPanel({
active,
onEmailResolved,
@@ -33,10 +53,14 @@ export default function AccountSettingsPanel({
const [loading, setLoading] = useState(true);
const [spaceBindLoading, setSpaceBindLoading] = useState(false);
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
const [passkeys, setPasskeys] = useState<PasskeyItem[]>([]);
const [passkeyLoading, setPasskeyLoading] = useState(false);
const [registeringPasskey, setRegisteringPasskey] = useState(false);
useEffect(() => {
if (active) {
loadUserInfo();
loadPasskeys();
}
}, [active]);
@@ -55,6 +79,67 @@ export default function AccountSettingsPanel({
}
}
async function loadPasskeys() {
setPasskeyLoading(true);
try {
const list = await httpClient.getPasskeys();
setPasskeys(list);
} catch {
// ignore
} finally {
setPasskeyLoading(false);
}
}
const handleAddPasskey = async () => {
setRegisteringPasskey(true);
try {
const { options, challenge_token } =
await httpClient.getPasskeyRegisterOptions(window.location.origin);
const regResp = await startRegistration({ optionsJSON: options });
const defaultName =
prompt(t('account.passkeyNamePlaceholder')) || undefined;
await httpClient.verifyPasskeyRegister(
challenge_token,
regResp,
defaultName,
);
toast.success(t('account.passkeyAddedSuccess'));
await loadPasskeys();
} catch (error: any) {
if (error?.name === 'NotAllowedError') {
// User cancelled
} else {
toast.error(error?.message || t('common.error'));
}
} finally {
setRegisteringPasskey(false);
}
};
const handleDeletePasskey = async (uuid: string) => {
if (!confirm(t('account.deletePasskeyConfirm'))) return;
try {
await httpClient.deletePasskey(uuid);
toast.success(t('account.passkeyDeleteSuccess'));
await loadPasskeys();
} catch (error: any) {
toast.error(error?.message || t('common.error'));
}
};
const handleRenamePasskey = async (uuid: string, currentName: string) => {
const newName = prompt(t('account.passkeyName'), currentName);
if (!newName || !newName.trim() || newName === currentName) return;
try {
await httpClient.renamePasskey(uuid, newName.trim());
toast.success(t('account.passkeyRenameSuccess'));
await loadPasskeys();
} catch (error: any) {
toast.error(error?.message || t('common.error'));
}
};
const handleBindSpace = async () => {
setSpaceBindLoading(true);
try {
@@ -148,6 +233,105 @@ export default function AccountSettingsPanel({
</ItemActions>
)}
</Item>
{/* Passkey Section */}
<div className="pt-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-medium">
{t('account.passkeySectionTitle')}
</h4>
<p className="text-xs text-muted-foreground">
{t('account.passkeySectionDesc')}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleAddPasskey}
disabled={
registeringPasskey || !systemInfo.allow_modify_login_info
}
className="cursor-pointer"
>
{registeringPasskey ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
{t('account.addPasskey')}
</Button>
</div>
{passkeyLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : passkeys.length === 0 ? (
<div className="rounded-lg border border-dashed p-4 text-center text-xs text-muted-foreground">
{t('account.noPasskeys')}
</div>
) : (
<div className="space-y-2">
{passkeys.map((pk) => (
<Item
key={pk.uuid}
size="sm"
variant="muted"
className="rounded-lg"
>
<ItemMedia variant="icon">
<Fingerprint className="h-4 w-4" />
</ItemMedia>
<ItemContent>
<ItemTitle>{pk.name}</ItemTitle>
<ItemDescription>
{pk.created_at && (
<span>
{t('account.passkeyCreated', {
date: new Date(
pk.created_at,
).toLocaleDateString(),
})}
</span>
)}
{pk.last_used_at && (
<span className="ml-2">
·{' '}
{t('account.passkeyLastUsed', {
date: new Date(
pk.last_used_at,
).toLocaleDateString(),
})}
</span>
)}
</ItemDescription>
</ItemContent>
<ItemActions>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer"
onClick={() => handleRenamePasskey(pk.uuid, pk.name)}
disabled={!systemInfo.allow_modify_login_info}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive cursor-pointer hover:text-destructive"
onClick={() => handleDeletePasskey(pk.uuid)}
disabled={!systemInfo.allow_modify_login_info}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</ItemActions>
</Item>
))}
</div>
)}
</div>
</div>
)}
+80
View File
@@ -1304,12 +1304,92 @@ export class BackendClient extends BaseHttpClient {
invitation_registration_enabled?: boolean;
password_login_enabled?: boolean;
space_login_enabled?: boolean;
passkey_login_enabled?: boolean;
passkey_supported?: boolean;
}> {
return this.get('/api/v1/user/account-info', undefined, {
skipWorkspace: true,
});
}
// ============ Passkey (WebAuthn) API ============
public getPasskeyAuthOptions(
email?: string,
origin?: string,
): Promise<{ options: any; challenge_token: string }> {
return this.post(
'/api/v1/user/passkey/auth/options',
{ email, origin },
{ skipWorkspace: true },
);
}
public verifyPasskeyAuth(
challenge_token: string,
credential: any,
): Promise<{ token: string; user: string }> {
return this.post(
'/api/v1/user/passkey/auth/verify',
{ challenge_token, credential },
{ skipWorkspace: true },
);
}
public getPasskeyRegisterOptions(
origin?: string,
): Promise<{ options: any; challenge_token: string }> {
return this.post(
'/api/v1/user/passkey/register/options',
{ origin },
{ skipWorkspace: true },
);
}
public verifyPasskeyRegister(
challenge_token: string,
credential: any,
name?: string,
): Promise<{ uuid: string; name: string; created_at?: string }> {
return this.post(
'/api/v1/user/passkey/register/verify',
{ challenge_token, credential, name },
{ skipWorkspace: true },
);
}
public getPasskeys(): Promise<
Array<{
uuid: string;
name: string;
aaguid?: string;
transports?: string;
backed_up?: boolean;
created_at?: string;
last_used_at?: string;
}>
> {
return this.get('/api/v1/user/passkeys', undefined, {
skipWorkspace: true,
});
}
public renamePasskey(
uuid: string,
name: string,
): Promise<{ uuid: string; name: string }> {
return this.patch(
`/api/v1/user/passkey/${encodeURIComponent(uuid)}`,
{ name },
{ skipWorkspace: true },
);
}
public deletePasskey(uuid: string): Promise<void> {
return this.delete(`/api/v1/user/passkey/${encodeURIComponent(uuid)}`, {
skipWorkspace: true,
});
}
// ============ Workspace API ============
public getWorkspaceBootstrap(): Promise<WorkspaceBootstrapResponse> {
return this.get('/api/v1/workspaces/bootstrap', undefined, {
+51 -1
View File
@@ -35,7 +35,9 @@ import {
AlertCircle,
RefreshCw,
Layers,
Fingerprint,
} from 'lucide-react';
import { startAuthentication } from '@simplewebauthn/browser';
import langbotIcon from '@/app/assets/langbot-logo.webp';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
@@ -63,6 +65,8 @@ export default function Login() {
const [spaceLoading, setSpaceLoading] = useState(false);
const [showLocalLogin, setShowLocalLogin] = useState(false);
const [showSpaceLogin, setShowSpaceLogin] = useState(false);
const [showPasskeyLogin, setShowPasskeyLogin] = useState(false);
const [passkeyLoading, setPasskeyLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [retrying, setRetrying] = useState(false);
@@ -90,6 +94,9 @@ export default function Login() {
}
setShowLocalLogin(res.password_login_enabled !== false);
setShowSpaceLogin(res.space_login_enabled !== false);
setShowPasskeyLogin(
res.passkey_login_enabled !== false || Boolean(res.passkey_supported),
);
setLoading(false);
// Also check if already logged in
@@ -184,6 +191,30 @@ export default function Login() {
handleLogin(values.email, values.password);
}
async function handlePasskeyLogin() {
setPasskeyLoading(true);
try {
const { options, challenge_token } =
await httpClient.getPasskeyAuthOptions(
undefined,
window.location.origin,
);
const authResp = await startAuthentication({ optionsJSON: options });
const res = await httpClient.verifyPasskeyAuth(challenge_token, authResp);
if (await finishLogin(res.token, res.user)) {
toast.success(t('common.passkeyLoginSuccess'));
}
} catch (error: any) {
if (error?.name === 'NotAllowedError') {
// User cancelled the biometric prompt
} else {
toast.error(error?.message || t('common.passkeyLoginFailed'));
}
} finally {
setPasskeyLoading(false);
}
}
function handleLogin(username: string, password: string) {
httpClient
.authUser(username, password)
@@ -324,8 +355,27 @@ export default function Login() {
</div>
)}
{showPasskeyLogin && (
<div className="space-y-3">
<Button
type="button"
variant="outline"
className="w-full cursor-pointer"
onClick={handlePasskeyLogin}
disabled={passkeyLoading}
>
{passkeyLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Fingerprint className="mr-2 h-4 w-4" />
)}
{t('common.loginWithPasskey')}
</Button>
</div>
)}
{/* Divider - only show if both login methods are available */}
{showSpaceLogin && showLocalLogin && (
{(showSpaceLogin || showPasskeyLogin) && showLocalLogin && (
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />