Files
Termix/src/ui/sidebar/AdminSettingsPanel.tsx
T
76fd9eedbf release-2.7.1 (#1296)
* Add Helm and GitOps deployment setup

* fix: build better-sqlite3 from source in Docker (#1267)

* fix: preserve runtime SSL settings (#1268)

* fix: support forwarding from the memory SSH agent (#1269)

* fix: support forwarding from the memory agent

* style: format memory agent test

* fix: prompt for encrypted SFTP key passphrases (#1270)

* fix: prompt for SFTP key passphrases

* style: format SSH key utility test

* fix: include host context in automation notifications (#1271)

* fix: include host context in automation notifications

* style: format automation notification changes

* fix: reserve sidebar height for host tags (#1272)

* fix: keep host action rows stable at large font sizes (#1273)

* fix: honor certificate setting during server probe (#1274)

* fix: package standard Linux icon sizes (#1275)

* fix: avoid duplicate Docker HTTPS listener (#1276)

* Fix host status without metrics collection (#1277)

* fix: allow eight-digit secure auth codes (#1263)

Allow TOTP prompts to accept secure auth codes longer than six digits without blocking valid authentication attempts.

Generated with Codebuff 🤖

Co-authored-by: Chetan <chetan.development@gmail.com>
Co-authored-by: Codebuff <noreply@codebuff.com>

* Harden Helm deployment defaults

* Update Helm workflow action

* Exclude Helm templates from Prettier

* Fix browser RDP file drops (#1279)

* Fix Proxmox guest credential usernames (#1280)

* Add WSL local terminal option (#1281)

* refactor: split the transfer engine into focused modules (#1282)

* refactor: extract SFTP promisify helpers into sftp-promisify module

* refactor: extract transfer timing and rate stats into transfer-stats module

* refactor: extract transfer error classes and recovery checks into transfer-errors module

* refactor: extract host/path utility helpers into transfer-host-utils module

* refactor: extract SFTP directory tree helpers into transfer-sftp-dir module

* refactor: extract segment copy job builder into transfer-segment-copy module

* refactor: extract file scan and sample helpers into transfer-scan module

* refactor: move throttled progress helper into transfer-stats module

* style: format transfer modules

* perf: optimize tmux monitor aggregation (#1283)

* fix: reserve credential tag row height (#1284)

* feat: edit AI provider model settings (#1285)

* fix: clarify click-to-expand host setting (#1286)

* fix: allow portable imports on remote databases (#1287)

* fix: allow HTTPS to share the configured port (#1288)

* fix: resolve synced jump hosts on the server (#1289)

* fix: make terminal clipboard shortcuts layout independent (#1290)

* fix: use compatible fetch dispatcher for Tailscale (#1291)

* fix: add OIDC environment recovery override (#1292)

* fix: coalesce rapid mobile terminal input (#1293)

* fix: coalesce rapid mobile terminal input

* fix: support clean xterm patch installs

* fix: resolve synced remote desktop host IDs (#1295)

* feat: make the SFTP file manager path bar editable (#1294)

Co-authored-by: Maxime Bonillo <257463937+dropafterfree@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>

* feat: add passkey sign in to the login screen

* fix: remove rounded corners from the host list search bar

* fix: stop image storage settings text wrapping to one word per line

* fix: prevent malformed websocket messages from crashing the server

* chore: increment version

* fix: remove gaps between host rows in the sidebar list

Keep sub-pixel row measurements and stop wiping the size cache on hover.

* fix: Failed to connect through jump hosts (#1180)

https://github.com/Termix-SSH/Support/issues/1180

* feat: Progress bar for file downloads in the file manager (#1158)

https://github.com/Termix-SSH/Support/issues/1158

* feat: Allow setting Silent OIDC Login via ENV var (#1174)

https://github.com/Termix-SSH/Support/issues/1174

* feat: `IdentityFile` to limit the number of attempts by agents (#1165)

https://github.com/Termix-SSH/Support/issues/1165

* feat: Credentials clone (#1159)

https://github.com/Termix-SSH/Support/issues/1159

* chore: update release notes

* docs: move helm setup guide to the docs site

* fix: type errors in FilteredAgent agent identity handling

* fix: remove stale better-sqlite3 prebuilds so the source build is used

* fix: actually build better-sqlite3 from source so arm64 docker images work

* fix: credential edit pencil in host editor and add clone action to credential list

* fix: clear editingHost so the credential pencil actually opens the editor

* chore: run format and lint

* fix: folder drag and drop upload failing in the file manager

* chore: sync Crowdin translations for 2.7.1

---------

Co-authored-by: alex-ctms <alex-ctms@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: Chetan Kumar <74929596+ckloop@users.noreply.github.com>
Co-authored-by: Chetan <chetan.development@gmail.com>
Co-authored-by: Codebuff <noreply@codebuff.com>
Co-authored-by: ZacharyZcR <payasonorahc@protonmail.com>
Co-authored-by: dropafterfree <maxime.bonillo@gmail.com>
Co-authored-by: Maxime Bonillo <257463937+dropafterfree@users.noreply.github.com>
2026-08-22 19:47:40 -05:00

1367 lines
43 KiB
TypeScript

import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { notifyAiStatusChanged } from "@/hooks/use-ai-availability";
import {
getAiGloballyEnabled,
getAiPrivateEndpoints,
setAiGloballyEnabled as setAiGloballyEnabledApi,
setAiPrivateEndpoints as setAiPrivateEndpointsApi,
} from "@/api/ai-api";
import {
getUserList,
getSessions,
getRoles,
getApiKeys,
createApiKey,
deleteUser,
revokeAllUserSessions,
createRole,
adminCreateUser,
makeUserAdmin,
removeAdminStatus,
getRegistrationAllowed,
updateRegistrationAllowed,
getPasswordLoginAllowed,
updatePasswordLoginAllowed,
getPasswordResetAllowed,
updatePasswordResetAllowed,
getSessionTimeout,
updateSessionTimeout,
getGlobalMonitoringSettings,
updateGlobalMonitoringSettings,
getLogLevel,
updateLogLevel,
getGuacamoleSettings,
updateGuacamoleSettings,
getOidcAutoProvision,
updateOidcAutoProvision,
getOidcSilentLoginDefault,
updateOidcSilentLoginDefault,
getCommandHistoryEnabled,
updateCommandHistoryEnabled,
isElectron,
getUserRoles,
} from "@/main-axios";
import {
getTailscaleSettings,
updateTailscaleSettings,
getHostDefaults,
updateHostDefaults,
getAnalyticsEnabled,
updateAnalyticsEnabled,
getTerminalImageStorageSettings,
updateTerminalImageStorageSettings,
testTerminalImageStorage,
type HostDefaults,
type TerminalImageStorageSettings,
type TerminalImageStorageTestResult,
} from "@/api/settings-api";
import {
getSessionSharingGloballyEnabled,
updateSessionSharingGloballyEnabled,
} from "@/api/session-sharing-api";
import {
getAcmeSslSettings,
updateAcmeSslSettings,
requestAcmeCertificate,
uploadManualSslCertificate,
type AcmeSettings,
} from "@/api/acme-ssl-api";
import {
getAdminSSOProviders,
updateSSOProvider,
deleteSSOProvider,
} from "@/api/sso-provider-api";
import {
getMetricsHistoryRetention,
saveMetricsHistoryRetention,
} from "@/api/host-metrics-api";
import type { SSOProvider } from "@/types/index";
import {
type ApiKey,
type CreatedApiKey,
type Role,
type UserRole,
} from "@/main-axios";
import { type AdminSection, type Host } from "@/types/ui-types";
import {
AdminRolesSection,
AdminSessionsSection,
AdminUsersSection,
type AdminSession,
type AdminUser,
} from "./AdminManagementSections";
import { toast } from "sonner";
import { getDatabaseTransferUrl } from "@/lib/database-transfer-url";
import {
AdminDatabaseSection,
AdminGeneralSettingsSection,
AdminHostDefaultsSection,
AdminSSOSection,
AdminSSLSection,
} from "./AdminSettingsSections";
import { SSOProviderDialog } from "./SSOProviderDialog";
import { AdminApiKeysSection } from "./AdminApiKeysSection";
import { AdminAuditLogSection } from "./AdminAuditLogSection";
import {
AdminCreateUserDialog,
AdminEditUserDialog,
AdminLinkAccountDialog,
AdminUnlinkAccountDialog,
} from "./AdminUserDialogs";
import { AdminUserManagePanel } from "./AdminUserManagePanel";
import {
TOUCH_INPUT_DEFAULTS,
type TouchInputSettings,
} from "@/types/touch-input-settings";
import {
getTouchInputSettings,
updateTouchInputSettings,
} from "@/api/touch-input-settings-api";
import { cacheTouchInputSettings } from "@/features/terminal/touch-input-settings-store";
import { AdminTouchInputSection } from "./AdminTouchInputSection";
import { AdminImageStorageSection } from "./AdminImageStorageSection";
type ApiErrorLike = {
response?: {
data?: {
error?: string;
};
};
};
function apiErrorMessage(error: unknown, fallback: string) {
return (error as ApiErrorLike).response?.data?.error || fallback;
}
const USERS_PAGE_SIZE = 25;
export function AdminSettingsPanel({
onEditingChange,
onOpenHostTab,
}: {
onEditingChange?: (editing: boolean) => void;
onOpenHostTab?: (host: Host) => void;
} = {}) {
const { t } = useTranslation();
const [openSections, setOpenSections] = useState<Set<AdminSection>>(
() => new Set(["general"]),
);
const [manageUser, setManageUser] = useState<AdminUser | null>(null);
const [allowRegistration, setAllowRegistration] = useState(true);
const [allowPasswordLogin, setAllowPasswordLogin] = useState(true);
const [allowPasswordReset, setAllowPasswordReset] = useState(true);
const [sessionTimeout, setSessionTimeout] = useState("24");
const [statusInterval, setStatusInterval] = useState("60");
const [metricsInterval, setMetricsInterval] = useState("30");
const [metricsHistoryRetention, setMetricsHistoryRetention] = useState("7");
const [guacEnabled, setGuacEnabled] = useState(false);
const [guacUrl, setGuacUrl] = useState("guacd:4822");
const [logLevel, setLogLevel] = useState("info");
const [tailscaleApiKey, setTailscaleApiKey] = useState("");
const [tailscaleApiBaseUrl, setTailscaleApiBaseUrl] = useState("");
const [commandHistoryEnabled, setCommandHistoryEnabled] = useState(true);
const [analyticsEnabled, setAnalyticsEnabled] = useState(true);
const [analyticsLocked, setAnalyticsLocked] = useState(false);
const [oidcSilentLoginDefaultLocked, setOidcSilentLoginDefaultLocked] =
useState(false);
const [sessionSharingGloballyEnabled, setSessionSharingGloballyEnabled] =
useState(true);
const [aiGloballyEnabled, setAiGloballyEnabled] = useState(false);
const [aiPrivateEndpoints, setAiPrivateEndpoints] = useState<string[]>([]);
const [hostDefaults, setHostDefaults] = useState<HostDefaults>({});
const [touchInputSettings, setTouchInputSettings] =
useState<TouchInputSettings>({ ...TOUCH_INPUT_DEFAULTS });
// Terminal image storage state. localDir stays a draft: the API never
// returns the configured backend path, so it is only sent when changed.
const [imageStorageSettings, setImageStorageSettings] =
useState<TerminalImageStorageSettings | null>(null);
const [imageStorageLocalDir, setImageStorageLocalDir] = useState("");
const [imageStorageInstanceId, setImageStorageInstanceId] = useState("");
const [imageStorageSaving, setImageStorageSaving] = useState(false);
const [imageStorageTesting, setImageStorageTesting] = useState(false);
const [imageStorageTestResult, setImageStorageTestResult] =
useState<TerminalImageStorageTestResult | null>(null);
// SSO / auto-provision state
const [oidcAutoProvision, setOidcAutoProvision] = useState(false);
const [oidcSilentLoginDefault, setOidcSilentLoginDefault] = useState(false);
const [ssoProviders, setSsoProviders] = useState<SSOProvider[]>([]);
const [ssoDialogOpen, setSsoDialogOpen] = useState(false);
const [ssoDialogProvider, setSsoDialogProvider] =
useState<SSOProvider | null>(null);
// Create user dialog
const [createUserOpen, setCreateUserOpen] = useState(false);
const [newUsername, setNewUsername] = useState("");
const [newPassword, setNewPassword] = useState("");
const [showNewPassword, setShowNewPassword] = useState(false);
const [createUserLoading, setCreateUserLoading] = useState(false);
// Edit user dialog
const [editUserOpen, setEditUserOpen] = useState(false);
const [editUserTarget, setEditUserTarget] = useState<AdminUser | null>(null);
const [editUserLoading, setEditUserLoading] = useState(false);
const [editUserRoles, setEditUserRoles] = useState<UserRole[]>([]);
const [editUserRolesLoading, setEditUserRolesLoading] = useState(false);
// Link account dialog
const [linkAccountOpen, setLinkAccountOpen] = useState(false);
const [linkAccountTarget, setLinkAccountTarget] = useState<{
id: string;
username: string;
isOidc: boolean;
} | null>(null);
// Unlink account dialog
const [unlinkAccountOpen, setUnlinkAccountOpen] = useState(false);
const [unlinkAccountTarget, setUnlinkAccountTarget] = useState<{
id: string;
username: string;
} | null>(null);
// Create role form
const [showCreateRole, setShowCreateRole] = useState(false);
const [newRoleName, setNewRoleName] = useState("");
const [newRoleDisplayName, setNewRoleDisplayName] = useState("");
const [newRoleDescription, setNewRoleDescription] = useState("");
const [createRoleLoading, setCreateRoleLoading] = useState(false);
// Create API key form
const [showCreateKey, setShowCreateKey] = useState(false);
const [newKeyName, setNewKeyName] = useState("");
const [newKeyUserId, setNewKeyUserId] = useState("");
const [newKeyExpiry, setNewKeyExpiry] = useState("");
const [newKeyLoading, setNewKeyLoading] = useState(false);
const [createdKeyToken, setCreatedKeyToken] = useState<string | null>(null);
// Import state
const [importFile, setImportFile] = useState<File | null>(null);
const [exportLoading, setExportLoading] = useState(false);
const [importLoading, setImportLoading] = useState(false);
// ACME SSL state
const defaultAcmeSettings: AcmeSettings = {
enabled: false,
domain: "",
email: "",
challengeType: "http-webroot",
cloudflareToken: "",
lastIssuedAt: null,
certStatus: "none",
certExpiresAt: null,
};
const [acmeSettings, setAcmeSettings] =
useState<AcmeSettings>(defaultAcmeSettings);
const [cloudflareTokenDraft, setCloudflareTokenDraft] = useState("");
const [acmeRequesting, setAcmeRequesting] = useState(false);
const [manualCertDraft, setManualCertDraft] = useState("");
const [manualKeyDraft, setManualKeyDraft] = useState("");
const [manualUploading, setManualUploading] = useState(false);
const [users, setUsers] = useState<AdminUser[]>([]);
const [userSearch, setUserSearch] = useState("");
const [userPage, setUserPage] = useState(0);
const [userTotal, setUserTotal] = useState(0);
const [sessions, setSessions] = useState<AdminSession[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
useEffect(() => {
loadSessions();
loadRoles();
loadApiKeys();
loadGeneralSettings();
loadSSOProviders();
}, []);
// Debounced so typing in the search box does not fire a request per keystroke.
useEffect(() => {
const timer = setTimeout(loadUsers, userSearch ? 250 : 0);
return () => clearTimeout(timer);
}, [userSearch, userPage]);
// A new search term starts again from the first page.
useEffect(() => {
setUserPage(0);
}, [userSearch]);
useEffect(() => {
onEditingChange?.(manageUser !== null);
return () => onEditingChange?.(false);
}, [manageUser, onEditingChange]);
useEffect(() => {
if (editUserOpen && editUserTarget) {
setEditUserRoles([]);
setEditUserRolesLoading(true);
getUserRoles(editUserTarget.id)
.then(({ roles: r }) => setEditUserRoles(r))
.catch(() => {})
.finally(() => setEditUserRolesLoading(false));
}
}, [editUserOpen, editUserTarget]);
function loadUsers() {
// Paged server-side so an install with thousands of accounts does not
// ship the whole directory to render one screen of it.
getUserList({
search: userSearch.trim() || undefined,
limit: USERS_PAGE_SIZE,
offset: userPage * USERS_PAGE_SIZE,
})
.then(({ users: u, total }) => {
setUsers(
u.map((user) => ({
id: user.userId,
username: user.username,
isAdmin: user.is_admin,
isOidc: user.is_oidc,
passwordHash: user.password_hash,
dataUnlocked: user.data_unlocked,
totpEnabled: user.totp_enabled,
})),
);
setUserTotal(total ?? u.length);
})
.catch(() => {});
}
function loadSessions() {
getSessions()
.then(({ sessions: s }) => setSessions(s))
.catch(() => {});
}
function loadRoles() {
getRoles()
.then(({ roles: r }) => setRoles(r))
.catch(() => {});
}
function loadApiKeys() {
getApiKeys()
.then(({ apiKeys: k }) => setApiKeys(k))
.catch(() => {});
}
async function loadGeneralSettings() {
try {
const [
reg,
pwLogin,
pwReset,
timeout,
monitoring,
level,
guac,
oidcProv,
oidcSilent,
tailscale,
cmdHistory,
analytics,
sessionSharingEnabled,
touchInput,
aiEnabled,
aiEndpoints,
imageStorage,
] = await Promise.allSettled([
getRegistrationAllowed(),
getPasswordLoginAllowed(),
getPasswordResetAllowed(),
getSessionTimeout(),
getGlobalMonitoringSettings(),
getLogLevel(),
getGuacamoleSettings(),
getOidcAutoProvision(),
getOidcSilentLoginDefault(),
getTailscaleSettings(),
getCommandHistoryEnabled(),
getAnalyticsEnabled(),
getSessionSharingGloballyEnabled(),
getTouchInputSettings(),
getAiGloballyEnabled(),
getAiPrivateEndpoints(),
getTerminalImageStorageSettings(),
]);
if (reg.status === "fulfilled") setAllowRegistration(reg.value.allowed);
if (pwLogin.status === "fulfilled")
setAllowPasswordLogin(pwLogin.value.allowed);
if (oidcProv.status === "fulfilled")
setOidcAutoProvision(oidcProv.value.enabled);
if (oidcSilent.status === "fulfilled") {
setOidcSilentLoginDefault(oidcSilent.value.enabled);
setOidcSilentLoginDefaultLocked(oidcSilent.value.locked ?? false);
}
if (pwReset.status === "fulfilled") setAllowPasswordReset(pwReset.value);
if (timeout.status === "fulfilled")
setSessionTimeout(String(timeout.value.timeoutHours));
if (monitoring.status === "fulfilled") {
setStatusInterval(String(monitoring.value.statusCheckInterval));
setMetricsInterval(String(monitoring.value.metricsInterval));
}
getMetricsHistoryRetention()
.then((days) => setMetricsHistoryRetention(String(days)))
.catch(() => {});
if (level.status === "fulfilled") setLogLevel(level.value.level);
if (guac.status === "fulfilled") {
setGuacEnabled(guac.value.enabled);
setGuacUrl(guac.value.url || "guacd:4822");
}
if (tailscale.status === "fulfilled") {
setTailscaleApiKey(tailscale.value.apiKey ?? "");
setTailscaleApiBaseUrl(tailscale.value.apiBaseUrl ?? "");
}
if (cmdHistory.status === "fulfilled") {
setCommandHistoryEnabled(cmdHistory.value.enabled);
}
if (analytics.status === "fulfilled") {
setAnalyticsEnabled(analytics.value.enabled);
setAnalyticsLocked(analytics.value.locked ?? false);
}
if (sessionSharingEnabled.status === "fulfilled") {
setSessionSharingGloballyEnabled(sessionSharingEnabled.value.enabled);
}
if (touchInput.status === "fulfilled") {
setTouchInputSettings(touchInput.value);
cacheTouchInputSettings(touchInput.value);
}
if (aiEnabled.status === "fulfilled") {
setAiGloballyEnabled(aiEnabled.value);
}
if (aiEndpoints.status === "fulfilled") {
setAiPrivateEndpoints(aiEndpoints.value);
}
if (imageStorage.status === "fulfilled") {
setImageStorageSettings(imageStorage.value);
}
} catch {
// non-fatal
}
getHostDefaults()
.then((d) => setHostDefaults(d))
.catch(() => {});
getAcmeSslSettings()
.then((s) => setAcmeSettings(s))
.catch(() => {});
}
async function loadSSOProviders() {
try {
const providers = await getAdminSSOProviders();
setSsoProviders(providers);
} catch {
// non-fatal
}
}
function toggle(id: AdminSection) {
setOpenSections((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
async function handleSaveHostDefaults() {
try {
await updateHostDefaults(hostDefaults);
toast.success(t("admin.hostDefaultsSaved"));
} catch {
toast.error(t("admin.hostDefaultsSaveFailed"));
}
}
async function handleToggleRegistration() {
const newVal = !allowRegistration;
setAllowRegistration(newVal);
try {
await updateRegistrationAllowed(newVal);
} catch {
setAllowRegistration(!newVal);
toast.error(t("admin.updateRegistrationFailed"));
}
}
async function handleTogglePasswordLogin() {
const newVal = !allowPasswordLogin;
setAllowPasswordLogin(newVal);
try {
await updatePasswordLoginAllowed(newVal);
} catch (e) {
setAllowPasswordLogin(!newVal);
const msg = (e as ApiErrorLike).response?.data?.error;
toast.error(msg || t("admin.updatePasswordLoginFailed"));
}
}
async function handleToggleOidcAutoProvision() {
const newVal = !oidcAutoProvision;
setOidcAutoProvision(newVal);
try {
await updateOidcAutoProvision(newVal);
} catch {
setOidcAutoProvision(!newVal);
toast.error(t("admin.updateOidcAutoProvisionFailed"));
}
}
async function handleToggleOidcSilentLoginDefault() {
if (oidcSilentLoginDefaultLocked) return;
const newVal = !oidcSilentLoginDefault;
setOidcSilentLoginDefault(newVal);
try {
await updateOidcSilentLoginDefault(newVal);
} catch {
setOidcSilentLoginDefault(!newVal);
toast.error(t("admin.updateOidcSilentLoginDefaultFailed"));
}
}
async function handleTogglePasswordReset() {
const newVal = !allowPasswordReset;
setAllowPasswordReset(newVal);
try {
await updatePasswordResetAllowed(newVal);
} catch {
setAllowPasswordReset(!newVal);
toast.error(t("admin.updatePasswordResetFailed"));
}
}
async function handleToggleCommandHistory() {
const newVal = !commandHistoryEnabled;
setCommandHistoryEnabled(newVal);
try {
await updateCommandHistoryEnabled(newVal);
} catch {
setCommandHistoryEnabled(!newVal);
toast.error(t("admin.updateCommandHistoryFailed"));
}
}
async function handleToggleAnalytics() {
if (analyticsLocked) return;
const newVal = !analyticsEnabled;
setAnalyticsEnabled(newVal);
try {
await updateAnalyticsEnabled(newVal);
} catch {
setAnalyticsEnabled(!newVal);
toast.error(t("admin.updateAnalyticsFailed"));
}
}
async function handleToggleSessionSharingGloballyEnabled() {
const newVal = !sessionSharingGloballyEnabled;
setSessionSharingGloballyEnabled(newVal);
try {
await updateSessionSharingGloballyEnabled(newVal);
} catch {
setSessionSharingGloballyEnabled(!newVal);
toast.error(t("admin.updateSessionSharingFailed"));
}
}
async function handleToggleAiGloballyEnabled() {
const newVal = !aiGloballyEnabled;
setAiGloballyEnabled(newVal);
try {
await setAiGloballyEnabledApi(newVal);
// Every AI surface listens for this, so the admin sees the entry appear
// or disappear right away instead of after a reload.
notifyAiStatusChanged();
} catch {
setAiGloballyEnabled(!newVal);
toast.error(t("admin.updateAiEnabledFailed"));
}
}
async function handleSaveAiPrivateEndpoints(hosts: string[]) {
const previous = aiPrivateEndpoints;
setAiPrivateEndpoints(hosts);
try {
setAiPrivateEndpoints(await setAiPrivateEndpointsApi(hosts));
} catch {
setAiPrivateEndpoints(previous);
toast.error(t("admin.updateAiEndpointsFailed"));
}
}
async function saveTouchInputSettings(settings = touchInputSettings) {
try {
const saved = await updateTouchInputSettings(settings);
setTouchInputSettings(saved);
cacheTouchInputSettings(saved);
toast.success(t("admin.touchSaved"));
} catch {
toast.error(t("admin.touchSaveFailed"));
}
}
function resetTouchInputSettings() {
const defaults = { ...TOUCH_INPUT_DEFAULTS };
setTouchInputSettings(defaults);
void saveTouchInputSettings(defaults);
}
async function handleSaveImageStorage() {
if (!imageStorageSettings) return;
setImageStorageSaving(true);
try {
const saved = await updateTerminalImageStorageSettings({
mode: imageStorageSettings.mode,
hostPath: imageStorageSettings.hostPath,
ttlMs: imageStorageSettings.ttlMs,
maxCount: imageStorageSettings.maxCount,
maxBytes: imageStorageSettings.maxBytes,
...(imageStorageLocalDir.trim()
? { localDir: imageStorageLocalDir.trim() }
: {}),
});
setImageStorageSettings(saved);
setImageStorageLocalDir("");
toast.success(t("admin.imageStorageSaved"));
} catch (e) {
toast.error(apiErrorMessage(e, t("admin.imageStorageSaveFailed")));
} finally {
setImageStorageSaving(false);
}
}
async function handleTestImageStorage() {
if (!imageStorageInstanceId.trim()) {
toast.error(t("admin.imageStorageInstanceIdRequired"));
return;
}
setImageStorageTesting(true);
setImageStorageTestResult(null);
try {
setImageStorageTestResult(
await testTerminalImageStorage(imageStorageInstanceId.trim()),
);
} catch (e) {
toast.error(apiErrorMessage(e, t("admin.imageStorageTestFailed")));
} finally {
setImageStorageTesting(false);
}
}
async function handleSaveSessionTimeout() {
const hours = parseInt(sessionTimeout, 10);
if (isNaN(hours) || hours < 1 || hours > 720) {
toast.error(t("admin.sessionTimeoutRange2"));
return;
}
try {
await updateSessionTimeout(hours);
toast.success(t("admin.sessionTimeoutSaved"));
} catch {
toast.error(t("admin.sessionTimeoutSaveFailed"));
}
}
async function handleSaveMonitoring() {
const status = parseInt(statusInterval, 10);
const metrics = parseInt(metricsInterval, 10);
const retention = parseInt(metricsHistoryRetention, 10);
if (isNaN(status) || isNaN(metrics)) {
toast.error(t("admin.monitoringIntervalInvalid"));
return;
}
if (!isNaN(retention) && (retention < 1 || retention > 90)) {
toast.error(t("admin.metricsHistoryRetentionRange"));
return;
}
try {
await Promise.all([
updateGlobalMonitoringSettings({
statusCheckInterval: status,
metricsInterval: metrics,
}),
!isNaN(retention)
? saveMetricsHistoryRetention(retention)
: Promise.resolve(),
]);
toast.success(t("admin.monitoringSaved"));
} catch {
toast.error(t("admin.monitoringSaveFailed"));
}
}
async function handleSaveGuacamole() {
try {
await updateGuacamoleSettings({ enabled: guacEnabled, url: guacUrl });
toast.success(t("admin.guacamoleSaved"));
} catch {
toast.error(t("admin.guacamoleSaveFailed"));
}
}
async function handleToggleGuacamole() {
const newVal = !guacEnabled;
setGuacEnabled(newVal);
try {
await updateGuacamoleSettings({ enabled: newVal, url: guacUrl });
} catch {
setGuacEnabled(!newVal);
toast.error(t("admin.guacamoleUpdateFailed"));
}
}
async function handleSaveTailscaleApiKey() {
try {
await updateTailscaleSettings(tailscaleApiKey, tailscaleApiBaseUrl);
toast.success(t("admin.tailscaleSettingsSaved"));
} catch {
toast.error(t("admin.tailscaleSettingsSaveFailed"));
}
}
async function handleSaveLogLevel(level: string) {
setLogLevel(level);
try {
await updateLogLevel(level);
} catch {
toast.error(t("admin.logLevelUpdateFailed"));
}
}
function handleAddProvider() {
setSsoDialogProvider(null);
setSsoDialogOpen(true);
}
function handleEditProvider(provider: SSOProvider) {
setSsoDialogProvider(provider);
setSsoDialogOpen(true);
}
async function handleDeleteProvider(id: number) {
if (!window.confirm(t("admin.ssoDeleteConfirm"))) return;
try {
await deleteSSOProvider(id);
setSsoProviders((prev) => prev.filter((p) => p.id !== id));
toast.success(t("common.deleted"));
} catch (e) {
toast.error(apiErrorMessage(e, t("common.deleteFailed")));
}
}
async function handleToggleProviderEnabled(id: number, enabled: boolean) {
const provider = ssoProviders.find((p) => p.id === id);
if (!provider) return;
setSsoProviders((prev) =>
prev.map((p) => (p.id === id ? { ...p, enabled } : p)),
);
try {
await updateSSOProvider(id, { enabled });
} catch (e) {
setSsoProviders((prev) =>
prev.map((p) => (p.id === id ? { ...p, enabled: !enabled } : p)),
);
toast.error(apiErrorMessage(e, t("common.saveFailed")));
}
}
async function handleSaveAcmeSettings() {
try {
const payload: Parameters<typeof updateAcmeSslSettings>[0] = {
enabled: acmeSettings.enabled,
domain: acmeSettings.domain,
email: acmeSettings.email,
challengeType: acmeSettings.challengeType,
...(cloudflareTokenDraft && { cloudflareToken: cloudflareTokenDraft }),
};
const updated = await updateAcmeSslSettings(payload);
setAcmeSettings(updated);
setCloudflareTokenDraft("");
toast.success(t("admin.sslSaved"));
} catch {
toast.error(t("admin.sslSaveFailed"));
}
}
async function handleRequestAcmeCertificate() {
if (!acmeSettings.domain || !acmeSettings.email) {
toast.error(t("admin.sslRequiresDomain"));
return;
}
setAcmeRequesting(true);
try {
if (cloudflareTokenDraft) {
await updateAcmeSslSettings({
domain: acmeSettings.domain,
email: acmeSettings.email,
challengeType: acmeSettings.challengeType,
cloudflareToken: cloudflareTokenDraft,
});
setCloudflareTokenDraft("");
}
const result = await requestAcmeCertificate();
setAcmeSettings(result);
toast.success(t("admin.sslRequestCertSuccess"));
if (result.reloadMessage) {
toast.info(result.reloadMessage);
}
} catch (e) {
toast.error(apiErrorMessage(e, t("admin.sslRequestCertFailed")));
} finally {
setAcmeRequesting(false);
}
}
async function handleManualSslUpload() {
if (!manualCertDraft.trim() || !manualKeyDraft.trim()) {
toast.error(t("admin.sslManualRequiresFields"));
return;
}
setManualUploading(true);
try {
const result = await uploadManualSslCertificate({
certificate: manualCertDraft,
privateKey: manualKeyDraft,
});
setAcmeSettings(result);
setManualCertDraft("");
setManualKeyDraft("");
toast.success(t("admin.sslManualUploadSuccess"));
if (result.reloadMessage) {
toast.info(result.reloadMessage);
}
} catch (e) {
toast.error(apiErrorMessage(e, t("admin.sslManualUploadFailed")));
} finally {
setManualUploading(false);
}
}
function handleProviderSaved(saved: SSOProvider) {
setSsoProviders((prev) => {
const idx = prev.findIndex((p) => p.id === saved.id);
if (idx >= 0) {
const next = [...prev];
next[idx] = saved;
return next;
}
return [...prev, saved];
});
}
async function handleCreateUser() {
if (!newUsername.trim() || !newPassword.trim()) {
toast.error(t("admin.createUserRequired"));
return;
}
if (newPassword.length < 6) {
toast.error(t("admin.createUserPasswordTooShort"));
return;
}
setCreateUserLoading(true);
try {
await adminCreateUser(newUsername.trim(), newPassword);
toast.success(t("admin.createUserSuccess", { username: newUsername }));
setCreateUserOpen(false);
setNewUsername("");
setNewPassword("");
loadUsers();
} catch (e: unknown) {
toast.error(apiErrorMessage(e, t("admin.createUserFailed")));
} finally {
setCreateUserLoading(false);
}
}
async function handleToggleAdmin(user: AdminUser) {
setEditUserLoading(true);
try {
if (user.isAdmin) {
await removeAdminStatus(user.id);
setEditUserTarget((prev) =>
prev ? { ...prev, isAdmin: false } : prev,
);
setUsers((prev) =>
prev.map((u) => (u.id === user.id ? { ...u, isAdmin: false } : u)),
);
} else {
await makeUserAdmin(user.id);
setEditUserTarget((prev) => (prev ? { ...prev, isAdmin: true } : prev));
setUsers((prev) =>
prev.map((u) => (u.id === user.id ? { ...u, isAdmin: true } : u)),
);
}
} catch {
toast.error(t("admin.updateAdminStatusFailed"));
} finally {
setEditUserLoading(false);
}
}
async function handleRevokeUserSessions(userId: string) {
try {
await revokeAllUserSessions(userId);
toast.success(t("admin.allSessionsRevoked"));
loadSessions();
} catch {
toast.error(t("admin.revokeSessionsFailed"));
}
}
async function handleDeleteEditUser() {
if (!editUserTarget) return;
setEditUserLoading(true);
try {
await deleteUser(editUserTarget.username);
setUsers((prev) => prev.filter((u) => u.id !== editUserTarget.id));
setEditUserOpen(false);
setEditUserTarget(null);
toast.success(
t("admin.deleteUserSuccess", { username: editUserTarget.username }),
);
} catch (e: unknown) {
toast.error(apiErrorMessage(e, t("admin.deleteUserFailed")));
} finally {
setEditUserLoading(false);
}
}
async function handleCreateRole() {
if (!newRoleName.trim() || !newRoleDisplayName.trim()) {
toast.error(t("admin.createRoleRequired"));
return;
}
setCreateRoleLoading(true);
const displayName = newRoleDisplayName.trim();
try {
await createRole({
name: newRoleName.trim(),
displayName,
description: newRoleDescription.trim() || null,
});
setShowCreateRole(false);
setNewRoleName("");
setNewRoleDisplayName("");
setNewRoleDescription("");
toast.success(t("admin.createRoleSuccess", { name: displayName }));
loadRoles();
} catch (e: unknown) {
toast.error(apiErrorMessage(e, t("admin.createRoleFailed")));
} finally {
setCreateRoleLoading(false);
}
}
async function handleCreateApiKey() {
if (!newKeyName.trim()) {
toast.error(t("admin.apiKeyNameRequired"));
return;
}
if (!newKeyUserId.trim()) {
toast.error(t("admin.apiKeyUserRequired"));
return;
}
setNewKeyLoading(true);
try {
const created: CreatedApiKey = await createApiKey(
newKeyName.trim(),
newKeyUserId.trim(),
newKeyExpiry ? new Date(newKeyExpiry).toISOString() : undefined,
);
setApiKeys((prev) => [{ ...created, isActive: true }, ...prev]);
setCreatedKeyToken(created.token);
setNewKeyName("");
setNewKeyUserId("");
setNewKeyExpiry("");
toast.success(t("admin.apiKeyCreatedSuccess", { name: created.name }));
} catch (e: unknown) {
toast.error(apiErrorMessage(e, t("admin.apiKeyCreateFailed")));
} finally {
setNewKeyLoading(false);
}
}
async function handleExportDatabase() {
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("admin.exportSuccess"));
} else {
const err = await response.json().catch(() => ({}));
toast.error(err.error || t("admin.exportFailed"));
}
} catch {
toast.error(t("admin.exportFailed"));
} finally {
setExportLoading(false);
}
}
async function handleImportDatabase() {
if (!importFile) {
toast.error(t("admin.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("admin.importCompleted", { total, skipped: s.skippedItems || 0 }),
);
setImportFile(null);
setTimeout(() => window.location.reload(), 1500);
} else {
toast.error(
t("admin.importFailed", {
error: result.summary?.errors?.join(", ") || "Unknown error",
}),
);
}
} else {
const err = await response.json().catch(() => ({}));
toast.error(err.error || t("admin.importError"));
}
} catch {
toast.error(t("admin.importError"));
} finally {
setImportLoading(false);
}
}
if (manageUser) {
return (
<AdminUserManagePanel
key={manageUser.id}
user={manageUser}
roles={roles}
onBack={() => setManageUser(null)}
onOpenHostTab={onOpenHostTab}
onUserDeleted={() => {
setUsers((prev) => prev.filter((u) => u.id !== manageUser.id));
setManageUser(null);
}}
onTotpDisabled={() => {
setUsers((prev) =>
prev.map((u) =>
u.id === manageUser.id ? { ...u, totpEnabled: false } : u,
),
);
}}
/>
);
}
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-2 p-3 flex-1 min-h-0 overflow-y-auto">
<AdminGeneralSettingsSection
open={openSections.has("general")}
onToggle={() => toggle("general")}
analyticsEnabled={analyticsEnabled}
analyticsLocked={analyticsLocked}
handleToggleAnalytics={handleToggleAnalytics}
sessionSharingGloballyEnabled={sessionSharingGloballyEnabled}
aiGloballyEnabled={aiGloballyEnabled}
onToggleAiGloballyEnabled={handleToggleAiGloballyEnabled}
aiPrivateEndpoints={aiPrivateEndpoints}
onSaveAiPrivateEndpoints={handleSaveAiPrivateEndpoints}
handleToggleSessionSharingGloballyEnabled={
handleToggleSessionSharingGloballyEnabled
}
allowRegistration={allowRegistration}
handleToggleRegistration={handleToggleRegistration}
allowPasswordLogin={allowPasswordLogin}
handleTogglePasswordLogin={handleTogglePasswordLogin}
oidcAutoProvision={oidcAutoProvision}
handleToggleOidcAutoProvision={handleToggleOidcAutoProvision}
oidcSilentLoginDefault={oidcSilentLoginDefault}
oidcSilentLoginDefaultLocked={oidcSilentLoginDefaultLocked}
handleToggleOidcSilentLoginDefault={handleToggleOidcSilentLoginDefault}
allowPasswordReset={allowPasswordReset}
handleTogglePasswordReset={handleTogglePasswordReset}
commandHistoryEnabled={commandHistoryEnabled}
handleToggleCommandHistory={handleToggleCommandHistory}
sessionTimeout={sessionTimeout}
setSessionTimeout={setSessionTimeout}
handleSaveSessionTimeout={handleSaveSessionTimeout}
statusInterval={statusInterval}
setStatusInterval={setStatusInterval}
metricsInterval={metricsInterval}
setMetricsInterval={setMetricsInterval}
metricsHistoryRetention={metricsHistoryRetention}
setMetricsHistoryRetention={setMetricsHistoryRetention}
handleSaveMonitoring={handleSaveMonitoring}
guacEnabled={guacEnabled}
handleToggleGuacamole={handleToggleGuacamole}
guacUrl={guacUrl}
setGuacUrl={setGuacUrl}
handleSaveGuacamole={handleSaveGuacamole}
logLevel={logLevel}
handleSaveLogLevel={handleSaveLogLevel}
tailscaleApiKey={tailscaleApiKey}
setTailscaleApiKey={setTailscaleApiKey}
tailscaleApiBaseUrl={tailscaleApiBaseUrl}
setTailscaleApiBaseUrl={setTailscaleApiBaseUrl}
handleSaveTailscaleApiKey={handleSaveTailscaleApiKey}
/>
<AdminSSOSection
open={openSections.has("sso")}
onToggle={() => toggle("sso")}
providers={ssoProviders}
onAddProvider={handleAddProvider}
onEditProvider={handleEditProvider}
onDeleteProvider={handleDeleteProvider}
onToggleEnabled={handleToggleProviderEnabled}
/>
<SSOProviderDialog
open={ssoDialogOpen}
onOpenChange={setSsoDialogOpen}
provider={ssoDialogProvider}
onSaved={handleProviderSaved}
/>
<AdminUsersSection
open={openSections.has("users")}
onToggle={() => toggle("users")}
users={users}
setUsers={setUsers}
loadUsers={loadUsers}
setCreateUserOpen={setCreateUserOpen}
setEditUserTarget={setEditUserTarget}
setEditUserOpen={setEditUserOpen}
setLinkAccountTarget={setLinkAccountTarget}
setLinkAccountOpen={setLinkAccountOpen}
setUnlinkAccountTarget={setUnlinkAccountTarget}
setUnlinkAccountOpen={setUnlinkAccountOpen}
onManageUser={setManageUser}
search={userSearch}
onSearchChange={setUserSearch}
page={userPage}
pageSize={USERS_PAGE_SIZE}
total={userTotal}
onPageChange={setUserPage}
/>
<AdminSessionsSection
open={openSections.has("sessions")}
onToggle={() => toggle("sessions")}
sessions={sessions}
setSessions={setSessions}
loadSessions={loadSessions}
/>
<AdminRolesSection
open={openSections.has("roles")}
onToggle={() => toggle("roles")}
roles={roles}
setRoles={setRoles}
showCreateRole={showCreateRole}
setShowCreateRole={setShowCreateRole}
newRoleName={newRoleName}
setNewRoleName={setNewRoleName}
newRoleDisplayName={newRoleDisplayName}
setNewRoleDisplayName={setNewRoleDisplayName}
newRoleDescription={newRoleDescription}
setNewRoleDescription={setNewRoleDescription}
handleCreateRole={handleCreateRole}
createRoleLoading={createRoleLoading}
/>
<AdminHostDefaultsSection
open={openSections.has("host-defaults")}
onToggle={() => toggle("host-defaults")}
defaults={hostDefaults}
setDefaults={setHostDefaults}
handleSaveDefaults={handleSaveHostDefaults}
/>
<AdminImageStorageSection
open={openSections.has("image-storage")}
onToggle={() => toggle("image-storage")}
settings={imageStorageSettings}
setSettings={setImageStorageSettings}
localDir={imageStorageLocalDir}
setLocalDir={setImageStorageLocalDir}
instanceId={imageStorageInstanceId}
setInstanceId={setImageStorageInstanceId}
saving={imageStorageSaving}
testing={imageStorageTesting}
testResult={imageStorageTestResult}
onSave={() => void handleSaveImageStorage()}
onTest={() => void handleTestImageStorage()}
/>
<AdminDatabaseSection
open={openSections.has("database")}
onToggle={() => toggle("database")}
importFile={importFile}
setImportFile={setImportFile}
exportLoading={exportLoading}
importLoading={importLoading}
handleExportDatabase={handleExportDatabase}
handleImportDatabase={handleImportDatabase}
/>
<AdminSSLSection
open={openSections.has("ssl")}
onToggle={() => toggle("ssl")}
settings={acmeSettings}
setSettings={setAcmeSettings}
cloudflareTokenDraft={cloudflareTokenDraft}
setCloudflareTokenDraft={setCloudflareTokenDraft}
requesting={acmeRequesting}
handleSave={handleSaveAcmeSettings}
handleRequest={handleRequestAcmeCertificate}
manualCertDraft={manualCertDraft}
setManualCertDraft={setManualCertDraft}
manualKeyDraft={manualKeyDraft}
setManualKeyDraft={setManualKeyDraft}
manualUploading={manualUploading}
handleManualUpload={handleManualSslUpload}
/>
<AdminApiKeysSection
open={openSections.has("api-keys")}
onToggle={() => toggle("api-keys")}
apiKeys={apiKeys}
setApiKeys={setApiKeys}
loadApiKeys={loadApiKeys}
showCreateKey={showCreateKey}
setShowCreateKey={setShowCreateKey}
createdKeyToken={createdKeyToken}
setCreatedKeyToken={setCreatedKeyToken}
newKeyName={newKeyName}
setNewKeyName={setNewKeyName}
newKeyUserId={newKeyUserId}
setNewKeyUserId={setNewKeyUserId}
newKeyExpiry={newKeyExpiry}
setNewKeyExpiry={setNewKeyExpiry}
users={users}
handleCreateApiKey={handleCreateApiKey}
newKeyLoading={newKeyLoading}
/>
<AdminAuditLogSection
open={openSections.has("audit-log")}
onToggle={() => toggle("audit-log")}
users={users}
/>
<AdminTouchInputSection
open={openSections.has("touch-input")}
onToggle={() => toggle("touch-input")}
settings={touchInputSettings}
setSettings={setTouchInputSettings}
onSave={() => void saveTouchInputSettings()}
onReset={resetTouchInputSettings}
/>
<AdminCreateUserDialog
open={createUserOpen}
onOpenChange={setCreateUserOpen}
newUsername={newUsername}
setNewUsername={setNewUsername}
newPassword={newPassword}
setNewPassword={setNewPassword}
showNewPassword={showNewPassword}
setShowNewPassword={setShowNewPassword}
handleCreateUser={handleCreateUser}
createUserLoading={createUserLoading}
/>
<AdminEditUserDialog
open={editUserOpen}
onOpenChange={setEditUserOpen}
editUserTarget={editUserTarget}
editUserLoading={editUserLoading}
editUserRoles={editUserRoles}
editUserRolesLoading={editUserRolesLoading}
roles={roles}
setEditUserRoles={setEditUserRoles}
handleToggleAdmin={handleToggleAdmin}
handleRevokeUserSessions={handleRevokeUserSessions}
handleDeleteEditUser={handleDeleteEditUser}
/>
<AdminLinkAccountDialog
open={linkAccountOpen}
onOpenChange={setLinkAccountOpen}
linkAccountTarget={linkAccountTarget}
setUsers={setUsers}
users={users}
/>
<AdminUnlinkAccountDialog
open={unlinkAccountOpen}
onOpenChange={setUnlinkAccountOpen}
unlinkAccountTarget={unlinkAccountTarget}
onSuccess={(userId) =>
setUsers((prev) =>
prev.map((u) =>
u.id === userId
? { ...u, isOidc: false, passwordHash: undefined }
: u,
),
)
}
/>
</div>
);
}