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>( () => new Set(["general"]), ); const [manageUser, setManageUser] = useState(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([]); const [hostDefaults, setHostDefaults] = useState({}); const [touchInputSettings, setTouchInputSettings] = useState({ ...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(null); const [imageStorageLocalDir, setImageStorageLocalDir] = useState(""); const [imageStorageInstanceId, setImageStorageInstanceId] = useState(""); const [imageStorageSaving, setImageStorageSaving] = useState(false); const [imageStorageTesting, setImageStorageTesting] = useState(false); const [imageStorageTestResult, setImageStorageTestResult] = useState(null); // SSO / auto-provision state const [oidcAutoProvision, setOidcAutoProvision] = useState(false); const [oidcSilentLoginDefault, setOidcSilentLoginDefault] = useState(false); const [ssoProviders, setSsoProviders] = useState([]); const [ssoDialogOpen, setSsoDialogOpen] = useState(false); const [ssoDialogProvider, setSsoDialogProvider] = useState(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(null); const [editUserLoading, setEditUserLoading] = useState(false); const [editUserRoles, setEditUserRoles] = useState([]); 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(null); // Import state const [importFile, setImportFile] = useState(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(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([]); const [userSearch, setUserSearch] = useState(""); const [userPage, setUserPage] = useState(0); const [userTotal, setUserTotal] = useState(0); const [sessions, setSessions] = useState([]); const [roles, setRoles] = useState([]); const [apiKeys, setApiKeys] = useState([]); 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[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 ( 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 (
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} /> toggle("sso")} providers={ssoProviders} onAddProvider={handleAddProvider} onEditProvider={handleEditProvider} onDeleteProvider={handleDeleteProvider} onToggleEnabled={handleToggleProviderEnabled} /> 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} /> toggle("sessions")} sessions={sessions} setSessions={setSessions} loadSessions={loadSessions} /> 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} /> toggle("host-defaults")} defaults={hostDefaults} setDefaults={setHostDefaults} handleSaveDefaults={handleSaveHostDefaults} /> 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()} /> toggle("database")} importFile={importFile} setImportFile={setImportFile} exportLoading={exportLoading} importLoading={importLoading} handleExportDatabase={handleExportDatabase} handleImportDatabase={handleImportDatabase} /> 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} /> 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} /> toggle("audit-log")} users={users} /> toggle("touch-input")} settings={touchInputSettings} setSettings={setTouchInputSettings} onSave={() => void saveTouchInputSettings()} onReset={resetTouchInputSettings} /> setUsers((prev) => prev.map((u) => u.id === userId ? { ...u, isOidc: false, passwordHash: undefined } : u, ), ) } />
); }