import { useState, useRef, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { copyToClipboard } from "@/lib/clipboard"; import { getUserInfo, getRemoteSyncUserInfo, getApiKeys, createApiKey, deleteApiKey, changePassword, deleteAccount, logoutUser, setupTOTP, enableTOTP, disableTOTP, getVersionInfo, releaseUrlFrom, getUserRoles, saveUserPreferences, getUserPreferences, } from "@/main-axios"; 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"; import { RemoteSyncPanel } from "@/settings/RemoteSyncPanel.tsx"; import { shouldForceLocalPreferenceStorage } from "@/settings/remote-sync-state"; import { C2STunnelPresetManager } from "@/user/C2STunnelPresetManager"; import { Button } from "@/components/button"; import { Input } from "@/components/input"; import { VersionBadge } from "@/components/version-badge"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from "@/components/dialog"; import { Activity, AlertCircle, CheckCircle2, ChevronDown, Copy, Database, Eye, EyeOff, KeyRound, LayoutTemplate, Network, Palette, Plus, RotateCcw, Shield, ShieldCheck, Trash2, Type, User, X, } from "lucide-react"; import { SettingRow, FakeSwitch } from "@/components/section-card"; import { visibleRailItems } from "./rail-items"; import { InterfacePresetSettings } from "./InterfacePresetSettings"; import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext"; import { KeybindingsDialog } from "./KeybindingsDialog"; import { ACCENT_PRESET_COLORS, applyAccentColor, applyFontSize, applyUiFont, FONT_SIZES, UI_FONTS, } from "@/lib/theme"; import type { ApiKey } from "@/main-axios"; import { useTheme } from "@/components/theme-provider"; import type { FontSizeId, ThemeId, UiFontId } from "@/types/ui-types"; import { toast } from "sonner"; import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n"; import { clearLocalAdaptivePreferences } from "@/lib/local-adaptive-preferences"; import { ConnectionDefaultsSettings } from "./ConnectionDefaultsSettings"; type UserProfileSection = | "account" | "interface" | "appearance" | "security" | "api-keys" | "data" | "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" }, ]; export function AccordionSection({ id, label, icon, open, onToggle, hidden = false, children, }: { id: string; label: string; icon: React.ReactNode; open: boolean; onToggle: () => void; hidden?: boolean; children: React.ReactNode; }) { if (hidden) return null; 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; }; export 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 [createdToken, setCreatedToken] = useState(null); const [creating, setCreating] = useState(false); const close = () => { setName(""); setExpiry(""); setCreatedToken(null); onOpenChange(false); }; const handleCreate = async () => { if (!name.trim()) { toast.error(t("newUi.sidebar.userProfile.apiKeyNameRequired")); return; } if (creating) return; setCreating(true); try { const created = await createApiKey( name.trim(), userId, expiry ? new Date(expiry).toISOString() : undefined, ); onAdd(created); setCreatedToken(created.token); toast.success(t("newUi.sidebar.userProfile.apiKeyCreated", { name })); } catch { toast.error(t("newUi.sidebar.userProfile.apiKeyCreateFailed")); } finally { setCreating(false); } }; return ( { if (nextOpen || !createdToken) onOpenChange(nextOpen); }} >
{t("newUi.sidebar.userProfile.createApiKeyTitle")} {t("newUi.sidebar.userProfile.createApiKeyDescription")}
{createdToken ? (
{t("newUi.sidebar.userProfile.apiKeyCreatedWarning")}
{createdToken}
) : (
setName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleCreate()} className="rounded-none bg-muted/50 border-border text-sm h-9" />
setExpiry(e.target.value)} className="rounded-none bg-muted/50 border-border text-sm h-9" />
)} {createdToken ? ( ) : ( <> )}
); } 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 (
{t("newUi.sidebar.userProfile.changePassword")}
setCurrentPw(e.target.value)} className="pr-9 text-sm" />
setNewPw(e.target.value)} className="text-sm" />
setConfirmPw(e.target.value)} className="text-sm" />
); } export function UserProfilePanel({ username, onLogout, userPrefs, onPrefsChange, remoteSyncInitialServerUrl, }: { username?: string; onLogout?: () => void; remoteSyncInitialServerUrl?: string; userPrefs?: { reopenTabsOnLogin: boolean; storageMode?: string | null; commandAutocomplete?: boolean | null; commandPaletteEnabled?: boolean | null; aiAssistantEnabled?: boolean | null; aiReadOnlyCommands?: 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 [openSections, setOpenSections] = useState>( () => new Set(["account"]), ); // User info const [userId, setUserId] = useState(""); const [accountUsername, setAccountUsername] = useState(username ?? ""); const [accountTotpEnabled, setAccountTotpEnabled] = useState(false); 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 [releaseUrl, setReleaseUrl] = useState(""); 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 [showAddTotp, setShowAddTotp] = useState(false); const [addTotpInput, setAddTotpInput] = useState(""); const [addingTotpAuthenticator, setAddingTotpAuthenticator] = useState(false); 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); // Data export/import const [importFile, setImportFile] = useState(null); const [exportLoading, setExportLoading] = useState(false); const [importLoading, setImportLoading] = 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 [uiFont, setUiFont] = useState( () => (localStorage.getItem("termix-ui-font") as UiFontId) ?? "jetbrains-mono", ); 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]); // Remote sync is not connected by default on the desktop app (it's an // opt-in feature configured from this same panel). "cloud" storage mode // and Termix ID both assume a real multi-device server account, so they // stay hidden/forced-off until the user actually connects one. const [isRemoteSyncConnected, setIsRemoteSyncConnected] = useState< boolean | null >(() => (isElectron() ? null : true)); useEffect(() => { if (!isElectron()) return; let cancelled = false; const refreshSyncStatus = () => { window.electronAPI ?.invoke?.("get-remote-sync-config") .then((config) => { if (!cancelled) { setIsRemoteSyncConnected( !!(config as { serverUrl?: string } | null)?.serverUrl, ); } }) .catch(() => {}); }; refreshSyncStatus(); const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.(() => refreshSyncStatus(), ); return () => { cancelled = true; unsubscribe?.(); }; }, []); useEffect(() => { if ( shouldForceLocalPreferenceStorage( isElectron(), isRemoteSyncConnected, storageMode, ) ) { setStorageMode("local"); onPrefsChange?.({ storageMode: "local" }); } }, [isRemoteSyncConnected, storageMode, onPrefsChange]); // 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 [keybindingsDialogOpen, setKeybindingsDialogOpen] = useState(false); const [aiAssistantEnabled, setAiAssistantEnabled] = useState(false); const [aiReadOnlyCommands, setAiReadOnlyCommands] = useState(false); // Null until the status call answers; the whole section stays hidden while // unknown so a user who cannot have the feature never sees it mentioned. const [aiGloballyEnabled, setAiGloballyEnabled] = useState( null, ); // Sidebar display customization (density, tags, tray trigger, status // colors) now lives in the dedicated Customize Sidebar panel opened from // the Hosts toolbar, not here -- resetToDefaults still needs write access. const { update: updateSidebarPrefs } = useHostSidebarPreferences(); const uiPrefs = useUiPreferencesContext(); useEffect(() => { let cancelled = false; import("@/api/ai-api") .then(({ getAiStatus }) => getAiStatus()) .then((status) => { if (cancelled) return; setAiGloballyEnabled(status.globallyEnabled); setAiAssistantEnabled(status.enabled); setAiReadOnlyCommands(status.allowReadOnlyCommands); }) .catch(() => { if (!cancelled) setAiGloballyEnabled(false); }); return () => { cancelled = true; }; }, []); /** * Turning the assistant off also hides its rail tab, so declining removes * the feature from view entirely rather than leaving an inert entry behind. */ const applyAiEnabled = (enabled: boolean) => { setAiAssistantEnabled(enabled); const hidden = new Set( JSON.parse(localStorage.getItem("hiddenRailTabs") ?? "[]"), ); if (enabled) hidden.delete("ai"); else hidden.add("ai"); const serialized = JSON.stringify([...hidden]); localStorage.setItem("hiddenRailTabs", serialized); setHiddenRailTabs(hidden); window.dispatchEvent(new Event("hiddenRailTabsChanged")); if (storageMode === "cloud") { saveToCloud({ aiAssistantEnabled: enabled, hiddenRailTabs: serialized }); } }; const [pinAppRail, setPinAppRail] = useState(() => readRailPreference("pinAppRail"), ); const [expandAppRailOnHover, setExpandAppRailOnHover] = useState(() => readRailPreference("expandAppRailOnHover"), ); // Read values are unused now that the Snippets settings UI lives in // SnippetsPanel.tsx; the setters still back the cloud-sync/reset/snapshot // machinery for these two localStorage-backed prefs below. 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(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(); const info = remoteInfo ?? localInfo; setAccountUsername(info.username); setAccountTotpEnabled(info.totp_enabled ?? 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")); } if (remoteInfo) { setUserRoles(remoteInfo.roles ?? []); } else { getUserRoles(localInfo.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"); setReleaseUrl(releaseUrlFrom(info)); }) .catch(() => {}); }, [t]); // The rail can toggle these from its right-click menu while this panel is // mounted, so mirror the change back into the switches instead of letting // them drift from the stored value. useEffect(() => { const pinHandler = () => setPinAppRail(readRailPreference("pinAppRail")); const hoverHandler = () => setExpandAppRailOnHover(readRailPreference("expandAppRailOnHover")); window.addEventListener("pinAppRailChanged", pinHandler); window.addEventListener("expandAppRailOnHoverChanged", hoverHandler); return () => { window.removeEventListener("pinAppRailChanged", pinHandler); window.removeEventListener("expandAppRailOnHoverChanged", hoverHandler); }; }, []); 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", "termix-ui-font", "i18nextLng", "commandAutocomplete", "commandPaletteShortcutEnabled", "showHostTags", "hostTrayOnClick", "compactHostView", "pinAppRail", "expandAppRailOnHover", "defaultSnippetFoldersCollapsed", "snippetShowCommands", "confirmSnippetExecution", "disableUpdateCheck", "confirmTabClose", "hiddenRailTabs", "statusColorScheme", "uiPreferences", // Layout/view keys that were never snapshotted, so switching to cloud // and back silently reset them. "dashboardTab.slots", "dashboardTab.mainWidthPct", "termix-terminal-toolbar-density", "fileManagerViewMode", ]; 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.aiAssistantEnabled != null) { setAiAssistantEnabled(prefs.aiAssistantEnabled); } if (prefs.aiReadOnlyCommands != null) { setAiReadOnlyCommands(prefs.aiReadOnlyCommands); } // showHostTags/hostTrayOnClick/compactHostView/statusColorScheme are // no longer restored here -- useHostSidebarPreferences independently // fetches its own authoritative copy from /host-sidebar/preferences. 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")); } } catch { // leave UI as-is on error } saveToCloud({ storageMode: "cloud" }); } else { restoreLocalSnapshot(); saveToCloud({ storageMode: "local" }); } } function resetToDefaults() { clearLocalAdaptivePreferences(); const DEFAULT_ACCENT = "#f59145"; setTheme("system"); setFontSize("md"); applyFontSize("md"); setUiFont("jetbrains-mono"); applyUiFont("jetbrains-mono"); 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"); updateSidebarPrefs((prev) => ({ ...prev, display: { ...prev.display, showTags: true, trayTrigger: "hover", density: "comfortable", statusColorScheme: "accent", }, })); 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")); // Back to the preset that matches the pre-preset UI, with nothing pinned. uiPrefs?.setPreset("balanced"); uiPrefs?.clearAllOverrides(); if (storageMode === "cloud") { saveToCloud({ theme: "system", fontSize: "md", accentColor: DEFAULT_ACCENT, language: "en", commandAutocomplete: false, commandPaletteEnabled: true, pinAppRail: false, expandAppRailOnHover: true, foldersCollapsed: true, confirmSnippetExecution: false, disableUpdateCheck: false, confirmTabClose: false, hiddenRailTabs: "[]", }); } 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 restoredUiFont = (restore("termix-ui-font", "jetbrains-mono") as UiFontId) ?? "jetbrains-mono"; setUiFont(restoredUiFont); applyUiFont(restoredUiFont); 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), ); // showHostTags/hostTrayOnClick/compactHostView/statusColorScheme are no // longer part of this snapshot -- useHostSidebarPreferences keeps its own // localStorage cache independent of storageMode switches. 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")); 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 handleUiFontChange(id: UiFontId) { setUiFont(id); applyUiFont(id); } function handleLanguageChange(code: string) { void changeAppLanguage(code) .then((language) => { setLanguage(language); if (storageMode === "cloud") saveToCloud({ language }); }) .catch(() => {}); } function toggle(id: UserProfileSection) { setOpenSections((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } 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")); 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); } } async function handleExportData() { setExportLoading(true); try { const apiUrl = getDatabaseTransferUrl("export", { electron: isElectron(), configuredServerUrl: null, location: window.location, }); const response = await fetch(apiUrl, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({}), }); if (response.ok) { const blob = await response.blob(); const contentDisposition = response.headers.get("content-disposition"); const filename = contentDisposition?.match(/filename="([^"]+)"/)?.[1] || "termix-export.sqlite"; const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); toast.success(t("newUi.sidebar.userProfile.exportSuccess")); } else { const err = await response.json().catch(() => ({})); toast.error(err.error || t("newUi.sidebar.userProfile.exportFailed")); } } catch { toast.error(t("newUi.sidebar.userProfile.exportFailed")); } finally { setExportLoading(false); } } async function handleImportData() { if (!importFile) { toast.error(t("newUi.sidebar.userProfile.importSelectFile")); return; } setImportLoading(true); try { const apiUrl = getDatabaseTransferUrl("import", { electron: isElectron(), configuredServerUrl: null, location: window.location, }); const formData = new FormData(); formData.append("file", importFile); const response = await fetch(apiUrl, { method: "POST", credentials: "include", body: formData, }); if (response.ok) { const result = await response.json(); if (result.success) { const s = result.summary; const total = (s.sshHostsImported || 0) + (s.sshCredentialsImported || 0) + (s.fileManagerItemsImported || 0) + (s.dismissedAlertsImported || 0) + (s.settingsImported || 0); toast.success( t("newUi.sidebar.userProfile.importCompleted", { total, skipped: s.skippedItems || 0, }), ); setImportFile(null); setTimeout(() => window.location.reload(), 1500); } else { toast.error( t("newUi.sidebar.userProfile.importFailed", { error: result.summary?.errors?.join(", ") || "Unknown error", }), ); } } else { const err = await response.json().catch(() => ({})); toast.error( t("newUi.sidebar.userProfile.importFailed", { error: err.error || "Unknown error", }), ); } } catch { toast.error( t("newUi.sidebar.userProfile.importFailed", { error: "Unknown error", }), ); } finally { setImportLoading(false); } } const canChangePasword = !isOidc || isDualAuth; return (
setApiKeys((prev) => [key as ApiKey, ...prev])} userId={userId} /> {/* Donate banner */}
{t("newUi.sidebar.userProfile.donateTitle")}

{t("newUi.sidebar.userProfile.donateDescription")}

{t("newUi.sidebar.userProfile.donateMilestones")}

{t("newUi.sidebar.userProfile.donateButton")}
{/* Storage mode toggle — only meaningful once a remote server is connected; with no sync there's nowhere for "cloud" to sync to, so this stays forced to local storage and hidden. */} {(!isElectron() || isRemoteSyncConnected === true) && (
{t("newUi.sidebar.userProfile.storageModeSwitch")}

{t("newUi.sidebar.userProfile.storageModeDescription")}

)} {/* Account */} } open={openSections.has("account")} onToggle={() => toggle("account")} >
{isElectron() ? (
{t("newUi.sidebar.userProfile.desktopProfileTitle")}

{t("newUi.sidebar.userProfile.desktopProfileDescription")}

) : (
{t("newUi.sidebar.userProfile.usernameLabel")} {accountUsername || "—"}
{t("newUi.sidebar.userProfile.roleLabel")}
{userRole || "—"} {userRoles.map((r) => ( {r.roleDisplayName} ))}
{t("newUi.sidebar.userProfile.authMethodLabel")} {authMethod || "—"}
{t("newUi.sidebar.userProfile.twoFaLabel")} {accountTotpEnabled ? ( <> {t("newUi.sidebar.userProfile.twoFaOn")} ) : ( {t("newUi.sidebar.userProfile.twoFaOff")} )}
)}
{t("newUi.sidebar.userProfile.versionLabel")}
{version ? `v${version}` : "—"}
{t("newUi.sidebar.userProfile.betaProgramTitle")} {t("newUi.sidebar.userProfile.betaProgramDescription")}{" "} {t("newUi.sidebar.userProfile.betaProgramFeedback")}
{t("newUi.sidebar.userProfile.betaProgramLearnMore")}
{t("newUi.sidebar.userProfile.cliTitle")} {t("newUi.sidebar.userProfile.cliDescription")}
{t("newUi.sidebar.userProfile.cliLearnMore")}
{isElectron() && (
)} {!isElectron() && (
{t("newUi.sidebar.userProfile.deleteAccount")} {t("newUi.sidebar.userProfile.deleteAccountDescription")}
)}
{/* Interface */} } open={openSections.has("interface")} onToggle={() => toggle("interface")} >
window.dispatchEvent(new Event("termix:open-onboarding")) } />
{/* Appearance */} } open={openSections.has("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.interfaceFontLabel")} {t("newUi.sidebar.userProfile.interfaceFontDescription")}
{t("newUi.sidebar.userProfile.fontSizeLabel")}
{FONT_SIZES.map((fs) => ( ))}
{t("newUi.sidebar.userProfile.accentColorLabel")}
{ACCENT_PRESET_COLORS.map((ac) => (
{t("newUi.sidebar.userProfile.settingsTerminal")} { setCommandAutocomplete(v); localStorage.setItem("commandAutocomplete", v.toString()); if (storageMode === "cloud") saveToCloud({ commandAutocomplete: v }); }} />
{t("newUi.sidebar.userProfile.localEcho")} {t("newUi.sidebar.userProfile.localEchoDesc")}
{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 }); }} /> {aiGloballyEnabled && ( <> {aiAssistantEnabled && ( { setAiReadOnlyCommands(v); if (storageMode === "cloud") saveToCloud({ aiReadOnlyCommands: 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.settingsAppRail")}

{t("newUi.sidebar.userProfile.sidebarSettingsMoved")}

{ setPinAppRail(v); setRailPreference("pinAppRail", v); }} /> { setExpandAppRailOnHover(v); setRailPreference("expandAppRailOnHover", v); }} />
{t("newUi.sidebar.userProfile.settingsNavigation")}

{t("newUi.sidebar.userProfile.navigationTabsDesc")}

{visibleRailItems() .filter((tab) => tab.id !== "ai" || aiGloballyEnabled) .map((tab) => (
{t(tab.labelKey)} { 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.settingsUpdates")} { setDisableUpdateCheck(v); localStorage.setItem("disableUpdateCheck", v.toString()); if (storageMode === "cloud") saveToCloud({ disableUpdateCheck: v }); }} />
{/* The embedded desktop backend auto-authenticates its machine-local profile, so server login controls would imply protection they do not provide. Remote Sync owns its separate account UI above. */} {/* API Keys */} } open={openSections.has("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")}

} open={openSections.has("data")} onToggle={() => toggle("data")} >
{t("newUi.sidebar.userProfile.exportData")} {t("newUi.sidebar.userProfile.exportDataDesc")}
{t("newUi.sidebar.userProfile.importData")} {importFile ? t("newUi.sidebar.userProfile.importDataSelected", { name: importFile.name, }) : t("newUi.sidebar.userProfile.importDataDesc")}
setImportFile(e.target.files?.[0] ?? null)} className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
{importFile && ( )}
{isElectron() && ( } open={openSections.has("c2s-tunnels")} onToggle={() => toggle("c2s-tunnels")} > )} {/* Delete account dialog */} { setShowDeleteConfirm(open); if (!open) setDeletePassword(""); }} > {t("newUi.sidebar.userProfile.deleteAccount")} {t("newUi.sidebar.userProfile.deleteAccountPermanent")}
{t("newUi.sidebar.userProfile.deleteAccountWarning")}
setDeletePassword(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleDeleteAccount()} />
); }