import { useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard";
import {
getUserInfo,
getApiKeys,
createApiKey,
deleteApiKey,
changePassword,
deleteAccount,
logoutUser,
setupTOTP,
enableTOTP,
disableTOTP,
getVersionInfo,
getUserRoles,
saveUserPreferences,
getUserPreferences,
} from "@/main-axios";
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";
import { C2STunnelPresetManager } from "@/user/C2STunnelPresetManager";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/dialog";
import {
Activity,
AlertCircle,
CheckCircle2,
ChevronDown,
Clock,
Copy,
Eye,
EyeOff,
Hammer,
KeyRound,
LayoutPanelLeft,
Network,
Palette,
Play,
Plug,
Plus,
RotateCcw,
ScrollText,
Server,
Shield,
ShieldCheck,
Trash2,
Type,
Usb,
User,
X,
Zap,
} from "lucide-react";
import { SettingRow, FakeSwitch } from "@/components/section-card";
import {
ACCENT_PRESET_COLORS,
applyAccentColor,
applyFontSize,
FONT_SIZES,
} from "@/lib/theme";
import type { ApiKey } from "@/main-axios";
import { useTheme } from "@/components/theme-provider";
import type { FontSizeId, ThemeId } from "@/types/ui-types";
import { toast } from "sonner";
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
type UserProfileSection =
| "account"
| "appearance"
| "security"
| "api-keys"
| "c2s-tunnels";
const THEMES: { id: ThemeId; preview: string }[] = [
{ id: "system", preview: "auto" },
{ id: "light", preview: "#ffffff" },
{ id: "dark", preview: "#1a1c22" },
{ id: "dracula", preview: "#282a36" },
{ id: "catppuccin", preview: "#1e1e2e" },
{ id: "nord", preview: "#2e3440" },
{ id: "solarized", preview: "#002b36" },
{ id: "tokyo-night", preview: "#1a1b26" },
{ id: "one-dark", preview: "#282c34" },
{ id: "gruvbox", preview: "#282828" },
];
const LANGUAGES = [
{ code: "en", label: "English" },
{ code: "af", label: "Afrikaans" },
{ code: "ar", label: "العربية" },
{ code: "bn", label: "বাংলা" },
{ code: "bg", label: "Български" },
{ code: "ca", label: "Català" },
{ code: "zh-CN", label: "中文 (简体)" },
{ code: "zh-TW", label: "中文 (繁體)" },
{ code: "cs", label: "Čeština" },
{ code: "da", label: "Dansk" },
{ code: "nl", label: "Nederlands" },
{ code: "fi", label: "Suomi" },
{ code: "fr", label: "Français" },
{ code: "de", label: "Deutsch" },
{ code: "el", label: "Ελληνικά" },
{ code: "he", label: "עברית" },
{ code: "hi", label: "हिन्दी" },
{ code: "hu", label: "Magyar" },
{ code: "id", label: "Indonesia" },
{ code: "it", label: "Italiano" },
{ code: "ja", label: "日本語" },
{ code: "ko", label: "한국어" },
{ code: "no", label: "Norsk" },
{ code: "pl", label: "Polski" },
{ code: "pt-PT", label: "Português (PT)" },
{ code: "pt-BR", label: "Português (BR)" },
{ code: "ro", label: "Română" },
{ code: "ru", label: "Русский" },
{ code: "sr", label: "Српски" },
{ code: "es-ES", label: "Español" },
{ code: "sv-SE", label: "Svenska" },
{ code: "th", label: "ไทย" },
{ code: "tr", label: "Türkçe" },
{ code: "uk", label: "Українська" },
{ code: "vi", label: "Tiếng Việt" },
];
function AccordionSection({
id,
label,
icon,
open,
onToggle,
children,
}: {
id: string;
label: string;
icon: React.ReactNode;
open: boolean;
onToggle: () => void;
children: React.ReactNode;
}) {
return (
{open && (
{children}
)}
);
}
type ApiErrorLike = {
response?: {
data?: {
error?: string;
};
};
};
function apiErrorMessage(error: unknown, fallback: string) {
return (error as ApiErrorLike).response?.data?.error || fallback;
}
type CreatedProfileApiKey = {
id: string;
name: string;
token?: string;
tokenPrefix?: string;
createdAt?: string;
expiresAt?: string | null;
};
function NewApiKeyDialog({
open,
onOpenChange,
onAdd,
userId,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
onAdd: (key: CreatedProfileApiKey) => void;
userId: string;
}) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [expiry, setExpiry] = useState("");
const handleCreate = async () => {
if (!name.trim()) {
toast.error(t("newUi.sidebar.userProfile.apiKeyNameRequired"));
return;
}
try {
const created = await createApiKey(
name.trim(),
userId,
expiry ? new Date(expiry).toISOString() : undefined,
);
onAdd(created);
onOpenChange(false);
setName("");
setExpiry("");
toast.success(t("newUi.sidebar.userProfile.apiKeyCreated", { name }));
} catch {
toast.error(t("newUi.sidebar.userProfile.apiKeyCreateFailed"));
}
};
return (
);
}
function PasswordChangeSection({
showPassword,
setShowPassword,
onLogout,
}: {
showPassword: boolean;
setShowPassword: (v: boolean | ((prev: boolean) => boolean)) => void;
onLogout?: () => void;
}) {
const { t } = useTranslation();
const [currentPw, setCurrentPw] = useState("");
const [newPw, setNewPw] = useState("");
const [confirmPw, setConfirmPw] = useState("");
async function handleUpdate() {
if (!currentPw || !newPw) {
toast.error(t("newUi.sidebar.userProfile.passwordFieldsRequired"));
return;
}
if (newPw !== confirmPw) {
toast.error(t("newUi.sidebar.userProfile.passwordMismatch"));
return;
}
if (newPw.length < 6) {
toast.error(t("newUi.sidebar.userProfile.passwordTooShort"));
return;
}
try {
await changePassword(currentPw, newPw);
toast.success(t("newUi.sidebar.userProfile.passwordUpdated"));
setCurrentPw("");
setNewPw("");
setConfirmPw("");
onLogout?.();
} catch (e: unknown) {
toast.error(
apiErrorMessage(e, t("newUi.sidebar.userProfile.passwordUpdateFailed")),
);
}
}
return (
);
}
export function UserProfilePanel({
username,
onLogout,
onChangeServer,
userPrefs,
onPrefsChange,
}: {
username?: string;
onLogout?: () => void;
onChangeServer?: () => void;
userPrefs?: {
reopenTabsOnLogin: boolean;
storageMode?: string | null;
commandAutocomplete?: boolean | null;
commandPaletteEnabled?: boolean | null;
showHostTags?: boolean | null;
hostTrayOnClick?: boolean | null;
compactHostView?: boolean | null;
pinAppRail?: boolean | null;
expandAppRailOnHover?: boolean | null;
foldersCollapsed?: boolean | null;
confirmSnippetExecution?: boolean | null;
disableUpdateCheck?: boolean | null;
confirmTabClose?: boolean | null;
hiddenRailTabs?: string | null;
statusColorScheme?: string | null;
};
onPrefsChange?: (prefs: {
reopenTabsOnLogin?: boolean;
storageMode?: "local" | "cloud";
}) => void;
}) {
const { t } = useTranslation();
const themeLabel: Record = {
system: t("newUi.sidebar.userProfile.themeSystem"),
light: t("newUi.sidebar.userProfile.themeLight"),
dark: t("newUi.sidebar.userProfile.themeDark"),
dracula: t("newUi.sidebar.userProfile.themeDracula"),
catppuccin: t("newUi.sidebar.userProfile.themeCatppuccin"),
nord: t("newUi.sidebar.userProfile.themeNord"),
solarized: t("newUi.sidebar.userProfile.themeSolarized"),
"tokyo-night": t("newUi.sidebar.userProfile.themeTokyoNight"),
"one-dark": t("newUi.sidebar.userProfile.themeOneDark"),
gruvbox: t("newUi.sidebar.userProfile.themeGruvbox"),
};
const [openSection, setOpenSection] = useState(
"account",
);
// User info
const [userId, setUserId] = useState("");
const [userRole, setUserRole] = useState("");
const [authMethod, setAuthMethod] = useState("");
const [version, setVersion] = useState("");
const [versionStatus, setVersionStatus] = useState<
"up_to_date" | "requires_update" | "beta"
>("up_to_date");
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([]);
const [totpLoading, setTotpLoading] = useState(false);
const [showDisableTotp, setShowDisableTotp] = useState(false);
const [disableTotpInput, setDisableTotpInput] = useState("");
const [passkeys, setPasskeys] = useState([]);
const [passkeyLoading, setPasskeyLoading] = useState(false);
const [passkeyName, setPasskeyName] = useState("");
const [passkeyUserVerification, setPasskeyUserVerification] =
useState("preferred");
// Delete account
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deletePassword, setDeletePassword] = useState("");
const [deleteLoading, setDeleteLoading] = useState(false);
// UI state
const [showPassword, setShowPassword] = useState(false);
const [newKeyOpen, setNewKeyOpen] = useState(false);
const colorInputRef = useRef(null);
const { theme, setTheme } = useTheme();
const localSnapshot = useRef>({});
// Appearance state — initialized from localStorage
const [accentColor, setAccentColor] = useState(
() => localStorage.getItem("termix-accent") ?? "#f59145",
);
const [customColorInput, setCustomColorInput] = useState(
() => localStorage.getItem("termix-accent") ?? "#f59145",
);
const [fontSize, setFontSize] = useState(
() => (localStorage.getItem("termix-font-size") as FontSizeId) ?? "md",
);
const [language, setLanguage] = useState(() =>
normalizeLanguageCode(localStorage.getItem("i18nextLng")),
);
const [storageMode, setStorageMode] = useState<"local" | "cloud">(() =>
userPrefs?.storageMode === "cloud" ? "cloud" : "local",
);
useEffect(() => {
if (userPrefs?.storageMode) {
setStorageMode(userPrefs.storageMode === "cloud" ? "cloud" : "local");
}
}, [userPrefs?.storageMode]);
// Settings toggles — all backed by localStorage
const [commandAutocomplete, setCommandAutocomplete] = useState(
() => localStorage.getItem("commandAutocomplete") === "true",
);
const [terminalLinkClickBehavior, setTerminalLinkClickBehavior] = useState(
() => localStorage.getItem("terminalLinkClickBehavior") ?? "confirm",
);
const [commandPaletteEnabled, setCommandPaletteEnabled] = useState(() => {
const v = localStorage.getItem("commandPaletteShortcutEnabled");
return v !== null ? v === "true" : true;
});
const [showHostTags, setShowHostTags] = useState(() => {
const v = localStorage.getItem("showHostTags");
return v !== null ? v === "true" : true;
});
const [hostTrayOnClick, setHostTrayOnClick] = useState(
() => localStorage.getItem("hostTrayOnClick") === "true",
);
const [compactHostView, setCompactHostView] = useState(
() => localStorage.getItem("compactHostView") === "true",
);
const [statusColorScheme, setStatusColorScheme] = useState(
() => localStorage.getItem("statusColorScheme") ?? "accent",
);
const [pinAppRail, setPinAppRail] = useState(
() => localStorage.getItem("pinAppRail") === "true",
);
const [expandAppRailOnHover, setExpandAppRailOnHover] = useState(
() => localStorage.getItem("expandAppRailOnHover") !== "false",
);
const [foldersCollapsed, setFoldersCollapsed] = useState(
() => localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false",
);
const [confirmSnippetExecution, setConfirmSnippetExecution] = useState(
() => localStorage.getItem("confirmSnippetExecution") === "true",
);
const [disableUpdateCheck, setDisableUpdateCheck] = useState(
() => localStorage.getItem("disableUpdateCheck") === "true",
);
const [confirmTabClose, setConfirmTabClose] = useState(
() => localStorage.getItem("confirmTabClose") === "true",
);
const [hiddenRailTabs, setHiddenRailTabs] = useState>(() => {
try {
const stored = localStorage.getItem("hiddenRailTabs");
return stored ? new Set(JSON.parse(stored)) : new Set();
} catch {
return new Set();
}
});
// API keys
const [apiKeys, setApiKeys] = useState([]);
// RBAC roles
const [userRoles, setUserRoles] = useState([]);
useEffect(() => {
getUserInfo()
.then((info) => {
setUserId(info.userId);
setTotpEnabled(info.totp_enabled ?? false);
setIsOidc(info.is_oidc ?? false);
setIsDualAuth(info.is_dual_auth ?? false);
setUserRole(
info.is_admin
? t("newUi.sidebar.userProfile.roleAdministrator")
: t("newUi.sidebar.userProfile.roleUser"),
);
if (info.is_dual_auth) {
setAuthMethod(t("newUi.sidebar.userProfile.authMethodDual"));
} else if (info.is_oidc) {
setAuthMethod(t("newUi.sidebar.userProfile.authMethodOidc"));
} else {
setAuthMethod(t("newUi.sidebar.userProfile.authMethodLocal"));
}
getUserRoles(info.userId)
.then(({ roles }) => setUserRoles(roles ?? []))
.catch(() => {});
})
.catch(() => {});
getApiKeys()
.then(({ apiKeys: keys }) => setApiKeys(keys))
.catch(() => {});
listWebAuthnCredentials()
.then(({ credentials }) => setPasskeys(credentials ?? []))
.catch(() => {});
getVersionInfo()
.then((info) => {
setVersion(info.localVersion);
setVersionStatus(info.status ?? "up_to_date");
})
.catch(() => {});
}, [t]);
function saveToCloud(prefs: Parameters[0]) {
void saveUserPreferences(prefs).catch(() => {});
}
async function handleStorageModeChange(mode: "local" | "cloud") {
setStorageMode(mode);
onPrefsChange?.({ storageMode: mode });
if (mode === "cloud") {
// Snapshot current browser localStorage values so any tab can restore them later
const SNAPSHOT_KEYS = [
"termix-accent",
"termix-font-size",
"i18nextLng",
"commandAutocomplete",
"commandPaletteShortcutEnabled",
"showHostTags",
"hostTrayOnClick",
"compactHostView",
"pinAppRail",
"expandAppRailOnHover",
"defaultSnippetFoldersCollapsed",
"confirmSnippetExecution",
"disableUpdateCheck",
"confirmTabClose",
"hiddenRailTabs",
"statusColorScheme",
];
const snap: Record = { __theme: theme };
for (const key of SNAPSHOT_KEYS) snap[key] = localStorage.getItem(key);
localSnapshot.current = snap;
localStorage.setItem("termix-local-snapshot", JSON.stringify(snap));
try {
const prefs = await getUserPreferences();
if (prefs.theme) setTheme(prefs.theme as ThemeId);
if (prefs.fontSize) {
setFontSize(prefs.fontSize as FontSizeId);
applyFontSize(prefs.fontSize as FontSizeId);
}
if (prefs.accentColor) {
setAccentColor(prefs.accentColor);
setCustomColorInput(prefs.accentColor);
localStorage.setItem("termix-accent", prefs.accentColor);
applyAccentColor(prefs.accentColor);
}
if (prefs.language) {
const language = await changeAppLanguage(prefs.language);
setLanguage(language);
}
if (prefs.commandAutocomplete != null) {
setCommandAutocomplete(prefs.commandAutocomplete);
localStorage.setItem(
"commandAutocomplete",
String(prefs.commandAutocomplete),
);
}
if (prefs.commandPaletteEnabled != null) {
setCommandPaletteEnabled(prefs.commandPaletteEnabled);
localStorage.setItem(
"commandPaletteShortcutEnabled",
String(prefs.commandPaletteEnabled),
);
}
if (prefs.showHostTags != null) {
setShowHostTags(prefs.showHostTags);
localStorage.setItem("showHostTags", String(prefs.showHostTags));
window.dispatchEvent(new CustomEvent("showHostTagsChanged"));
}
if (prefs.hostTrayOnClick != null) {
setHostTrayOnClick(prefs.hostTrayOnClick);
localStorage.setItem(
"hostTrayOnClick",
String(prefs.hostTrayOnClick),
);
}
if (prefs.compactHostView != null) {
setCompactHostView(prefs.compactHostView);
localStorage.setItem(
"compactHostView",
String(prefs.compactHostView),
);
window.dispatchEvent(new CustomEvent("compactHostViewChanged"));
}
if (prefs.pinAppRail != null) {
setPinAppRail(prefs.pinAppRail);
localStorage.setItem("pinAppRail", String(prefs.pinAppRail));
window.dispatchEvent(new Event("pinAppRailChanged"));
}
if (prefs.expandAppRailOnHover != null) {
setExpandAppRailOnHover(prefs.expandAppRailOnHover);
localStorage.setItem(
"expandAppRailOnHover",
String(prefs.expandAppRailOnHover),
);
window.dispatchEvent(new Event("expandAppRailOnHoverChanged"));
}
if (prefs.foldersCollapsed != null) {
setFoldersCollapsed(prefs.foldersCollapsed);
localStorage.setItem(
"defaultSnippetFoldersCollapsed",
String(prefs.foldersCollapsed),
);
}
if (prefs.confirmSnippetExecution != null) {
setConfirmSnippetExecution(prefs.confirmSnippetExecution);
localStorage.setItem(
"confirmSnippetExecution",
String(prefs.confirmSnippetExecution),
);
}
if (prefs.disableUpdateCheck != null) {
setDisableUpdateCheck(prefs.disableUpdateCheck);
localStorage.setItem(
"disableUpdateCheck",
String(prefs.disableUpdateCheck),
);
}
if (prefs.confirmTabClose != null) {
setConfirmTabClose(prefs.confirmTabClose);
localStorage.setItem(
"confirmTabClose",
String(prefs.confirmTabClose),
);
}
if (prefs.hiddenRailTabs != null) {
const s = new Set(JSON.parse(prefs.hiddenRailTabs));
setHiddenRailTabs(s);
localStorage.setItem("hiddenRailTabs", prefs.hiddenRailTabs);
window.dispatchEvent(new CustomEvent("hiddenRailTabsChanged"));
}
if (prefs.statusColorScheme != null) {
setStatusColorScheme(prefs.statusColorScheme);
localStorage.setItem("statusColorScheme", prefs.statusColorScheme);
window.dispatchEvent(new CustomEvent("statusColorSchemeChanged"));
}
} catch {
// leave UI as-is on error
}
saveToCloud({ storageMode: "cloud" });
} else {
restoreLocalSnapshot();
saveToCloud({ storageMode: "local" });
}
}
function resetToDefaults() {
const DEFAULT_ACCENT = "#f59145";
setTheme("system");
setFontSize("md");
applyFontSize("md");
setAccentColor(DEFAULT_ACCENT);
setCustomColorInput(DEFAULT_ACCENT);
localStorage.setItem("termix-accent", DEFAULT_ACCENT);
applyAccentColor(DEFAULT_ACCENT);
setLanguage("en");
void changeAppLanguage("en");
setCommandAutocomplete(false);
localStorage.setItem("commandAutocomplete", "false");
setCommandPaletteEnabled(true);
localStorage.setItem("commandPaletteShortcutEnabled", "true");
setShowHostTags(true);
localStorage.setItem("showHostTags", "true");
window.dispatchEvent(new CustomEvent("showHostTagsChanged"));
setHostTrayOnClick(false);
localStorage.setItem("hostTrayOnClick", "false");
setCompactHostView(false);
localStorage.setItem("compactHostView", "false");
window.dispatchEvent(new CustomEvent("compactHostViewChanged"));
setPinAppRail(false);
localStorage.setItem("pinAppRail", "false");
window.dispatchEvent(new Event("pinAppRailChanged"));
setExpandAppRailOnHover(true);
localStorage.setItem("expandAppRailOnHover", "true");
window.dispatchEvent(new Event("expandAppRailOnHoverChanged"));
setFoldersCollapsed(true);
localStorage.removeItem("defaultSnippetFoldersCollapsed");
setConfirmSnippetExecution(false);
localStorage.setItem("confirmSnippetExecution", "false");
setDisableUpdateCheck(false);
localStorage.setItem("disableUpdateCheck", "false");
setConfirmTabClose(false);
localStorage.setItem("confirmTabClose", "false");
setHiddenRailTabs(new Set());
localStorage.removeItem("hiddenRailTabs");
window.dispatchEvent(new CustomEvent("hiddenRailTabsChanged"));
setStatusColorScheme("accent");
localStorage.setItem("statusColorScheme", "accent");
window.dispatchEvent(new CustomEvent("statusColorSchemeChanged"));
if (storageMode === "cloud") {
saveToCloud({
theme: "system",
fontSize: "md",
accentColor: DEFAULT_ACCENT,
language: "en",
commandAutocomplete: false,
commandPaletteEnabled: true,
showHostTags: true,
hostTrayOnClick: false,
compactHostView: false,
pinAppRail: false,
expandAppRailOnHover: true,
foldersCollapsed: true,
confirmSnippetExecution: false,
disableUpdateCheck: false,
confirmTabClose: false,
hiddenRailTabs: "[]",
statusColorScheme: "accent",
});
}
localSnapshot.current = {};
localStorage.removeItem("termix-local-snapshot");
toast.success(t("newUi.sidebar.userProfile.resetToDefaultsSuccess"));
}
function restoreLocalSnapshot() {
// Prefer the persisted snapshot (survives new tabs/reloads), fall back to in-memory ref.
// If neither exists there were never any browser values to restore, so use current localStorage.
const persisted = localStorage.getItem("termix-local-snapshot");
const snap: Record = persisted
? (JSON.parse(persisted) as Record)
: localSnapshot.current;
const hasSnap = Object.keys(snap).length > 0;
const restore = (key: string, fallback: string | null = null) =>
hasSnap
? snap[key] !== undefined
? snap[key]
: fallback
: (localStorage.getItem(key) ?? fallback);
const restoredTheme =
((hasSnap ? snap["__theme"] : theme) as ThemeId) ?? "system";
setTheme(restoredTheme);
const restoredFontSize =
(restore("termix-font-size", "md") as FontSizeId) ?? "md";
setFontSize(restoredFontSize);
applyFontSize(restoredFontSize);
const restoredAccent = restore("termix-accent", "#f59145") ?? "#f59145";
setAccentColor(restoredAccent);
setCustomColorInput(restoredAccent);
localStorage.setItem("termix-accent", restoredAccent);
applyAccentColor(restoredAccent);
const restoredLang = normalizeLanguageCode(restore("i18nextLng", "en"));
setLanguage(restoredLang);
void changeAppLanguage(restoredLang);
const restoredAutocomplete =
restore("commandAutocomplete", "false") === "true";
setCommandAutocomplete(restoredAutocomplete);
localStorage.setItem("commandAutocomplete", String(restoredAutocomplete));
const restoredPalette =
restore("commandPaletteShortcutEnabled", "true") !== "false";
setCommandPaletteEnabled(restoredPalette);
localStorage.setItem(
"commandPaletteShortcutEnabled",
String(restoredPalette),
);
const restoredHostTags = restore("showHostTags", "true") !== "false";
setShowHostTags(restoredHostTags);
localStorage.setItem("showHostTags", String(restoredHostTags));
window.dispatchEvent(new CustomEvent("showHostTagsChanged"));
const restoredTrayOnClick = restore("hostTrayOnClick", "false") === "true";
setHostTrayOnClick(restoredTrayOnClick);
localStorage.setItem("hostTrayOnClick", String(restoredTrayOnClick));
const restoredCompactHostView =
restore("compactHostView", "false") === "true";
setCompactHostView(restoredCompactHostView);
localStorage.setItem("compactHostView", String(restoredCompactHostView));
window.dispatchEvent(new CustomEvent("compactHostViewChanged"));
const restoredPinRail = restore("pinAppRail", "false") === "true";
setPinAppRail(restoredPinRail);
localStorage.setItem("pinAppRail", String(restoredPinRail));
window.dispatchEvent(new Event("pinAppRailChanged"));
const restoredExpandRailOnHover =
restore("expandAppRailOnHover", "true") !== "false";
setExpandAppRailOnHover(restoredExpandRailOnHover);
localStorage.setItem(
"expandAppRailOnHover",
String(restoredExpandRailOnHover),
);
window.dispatchEvent(new Event("expandAppRailOnHoverChanged"));
const restoredFolders =
restore("defaultSnippetFoldersCollapsed", null) !== "false";
setFoldersCollapsed(restoredFolders);
const snapFolders = hasSnap
? snap["defaultSnippetFoldersCollapsed"]
: localStorage.getItem("defaultSnippetFoldersCollapsed");
if (snapFolders == null) {
localStorage.removeItem("defaultSnippetFoldersCollapsed");
} else {
localStorage.setItem("defaultSnippetFoldersCollapsed", snapFolders);
}
const restoredConfirmSnippet =
restore("confirmSnippetExecution", "false") === "true";
setConfirmSnippetExecution(restoredConfirmSnippet);
localStorage.setItem(
"confirmSnippetExecution",
String(restoredConfirmSnippet),
);
const restoredUpdateCheck =
restore("disableUpdateCheck", "false") === "true";
setDisableUpdateCheck(restoredUpdateCheck);
localStorage.setItem("disableUpdateCheck", String(restoredUpdateCheck));
const restoredConfirmTab = restore("confirmTabClose", "false") === "true";
setConfirmTabClose(restoredConfirmTab);
localStorage.setItem("confirmTabClose", String(restoredConfirmTab));
const restoredHiddenRaw = hasSnap
? snap["hiddenRailTabs"]
: localStorage.getItem("hiddenRailTabs");
if (restoredHiddenRaw == null) {
setHiddenRailTabs(new Set());
localStorage.removeItem("hiddenRailTabs");
} else {
try {
setHiddenRailTabs(new Set(JSON.parse(restoredHiddenRaw)));
} catch {
setHiddenRailTabs(new Set());
}
localStorage.setItem("hiddenRailTabs", restoredHiddenRaw);
}
window.dispatchEvent(new CustomEvent("hiddenRailTabsChanged"));
const restoredStatusScheme =
restore("statusColorScheme", "accent") ?? "accent";
setStatusColorScheme(restoredStatusScheme);
localStorage.setItem("statusColorScheme", restoredStatusScheme);
window.dispatchEvent(new CustomEvent("statusColorSchemeChanged"));
localStorage.removeItem("termix-local-snapshot");
localSnapshot.current = {};
}
function handleThemeChange(id: ThemeId) {
setTheme(id);
if (storageMode === "cloud") saveToCloud({ theme: id });
}
function handleAccentChange(value: string) {
setAccentColor(value);
setCustomColorInput(value);
localStorage.setItem("termix-accent", value);
applyAccentColor(value);
if (storageMode === "cloud") saveToCloud({ accentColor: value });
}
function handleFontSizeChange(id: FontSizeId) {
setFontSize(id);
applyFontSize(id);
if (storageMode === "cloud") saveToCloud({ fontSize: id });
}
function handleLanguageChange(code: string) {
void changeAppLanguage(code)
.then((language) => {
setLanguage(language);
if (storageMode === "cloud") saveToCloud({ language });
})
.catch(() => {});
}
function toggle(id: UserProfileSection) {
setOpenSection((prev) => (prev === id ? null : id));
}
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 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);
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);
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"));
return;
}
setDeleteLoading(true);
try {
await deleteAccount(deletePassword);
await logoutUser().catch(() => {});
window.location.reload();
} catch (e: unknown) {
toast.error(
apiErrorMessage(e, t("newUi.sidebar.userProfile.deleteFailed")),
);
setDeleteLoading(false);
}
}
const canChangePasword = !isOidc || isDualAuth;
return (
setApiKeys((prev) => [key, ...prev])}
userId={userId}
/>
{/* Storage mode toggle */}
{t("newUi.sidebar.userProfile.storageModeSwitch")}
{t("newUi.sidebar.userProfile.storageModeDescription")}
{/* Account */}
}
open={openSection === "account"}
onToggle={() => toggle("account")}
>
{t("newUi.sidebar.userProfile.usernameLabel")}
{username ?? "—"}
{t("newUi.sidebar.userProfile.roleLabel")}
{userRole || "—"}
{userRoles.map((r) => (
{r.roleDisplayName}
))}
{t("newUi.sidebar.userProfile.authMethodLabel")}
{authMethod || "—"}
{t("newUi.sidebar.userProfile.twoFaLabel")}
{totpEnabled ? (
<>
{t("newUi.sidebar.userProfile.twoFaOn")}
>
) : (
{t("newUi.sidebar.userProfile.twoFaOff")}
)}
{t("newUi.sidebar.userProfile.versionLabel")}
{version ? `v${version}` : "—"}
{versionStatus === "beta"
? t("dashboard.beta").toUpperCase()
: versionStatus === "requires_update"
? t("dashboard.updateAvailable").toUpperCase()
: t("dashboardTab.stable")}
{isElectron() && onChangeServer && (
{t("serverConfig.changeServer")}
{t("newUi.sidebar.userProfile.changeServerDescription")}
)}
{t("newUi.sidebar.userProfile.deleteAccount")}
{t("newUi.sidebar.userProfile.deleteAccountDescription")}
{/* Appearance */}
}
open={openSection === "appearance"}
onToggle={() => toggle("appearance")}
>
{t("newUi.sidebar.userProfile.languageLabel")}
{t("newUi.sidebar.userProfile.themeLabel")}
{THEMES.filter((th) => th.id !== "system").map((th) => (
{t("newUi.sidebar.userProfile.fontSizeLabel")}
{FONT_SIZES.map((fs) => (
))}
{t("newUi.sidebar.userProfile.settingsTerminal")}
{
setCommandAutocomplete(v);
localStorage.setItem("commandAutocomplete", v.toString());
if (storageMode === "cloud")
saveToCloud({ commandAutocomplete: v });
}}
/>
{t("newUi.sidebar.userProfile.terminalLinkBehavior")}
{t("newUi.sidebar.userProfile.terminalLinkBehaviorDesc")}
{
setCommandPaletteEnabled(v);
localStorage.setItem(
"commandPaletteShortcutEnabled",
v.toString(),
);
window.dispatchEvent(
new Event("commandPaletteShortcutEnabledChanged"),
);
if (storageMode === "cloud")
saveToCloud({ commandPaletteEnabled: v });
}}
/>
{
onPrefsChange?.({ reopenTabsOnLogin: v });
import("@/main-axios").then(({ saveUserPreferences }) => {
saveUserPreferences({ reopenTabsOnLogin: v }).catch(
() => {},
);
});
}}
/>
{
setConfirmTabClose(v);
localStorage.setItem("confirmTabClose", v.toString());
if (storageMode === "cloud")
saveToCloud({ confirmTabClose: v });
}}
/>
{t("newUi.sidebar.userProfile.settingsSidebar")}
{
setShowHostTags(v);
localStorage.setItem("showHostTags", v.toString());
window.dispatchEvent(new Event("showHostTagsChanged"));
if (storageMode === "cloud") saveToCloud({ showHostTags: v });
}}
/>
{
setHostTrayOnClick(v);
localStorage.setItem("hostTrayOnClick", v.toString());
window.dispatchEvent(new Event("hostTrayOnClickChanged"));
if (storageMode === "cloud")
saveToCloud({ hostTrayOnClick: v });
}}
/>
{
setCompactHostView(v);
localStorage.setItem("compactHostView", v.toString());
window.dispatchEvent(new Event("compactHostViewChanged"));
if (storageMode === "cloud")
saveToCloud({ compactHostView: v });
}}
/>
{
const scheme = v ? "status" : "accent";
setStatusColorScheme(scheme);
localStorage.setItem("statusColorScheme", scheme);
window.dispatchEvent(new Event("statusColorSchemeChanged"));
if (storageMode === "cloud")
saveToCloud({ statusColorScheme: scheme });
}}
/>
{
setPinAppRail(v);
localStorage.setItem("pinAppRail", v.toString());
window.dispatchEvent(new Event("pinAppRailChanged"));
if (storageMode === "cloud") saveToCloud({ pinAppRail: v });
}}
/>
{
setExpandAppRailOnHover(v);
localStorage.setItem("expandAppRailOnHover", v.toString());
window.dispatchEvent(
new Event("expandAppRailOnHoverChanged"),
);
if (storageMode === "cloud")
saveToCloud({ expandAppRailOnHover: v });
}}
/>
{t("newUi.sidebar.userProfile.settingsNavigation")}
{t("newUi.sidebar.userProfile.navigationTabsDesc")}
{(
[
{
id: "hosts",
icon:
,
label: t("nav.hosts"),
},
{
id: "credentials",
icon:
,
label: t("nav.credentials"),
},
{
id: "connections",
icon:
,
label: t("nav.connections"),
},
{
id: "quick-connect",
icon:
,
label: t("nav.quickConnect"),
},
{
id: "serial",
icon:
,
label: t("nav.serial"),
},
{
id: "ssh-tools",
icon:
,
label: t("nav.sshTools"),
},
{
id: "snippets",
icon:
,
label: t("nav.snippets"),
},
{
id: "history",
icon:
,
label: t("nav.history"),
},
{
id: "session-logs",
icon:
,
label: t("nav.sessionLogs"),
},
{
id: "split-screen",
icon:
,
label: t("nav.splitScreen"),
},
{
id: "network_graph",
icon:
,
label: t("nav.networkGraph"),
},
] as { id: string; icon: React.ReactNode; label: string }[]
).map((tab) => (
{tab.icon}
{tab.label}
{
const next = new Set(hiddenRailTabs);
if (visible) next.delete(tab.id);
else next.add(tab.id);
setHiddenRailTabs(next);
const serialized = JSON.stringify([...next]);
localStorage.setItem("hiddenRailTabs", serialized);
window.dispatchEvent(new Event("hiddenRailTabsChanged"));
if (storageMode === "cloud")
saveToCloud({ hiddenRailTabs: serialized });
}}
/>
))}
{t("newUi.sidebar.userProfile.settingsSnippets")}
{
setFoldersCollapsed(v);
localStorage.setItem(
"defaultSnippetFoldersCollapsed",
v.toString(),
);
window.dispatchEvent(
new Event("defaultSnippetFoldersCollapsedChanged"),
);
if (storageMode === "cloud")
saveToCloud({ foldersCollapsed: v });
}}
/>
{
setConfirmSnippetExecution(v);
localStorage.setItem("confirmSnippetExecution", v.toString());
if (storageMode === "cloud")
saveToCloud({ confirmSnippetExecution: v });
}}
/>
{t("newUi.sidebar.userProfile.settingsUpdates")}
{
setDisableUpdateCheck(v);
localStorage.setItem("disableUpdateCheck", v.toString());
if (storageMode === "cloud")
saveToCloud({ disableUpdateCheck: v });
}}
/>
{/* Security */}
}
open={openSection === "security"}
onToggle={() => toggle("security")}
>
{totpEnabled
? t("newUi.sidebar.userProfile.totpEnabled")
: t("newUi.sidebar.userProfile.totpDisabled")}
{totpEnabled ? (
) : (
)}
{/* Disable TOTP form */}
{totpEnabled && showDisableTotp && (
)}
{/* TOTP setup: scan QR */}
{!totpEnabled && totpStep === "setup" && (
{t("newUi.sidebar.userProfile.setupTotp")}
{totpQrCode ? (
) : (
{t("newUi.sidebar.userProfile.qrCode")}
)}
{totpSecret}
{t("newUi.sidebar.userProfile.totpInstructions")}
)}
{/* TOTP setup: verify code */}
{!totpEnabled && totpStep === "verify" && (
{t("newUi.sidebar.userProfile.totpVerifyTitle")}
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}
/>
)}
{/* TOTP setup: backup codes */}
{totpStep === "backup" && totpBackupCodes.length > 0 && (
{t("newUi.sidebar.userProfile.totpBackupTitle")}
{totpBackupCodes.map((code) => (
{code}
))}
)}
{t("newUi.sidebar.userProfile.passkeys")}
{t("newUi.sidebar.userProfile.passkeysDesc")}
setPasskeyName(e.target.value)}
className="h-8 text-xs"
disabled={passkeyLoading}
/>
{passkeys.length === 0 ? (
{t("newUi.sidebar.userProfile.noPasskeys")}
) : (
passkeys.map((passkey) => (
{passkey.name}
{passkey.deviceType || "unknown"}
{passkey.backedUp ? " / synced" : ""}
))
)}
{canChangePasword && (
)}
{/* API Keys */}
}
open={openSection === "api-keys"}
onToggle={() => toggle("api-keys")}
>
{t("newUi.sidebar.userProfile.apiKeyCount", {
count: apiKeys.length,
})}
{apiKeys.length === 0 ? (
{t("newUi.sidebar.userProfile.noApiKeys")}
) : (
apiKeys.map((key) => (
{key.name}
{key.isActive && (
{t("newUi.sidebar.userProfile.apiKeyActive")}
)}
{key.tokenPrefix}…
{t("newUi.sidebar.userProfile.apiKeyUser")}:{" "}
{key.username}
{key.expiresAt && (
{t("newUi.sidebar.userProfile.apiKeyExpires")}:{" "}
{new Date(key.expiresAt).toLocaleDateString()}
)}
))
)}
{t("newUi.sidebar.userProfile.apiKeyUsageHint")}{" "}
Authorization: Bearer
{" "}
{t("newUi.sidebar.userProfile.apiKeyUsageHintHeader")}
{t("newUi.sidebar.userProfile.apiKeyPermissionsHint")}
{isElectron() && (
}
open={openSection === "c2s-tunnels"}
onToggle={() => toggle("c2s-tunnels")}
>
)}
{/* Delete account dialog */}
);
}