feat: move totp and passkeys into plugins (C1)

This commit is contained in:
LukeGus committed 2026-09-24 16:57:24 -05:00
1 parent f4f26a9b8b
commit 5c18435105
235 files changed
+22545 -5610

No files matched your search

+1 -1
View File
@@ -36,7 +36,7 @@ export type AdminUser = {
isOidc: boolean;
passwordHash?: string;
dataUnlocked?: boolean;
totpEnabled?: boolean;
secondFactorEnabled?: boolean;
};
export type AdminSession = {
+7 -1
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { ShieldOff } from "lucide-react";
import { Button } from "@/components/button";
import { pluginKey } from "@/lib/plugin-i18n";
import {
getUserSecondFactors,
resetUserSecondFactors,
@@ -70,7 +71,12 @@ export function AdminSecondFactorsSection({
>
<span>
{factor.labelKey
? t(factor.labelKey, { defaultValue: factor.factorId })
? t(
factor.pluginId === "core"
? factor.labelKey
: pluginKey(factor.pluginId, factor.labelKey),
{ defaultValue: factor.factorId },
)
: factor.factorId}
</span>
{!factor.available && (
+3 -3
View File
@@ -306,7 +306,7 @@ export function AdminSettingsPanel({
isOidc: user.is_oidc,
passwordHash: user.password_hash,
dataUnlocked: user.data_unlocked,
totpEnabled: user.totp_enabled,
secondFactorEnabled: user.second_factor_enabled,
})),
);
setUserTotal(total ?? u.length);
@@ -989,10 +989,10 @@ export function AdminSettingsPanel({
setUsers((prev) => prev.filter((u) => u.id !== manageUser.id));
setManageUser(null);
}}
onTotpDisabled={() => {
onSecondFactorsReset={() => {
setUsers((prev) =>
prev.map((u) =>
u.id === manageUser.id ? { ...u, totpEnabled: false } : u,
u.id === manageUser.id ? { ...u, secondFactorEnabled: false } : u,
),
);
}}
+3 -50
View File
@@ -11,7 +11,6 @@ import {
Pencil,
Plus,
RefreshCw,
ShieldOff,
SquareTerminal,
Trash2,
TriangleAlert,
@@ -30,7 +29,6 @@ import {
adminUpdateUserSnippet,
adminDeleteUserSnippet,
adminResetUserPassword,
adminDisableUserTotp,
adminExportUserData,
getSessions,
revokeSession,
@@ -112,14 +110,14 @@ export function AdminUserManagePanel({
onBack,
onOpenHostTab,
onUserDeleted,
onTotpDisabled,
onSecondFactorsReset,
}: {
user: AdminUser;
roles: Role[];
onBack: () => void;
onOpenHostTab?: (host: Host) => void;
onUserDeleted: () => void;
onTotpDisabled: () => void;
onSecondFactorsReset: () => void;
}) {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<ManageTabId>("account");
@@ -172,7 +170,6 @@ export function AdminUserManagePanel({
// Account tab state
const [resetPassword, setResetPassword] = useState("");
const [resetLoading, setResetLoading] = useState(false);
const [totpEnabled, setTotpEnabled] = useState(!!user.totpEnabled);
const [exportLoading, setExportLoading] = useState(false);
const [newKeyName, setNewKeyName] = useState("");
const [newKeyLoading, setNewKeyLoading] = useState(false);
@@ -292,17 +289,6 @@ export function AdminUserManagePanel({
}
}
async function handleDisableTotp() {
try {
await adminDisableUserTotp(user.id);
setTotpEnabled(false);
onTotpDisabled();
toast.success(t("admin.totpDisabledSuccess"));
} catch (e) {
toast.error(apiErrorMessage(e, t("admin.totpDisableFailed")));
}
}
async function handleExport() {
setExportLoading(true);
try {
@@ -556,36 +542,6 @@ export function AdminUserManagePanel({
)}
</div>
{/* TOTP */}
<div className="flex flex-col gap-2">
{sectionHeading(t("admin.totpSectionTitle"))}
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{totpEnabled
? t("admin.totpStatusEnabled")
: t("admin.totpStatusDisabled")}
</span>
{totpEnabled && (
<Button
variant="outline"
size="sm"
className="h-7 text-[10px] border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() =>
setConfirmDialog({
message: t("admin.disableTotpConfirm", {
username: user.username,
}),
onConfirm: handleDisableTotp,
})
}
>
<ShieldOff className="size-3" />
{t("admin.disableTotp")}
</Button>
)}
</div>
</div>
<AdminSecondFactorsSection
userId={user.id}
username={user.username}
@@ -593,10 +549,7 @@ export function AdminUserManagePanel({
requestConfirm={(message, onConfirm) =>
setConfirmDialog({ message, onConfirm })
}
onReset={() => {
setTotpEnabled(false);
onTotpDisabled();
}}
onReset={onSecondFactorsReset}
/>
{/* Roles */}
+32
View File
@@ -0,0 +1,32 @@
import { useTranslation } from "react-i18next";
import { useLoginMethods, useSecondFactors } from "@/plugin-host/auth-registry";
/**
* Enrolment sections login methods and second factors register, such as
* adding a passkey or setting up an authenticator app.
*/
export function AuthEnrollmentSections() {
const { t } = useTranslation();
const sections = [...useLoginMethods(), ...useSecondFactors()].filter(
(entry) => entry.enrollment,
);
if (sections.length === 0) return null;
return (
<div className="flex flex-col gap-4">
{sections.map((entry) => {
const Enrollment = entry.enrollment!;
return (
<div
key={`${entry.pluginId}:${entry.id}`}
className="flex flex-col gap-2"
>
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t(entry.titleKey)}
</span>
<Enrollment />
</div>
);
})}
</div>
);
}
@@ -1,29 +0,0 @@
import { useTranslation } from "react-i18next";
import { useSecondFactors } from "@/plugin-host/auth-registry";
/**
* Enrolment sections second-factor plugins register. Core's own TOTP and
* passkey sections sit above this until they move into plugins.
*/
export function PluginSecondFactorEnrollment() {
const { t } = useTranslation();
const factors = useSecondFactors().filter(
(factor) => factor.enrollment && factor.pluginId !== "core",
);
if (factors.length === 0) return null;
return (
<div className="flex flex-col gap-4">
{factors.map((factor) => {
const Enrollment = factor.enrollment!;
return (
<div key={factor.id} className="flex flex-col gap-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t(factor.titleKey)}
</span>
<Enrollment />
</div>
);
})}
</div>
);
}
+2 -554
View File
@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from "react";
import { PluginSecondFactorEnrollment } from "./PluginSecondFactorEnrollment";
import { AuthEnrollmentSections } from "./AuthEnrollmentSections";
import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard";
import {
@@ -11,9 +11,6 @@ import {
changePassword,
deleteAccount,
logoutUser,
setupTOTP,
enableTOTP,
disableTOTP,
getVersionInfo,
releaseUrlFrom,
getUserRoles,
@@ -23,13 +20,6 @@ import {
import { getDatabaseTransferUrl } from "@/lib/database-transfer-url";
import { readRailPreference, setRailPreference } from "./rail-preferences";
import { useHostSidebarPreferences } from "./tree/hooks/useHostSidebarPreferences";
import {
deleteWebAuthnCredential,
listWebAuthnCredentials,
registerWebAuthnCredential,
type WebAuthnCredentialSummary,
type WebAuthnUserVerification,
} from "@/api/webauthn-api";
import type { UserRole } from "@/main-axios";
import type React from "react";
import { isElectron } from "@/lib/electron";
@@ -48,7 +38,6 @@ import {
} from "@/components/dialog";
import {
AlertCircle,
CheckCircle2,
ChevronDown,
Copy,
Database,
@@ -65,7 +54,6 @@ import {
Trash2,
Type,
User,
X,
} from "lucide-react";
import { SettingRow, FakeSwitch } from "@/components/section-card";
import { NavigationVisibilityToggles } from "./NavigationVisibilityToggles";
@@ -584,27 +572,6 @@ export function UserProfilePanel({
const [isOidc, setIsOidc] = useState(false);
const [isDualAuth, setIsDualAuth] = useState(false);
// TOTP
const [totpEnabled, setTotpEnabled] = useState(false);
const [totpStep, setTotpStep] = useState<
"idle" | "setup" | "verify" | "backup"
>("idle");
const [totpQrCode, setTotpQrCode] = useState("");
const [totpSecret, setTotpSecret] = useState("");
const [totpCode, setTotpCode] = useState("");
const [totpBackupCodes, setTotpBackupCodes] = useState<string[]>([]);
const [totpLoading, setTotpLoading] = useState(false);
const [showDisableTotp, setShowDisableTotp] = useState(false);
const [disableTotpInput, setDisableTotpInput] = useState("");
const [showAddTotp, setShowAddTotp] = useState(false);
const [addTotpInput, setAddTotpInput] = useState("");
const [addingTotpAuthenticator, setAddingTotpAuthenticator] = useState(false);
const [passkeys, setPasskeys] = useState<WebAuthnCredentialSummary[]>([]);
const [passkeyLoading, setPasskeyLoading] = useState(false);
const [passkeyName, setPasskeyName] = useState("");
const [passkeyUserVerification, setPasskeyUserVerification] =
useState<WebAuthnUserVerification>("preferred");
// Delete account
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deletePassword, setDeletePassword] = useState("");
@@ -760,7 +727,6 @@ export function UserProfilePanel({
getUserInfo()
.then(async (localInfo) => {
setUserId(localInfo.userId);
setTotpEnabled(localInfo.totp_enabled ?? false);
setIsOidc(localInfo.is_oidc ?? false);
setIsDualAuth(localInfo.is_dual_auth ?? false);
const remoteInfo = await getRemoteSyncUserInfo();
@@ -791,9 +757,6 @@ export function UserProfilePanel({
getApiKeys()
.then(({ apiKeys: keys }) => setApiKeys(keys))
.catch(() => {});
listWebAuthnCredentials()
.then(({ credentials }) => setPasskeys(credentials ?? []))
.catch(() => {});
getVersionInfo()
.then((info) => {
setVersion(info.localVersion);
@@ -1204,133 +1167,6 @@ export function UserProfilePanel({
});
}
async function handleStartTotpSetup() {
setTotpLoading(true);
try {
const result = await setupTOTP();
setTotpQrCode(result.qr_code);
setTotpSecret(result.secret);
setTotpCode("");
setTotpStep("setup");
} catch {
toast.error(t("newUi.sidebar.userProfile.totpSetupFailed"));
} finally {
setTotpLoading(false);
}
}
async function handleAddTotpAuthenticator() {
if (!addTotpInput) {
toast.error(t("newUi.sidebar.userProfile.totpAddInputRequired"));
return;
}
setTotpLoading(true);
try {
const result = await setupTOTP(addTotpInput);
setTotpQrCode(result.qr_code);
setTotpSecret(result.secret);
setAddingTotpAuthenticator(true);
setShowAddTotp(false);
setAddTotpInput("");
setTotpStep("setup");
} catch (e: unknown) {
toast.error(
apiErrorMessage(e, t("newUi.sidebar.userProfile.totpAddFailed")),
);
} finally {
setTotpLoading(false);
}
}
async function handleVerifyTotp() {
if (!totpCode || totpCode.length !== 6) {
toast.error(t("newUi.sidebar.userProfile.totpEnter6Digits"));
return;
}
setTotpLoading(true);
try {
const result = await enableTOTP(totpCode);
setTotpBackupCodes(result.backup_codes ?? []);
setTotpEnabled(true);
if (!isRemoteSyncConnected) setAccountTotpEnabled(true);
setTotpStep("backup");
toast.success(t("newUi.sidebar.userProfile.totpEnabledSuccess"));
} catch (e: unknown) {
toast.error(
apiErrorMessage(e, t("newUi.sidebar.userProfile.totpInvalidCode")),
);
} finally {
setTotpLoading(false);
}
}
async function handleDisableTotp() {
if (!disableTotpInput) {
toast.error(t("newUi.sidebar.userProfile.totpDisableInputRequired"));
return;
}
setTotpLoading(true);
try {
await disableTOTP(disableTotpInput);
setTotpEnabled(false);
if (!isRemoteSyncConnected) setAccountTotpEnabled(false);
setShowDisableTotp(false);
setDisableTotpInput("");
toast.success(t("newUi.sidebar.userProfile.totpDisabledSuccess"));
} catch (e: unknown) {
toast.error(
apiErrorMessage(e, t("newUi.sidebar.userProfile.totpDisableFailed")),
);
} finally {
setTotpLoading(false);
}
}
async function handleRegisterPasskey() {
setPasskeyLoading(true);
try {
await registerWebAuthnCredential(
passkeyName || "Passkey",
passkeyUserVerification,
);
const { credentials } = await listWebAuthnCredentials();
setPasskeys(credentials ?? []);
setPasskeyName("");
toast.success(t("newUi.sidebar.userProfile.passkeyAdded"));
} catch (e: unknown) {
toast.error(
apiErrorMessage(e, t("newUi.sidebar.userProfile.passkeyAddFailed")),
);
} finally {
setPasskeyLoading(false);
}
}
async function handleDeletePasskey(credentialId: string) {
setPasskeyLoading(true);
try {
await deleteWebAuthnCredential(credentialId);
setPasskeys((prev) => prev.filter((item) => item.id !== credentialId));
toast.success(t("newUi.sidebar.userProfile.passkeyDeleted"));
} catch (e: unknown) {
toast.error(
apiErrorMessage(e, t("newUi.sidebar.userProfile.passkeyDeleteFailed")),
);
} finally {
setPasskeyLoading(false);
}
}
function downloadBackupCodes() {
const blob = new Blob([totpBackupCodes.join("\n")], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "termix-backup-codes.txt";
a.click();
URL.revokeObjectURL(url);
}
async function handleDeleteAccount() {
if (!deletePassword.trim()) {
toast.error(t("newUi.sidebar.userProfile.deletePasswordRequired"));
@@ -2173,395 +2009,7 @@ export function UserProfilePanel({
onToggle={() => toggle("security")}
>
<div className="flex flex-col gap-4 pt-3">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className="text-xs font-medium">
{t("newUi.sidebar.userProfile.totpAuthenticator")}
</span>
<a
href="https://docs.termix.site/features/authentication/totp"
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</div>
<span className="text-[10px] text-muted-foreground">
{totpEnabled
? t("newUi.sidebar.userProfile.totpEnabled")
: t("newUi.sidebar.userProfile.totpDisabled")}
</span>
</div>
{totpEnabled ? (
<div className="ml-3 flex shrink-0 gap-2">
<Button
variant="outline"
size="sm"
className="h-7 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => setShowAddTotp((open) => !open)}
disabled={totpLoading || totpStep !== "idle"}
>
{t("newUi.sidebar.userProfile.totpAddAuthenticator")}
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-[10px] border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => setShowDisableTotp((o) => !o)}
>
{t("newUi.sidebar.userProfile.disable")}
</Button>
</div>
) : (
<Button
variant="outline"
size="sm"
className="shrink-0 ml-3 text-[10px] h-7 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleStartTotpSetup}
disabled={totpLoading || totpStep !== "idle"}
>
{t("newUi.sidebar.userProfile.enable")}
</Button>
)}
</div>
{totpEnabled && showAddTotp && (
<div className="border border-border bg-muted/20 p-3 flex flex-col gap-3">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.totpAddTitle")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("newUi.sidebar.userProfile.totpAddDescription")}
</span>
<Input
placeholder={t(
"newUi.sidebar.userProfile.totpDisablePlaceholder",
)}
value={addTotpInput}
onChange={(e) => setAddTotpInput(e.target.value)}
onKeyDown={(e) =>
e.key === "Enter" && handleAddTotpAuthenticator()
}
className="text-sm"
/>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
className="flex-1 text-xs"
onClick={() => {
setShowAddTotp(false);
setAddTotpInput("");
}}
>
{t("newUi.sidebar.userProfile.cancel")}
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleAddTotpAuthenticator}
disabled={totpLoading}
>
{t("common.continue")}
</Button>
</div>
</div>
)}
{/* Disable TOTP form */}
{totpEnabled && showDisableTotp && (
<div className="border border-border bg-muted/20 p-3 flex flex-col gap-3">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.totpDisableTitle")}
</span>
<Input
placeholder={t(
"newUi.sidebar.userProfile.totpDisablePlaceholder",
)}
value={disableTotpInput}
onChange={(e) => setDisableTotpInput(e.target.value)}
className="text-sm"
/>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
className="flex-1 text-xs"
onClick={() => {
setShowDisableTotp(false);
setDisableTotpInput("");
}}
>
{t("newUi.sidebar.userProfile.cancel")}
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 text-xs border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={handleDisableTotp}
disabled={totpLoading}
>
{t("newUi.sidebar.userProfile.totpDisableConfirm")}
</Button>
</div>
</div>
)}
{/* TOTP setup: scan QR */}
{totpStep === "setup" && (
<div className="border border-border bg-muted/20 p-3 flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.setupTotp")}
</span>
<button
onClick={() => {
setTotpStep("idle");
setAddingTotpAuthenticator(false);
}}
className="text-muted-foreground hover:text-foreground"
>
<X className="size-3.5" />
</button>
</div>
{totpQrCode ? (
<div className="flex items-center justify-center p-3 bg-background border border-border">
<img
src={totpQrCode}
alt={t("newUi.sidebar.userProfile.qrCode")}
className="size-32"
/>
</div>
) : (
<div className="flex items-center justify-center p-3 bg-background border border-border">
<div className="size-24 bg-muted flex items-center justify-center text-[10px] text-muted-foreground">
{t("newUi.sidebar.userProfile.qrCode")}
</div>
</div>
)}
<div className="flex items-center gap-2 bg-muted/30 border border-border px-2 py-1.5">
<span className="text-[10px] font-mono flex-1 tracking-widest select-all truncate">
{totpSecret}
</span>
<button
onClick={() => {
copyToClipboard(totpSecret);
toast.info(t("newUi.sidebar.userProfile.secretCopied"));
}}
className="text-muted-foreground hover:text-accent-brand shrink-0"
>
<Copy className="size-3.5" />
</button>
</div>
<span className="text-[10px] text-muted-foreground text-center">
{t(
addingTotpAuthenticator
? "newUi.sidebar.userProfile.totpAddScanInstructions"
: "newUi.sidebar.userProfile.totpInstructions",
)}
</span>
<Button
variant="outline"
size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => {
if (addingTotpAuthenticator) {
setAddingTotpAuthenticator(false);
setTotpStep("idle");
toast.success(
t("newUi.sidebar.userProfile.totpAddSuccess"),
);
} else {
setTotpStep("verify");
}
}}
>
{t(
addingTotpAuthenticator
? "newUi.sidebar.userProfile.done"
: "newUi.sidebar.userProfile.totpContinueVerify",
)}
</Button>
</div>
)}
{/* TOTP setup: verify code */}
{!totpEnabled && totpStep === "verify" && (
<div className="border border-border bg-muted/20 p-3 flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.totpVerifyTitle")}
</span>
<button
onClick={() => setTotpStep("setup")}
className="text-muted-foreground hover:text-foreground"
>
<X className="size-3.5" />
</button>
</div>
<Input
placeholder={t(
"newUi.sidebar.userProfile.totpCodePlaceholder",
)}
value={totpCode}
onChange={(e) =>
setTotpCode(e.target.value.replace(/\D/g, "").slice(0, 6))
}
onKeyDown={(e) => e.key === "Enter" && handleVerifyTotp()}
className="text-center font-mono tracking-widest text-lg h-10"
maxLength={6}
/>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
className="flex-1 text-xs"
onClick={() => setTotpStep("setup")}
>
{t("newUi.sidebar.userProfile.cancel")}
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleVerifyTotp}
disabled={totpLoading || totpCode.length !== 6}
>
<CheckCircle2 className="size-3.5" />
{t("newUi.sidebar.userProfile.verify")}
</Button>
</div>
</div>
)}
{/* TOTP setup: backup codes */}
{totpStep === "backup" && totpBackupCodes.length > 0 && (
<div className="border border-border bg-muted/20 p-3 flex flex-col gap-3">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.totpBackupTitle")}
</span>
<div className="grid grid-cols-2 gap-1">
{totpBackupCodes.map((code) => (
<span
key={code}
className="text-[10px] font-mono bg-muted border border-border px-2 py-1 text-center"
>
{code}
</span>
))}
</div>
<Button
variant="outline"
size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={downloadBackupCodes}
>
{t("newUi.sidebar.userProfile.totpDownloadBackup")}
</Button>
<Button
variant="ghost"
size="sm"
className="text-xs"
onClick={() => setTotpStep("idle")}
>
{t("newUi.sidebar.userProfile.done")}
</Button>
</div>
)}
</div>
<div className="flex flex-col gap-3 border-t border-border pt-3">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium">
{t("newUi.sidebar.userProfile.passkeys")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("newUi.sidebar.userProfile.passkeysDesc")}
</span>
</div>
</div>
<div className="grid grid-cols-[1fr_auto] gap-2">
<Input
placeholder={t("newUi.sidebar.userProfile.passkeyName")}
value={passkeyName}
onChange={(e) => setPasskeyName(e.target.value)}
className="h-8 text-xs"
disabled={passkeyLoading}
/>
<Select2
value={passkeyUserVerification}
onChange={(e) =>
setPasskeyUserVerification(
e.target.value as WebAuthnUserVerification,
)
}
className="h-8 w-28 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
disabled={passkeyLoading}
>
<option value="preferred">
{t("newUi.sidebar.userProfile.passkeyUvPreferred")}
</option>
<option value="required">
{t("newUi.sidebar.userProfile.passkeyUvRequired")}
</option>
<option value="discouraged">
{t("newUi.sidebar.userProfile.passkeyUvDiscouraged")}
</option>
</Select2>
</div>
<Button
variant="outline"
size="sm"
className="h-7 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleRegisterPasskey}
disabled={passkeyLoading}
>
<KeyRound className="size-3" />
{t("newUi.sidebar.userProfile.addPasskey")}
</Button>
<div className="flex flex-col divide-y divide-border border border-border">
{passkeys.length === 0 ? (
<div className="py-4 text-center text-[10px] text-muted-foreground">
{t("newUi.sidebar.userProfile.noPasskeys")}
</div>
) : (
passkeys.map((passkey) => (
<div
key={passkey.id}
className="flex items-center justify-between gap-2 px-2 py-2"
>
<div className="min-w-0 flex flex-col">
<span className="text-xs font-medium truncate">
{passkey.name}
</span>
<span className="text-[10px] text-muted-foreground truncate">
{passkey.deviceType || "unknown"}
{passkey.backedUp ? " / synced" : ""}
</span>
</div>
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-destructive"
onClick={() => handleDeletePasskey(passkey.id)}
disabled={passkeyLoading}
>
<Trash2 className="size-3" />
</Button>
</div>
))
)}
</div>
</div>
<PluginSecondFactorEnrollment />
<AuthEnrollmentSections />
{canChangePasword && (
<PasswordChangeSection