);
}
export function UserProfilePanel({
username,
onLogout,
userPrefs,
onPrefsChange,
section,
onOpenSettingsPage,
}: {
/** Shows just this section, flat, as a Settings page. */
section?: string;
/** Moves the Settings tab to another page. */
onOpenSettingsPage?: (id: string) => void;
username?: string;
onLogout?: () => void;
userPrefs?: {
reopenTabsOnLogin: boolean;
storageMode?: string | null;
commandPaletteEnabled?: boolean | null;
showHostTags?: boolean | null;
hostTrayOnClick?: boolean | null;
compactHostView?: boolean | null;
pinAppRail?: boolean | null;
expandAppRailOnHover?: boolean | null;
showPinAppRailButton?: 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 confirm = useConfirm();
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"]),
);
const featureSettings = useFeatureSettings("user");
// 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" | "unknown"
>("up_to_date");
const [releaseUrl, setReleaseUrl] = useState("");
const [isExternal, setIsExternal] = useState(false);
const [isDualAuth, setIsDualAuth] = useState(false);
// 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]);
// On the desktop app, the account it is linked to on a server, if any.
// "Cloud" storage only has somewhere to go once it is linked.
const [linkedAccount, setLinkedAccount] = useState<
LinkedAccountInfo | null | undefined
>(() => (isElectron() ? undefined : null));
const isLinked = !!linkedAccount;
useEffect(() => {
if (isElectron() && linkedAccount === null && storageMode === "cloud") {
setStorageMode("local");
onPrefsChange?.({ storageMode: "local" });
}
}, [linkedAccount, storageMode, onPrefsChange]);
// Settings toggles — all backed by localStorage
const [commandPaletteEnabled, setCommandPaletteEnabled] = useState(() => {
const v = localStorage.getItem("commandPaletteShortcutEnabled");
return v !== null ? v === "true" : true;
});
// 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();
const [pinAppRail, setPinAppRail] = useState(() =>
readRailPreference("pinAppRail"),
);
const [expandAppRailOnHover, setExpandAppRailOnHover] = useState(() =>
readRailPreference("expandAppRailOnHover"),
);
const [showPinAppRailButton, setShowPinAppRailButton] = useState(() =>
readRailPreference("showPinAppRailButton"),
);
const [disableUpdateCheck, setDisableUpdateCheck] = useState(
() => localStorage.getItem("disableUpdateCheck") === "true",
);
const [confirmTabClose, setConfirmTabClose] = useState(
() => localStorage.getItem("confirmTabClose") === "true",
);
const [showTabNumbers, setShowTabNumbers] = useState(
() => localStorage.getItem("showTabNumbers") === "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);
setIsExternal(info.is_external ?? false);
setIsDualAuth(info.is_dual_auth ?? false);
const linked = info.linked ?? null;
setLinkedAccount(linked);
// A linked desktop is the account it is signed in to on the server.
setAccountUsername(linked?.username || info.username);
setAccountTotpEnabled(info.totp_enabled ?? false);
setUserRole(
(linked ? linked.isAdmin : 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_external) {
setAuthMethod(t("newUi.sidebar.userProfile.authMethodExternal"));
} else {
setAuthMethod(t("newUi.sidebar.userProfile.authMethodLocal"));
}
if (linked) {
setUserRoles(
linked.roles.map((name, index) => ({
id: index,
roleId: index,
roleName: name,
roleDisplayName: name,
})) as unknown as UserRole[],
);
} else {
getUserRoles(info.userId)
.then(({ roles }) => setUserRoles(roles ?? []))
.catch(() => {});
}
})
.catch(() => {});
getApiKeys()
.then(({ apiKeys: keys }) => setApiKeys(keys))
.catch(() => {});
getVersionInfo()
.then((info) => {
setVersion(info.localVersion);
setVersionStatus(info.status ?? "unknown");
setReleaseUrl(releaseUrlFrom(info));
})
.catch(() => {
// Backend/network unreachable (offline machine). The installer
// version is still known locally via Electron, so use that instead
// of leaving the version blank.
if (isElectron()) {
window.electronAPI
?.getAppVersion?.()
.then((appVersion) => {
if (appVersion) {
setVersion(appVersion);
setVersionStatus("unknown");
}
})
.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"));
const showPinButtonHandler = () =>
setShowPinAppRailButton(readRailPreference("showPinAppRailButton"));
window.addEventListener("pinAppRailChanged", pinHandler);
window.addEventListener("expandAppRailOnHoverChanged", hoverHandler);
window.addEventListener(
"showPinAppRailButtonChanged",
showPinButtonHandler,
);
return () => {
window.removeEventListener("pinAppRailChanged", pinHandler);
window.removeEventListener("expandAppRailOnHoverChanged", hoverHandler);
window.removeEventListener(
"showPinAppRailButtonChanged",
showPinButtonHandler,
);
};
}, []);
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",
"commandPaletteShortcutEnabled",
"showHostTags",
"hostTrayOnClick",
"compactHostView",
"pinAppRail",
"expandAppRailOnHover",
"showPinAppRailButton",
"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",
];
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.commandPaletteEnabled != null) {
setCommandPaletteEnabled(prefs.commandPaletteEnabled);
localStorage.setItem(
"commandPaletteShortcutEnabled",
String(prefs.commandPaletteEnabled),
);
}
// 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.showPinAppRailButton != null) {
setShowPinAppRailButton(prefs.showPinAppRailButton);
localStorage.setItem(
"showPinAppRailButton",
String(prefs.showPinAppRailButton),
);
window.dispatchEvent(new Event("showPinAppRailButtonChanged"));
}
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");
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"));
setShowPinAppRailButton(false);
localStorage.setItem("showPinAppRailButton", "false");
window.dispatchEvent(new Event("showPinAppRailButtonChanged"));
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",
commandPaletteEnabled: true,
pinAppRail: false,
expandAppRailOnHover: true,
showPinAppRailButton: 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 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 restoredShowPinButton =
restore("showPinAppRailButton", "false") === "true";
setShowPinAppRailButton(restoredShowPinButton);
localStorage.setItem("showPinAppRailButton", String(restoredShowPinButton));
window.dispatchEvent(new Event("showPinAppRailButtonChanged"));
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 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.pluginItemsImported || 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 = !isExternal || isDualAuth;
const isOpen = (id: string) =>
section ? section === id : openSections.has(id as UserProfileSection);
const shows = (id: string) => !section || section === id;
return (
setApiKeys((prev) => [key as ApiKey, ...prev])}
userId={userId}
/>
{/* 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() || isLinked) && shows("data") && (
{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. A linked account's sign-in is managed on its server. */}
}
open={isOpen("security")}
onToggle={() => toggle("security")}
>
{canChangePasword && (
)}
{/* API Keys */}
}
open={isOpen("api-keys")}
onToggle={() => toggle("api-keys")}
>