mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
* 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>
1367 lines
43 KiB
TypeScript
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>
|
|
);
|
|
}
|