mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
feat: move totp and passkeys into plugins (C1)
This commit is contained in:
1 parent
f4f26a9b8b
commit
5c18435105
235 files changed
+22545
-5610
No files matched your search
@@ -36,7 +36,7 @@ export type AdminUser = {
|
||||
isOidc: boolean;
|
||||
passwordHash?: string;
|
||||
dataUnlocked?: boolean;
|
||||
totpEnabled?: boolean;
|
||||
secondFactorEnabled?: boolean;
|
||||
};
|
||||
|
||||
export type AdminSession = {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user