diff --git a/src/backend/ssh/docker.ts b/src/backend/ssh/docker.ts index 7bc5d57e..c995a824 100644 --- a/src/backend/ssh/docker.ts +++ b/src/backend/ssh/docker.ts @@ -2970,7 +2970,7 @@ app.get("/docker/containers/:sessionId/:containerId/logs", async (req, res) => { session.activeOperations++; try { - let command = `docker logs ${containerId}`; + let command = `docker logs ${containerId} 2>&1`; if (tail && tail > 0) { command += ` --tail ${Math.floor(tail)}`; diff --git a/src/ui/types/connection-log.ts b/src/types/connection-log.ts similarity index 100% rename from src/ui/types/connection-log.ts rename to src/types/connection-log.ts diff --git a/src/ui/types/electron.d.ts b/src/types/electron.d.ts similarity index 100% rename from src/ui/types/electron.d.ts rename to src/types/electron.d.ts diff --git a/src/ui/types/guacamole-common-js.d.ts b/src/types/guacamole-common-js.d.ts similarity index 100% rename from src/ui/types/guacamole-common-js.d.ts rename to src/types/guacamole-common-js.d.ts diff --git a/src/ui/types/index.ts b/src/types/index.ts similarity index 100% rename from src/ui/types/index.ts rename to src/types/index.ts diff --git a/src/ui/types/stats-widgets.ts b/src/types/stats-widgets.ts similarity index 100% rename from src/ui/types/stats-widgets.ts rename to src/types/stats-widgets.ts diff --git a/src/ui/types/ui-types.ts b/src/types/ui-types.ts similarity index 100% rename from src/ui/types/ui-types.ts rename to src/types/ui-types.ts diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index 6d620eef..d9368574 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -50,11 +50,11 @@ function sshHostToHost(h: SSHHostWithStatus): Host { notes: h.notes, pin: h.pin ?? false, macAddress: h.macAddress, + enableSsh: h.enableSsh ?? (h.connectionType === "ssh" || !h.connectionType), enableTerminal: h.enableTerminal ?? true, enableTunnel: h.enableTunnel ?? false, enableFileManager: h.enableFileManager ?? false, enableDocker: h.enableDocker ?? false, - enableSsh: h.connectionType === "ssh" || !h.connectionType, enableRdp: h.connectionType === "rdp", enableVnc: h.connectionType === "vnc", enableTelnet: h.connectionType === "telnet", diff --git a/src/ui/components/section-card.tsx b/src/ui/components/section-card.tsx index a1622b96..cfb98dd4 100644 --- a/src/ui/components/section-card.tsx +++ b/src/ui/components/section-card.tsx @@ -59,17 +59,20 @@ export function SettingRow({ export function FakeSwitch({ defaultChecked = false, + checked, onChange, }: { defaultChecked?: boolean; + checked?: boolean; onChange?: (v: boolean) => void; }) { - const [on, setOn] = useState(defaultChecked); + const [internalOn, setInternalOn] = useState(defaultChecked); + const on = checked !== undefined ? checked : internalOn; return ( ))} @@ -322,7 +403,15 @@ export function CommandPalette({ className="rounded-none text-xs font-semibold hover:bg-accent-brand/10 hover:text-accent-brand cursor-pointer" onClick={(e) => { e.stopPropagation(); - handleAction(() => onOpenTab("host-manager")); + setIsOpen(false); + onOpenTab("host-manager"); + setTimeout(() => { + window.dispatchEvent( + new CustomEvent("host-manager:edit-host", { + detail: host.id, + }), + ); + }, 100); }} > Edit Host @@ -335,7 +424,7 @@ export function CommandPalette({ }) ) : (
- No hosts found matching "{search}" + No hosts found matching “{search}”
)} diff --git a/src/ui/sidebar/AdminSettingsPanel.tsx b/src/ui/sidebar/AdminSettingsPanel.tsx index 61e8d96b..b1e57cde 100644 --- a/src/ui/sidebar/AdminSettingsPanel.tsx +++ b/src/ui/sidebar/AdminSettingsPanel.tsx @@ -4,12 +4,36 @@ import { getSessions, getRoles, getApiKeys, + createApiKey, deleteUser, revokeSession, + revokeAllUserSessions, deleteRole, deleteApiKey, + createRole, + registerUser, + makeUserAdmin, + removeAdminStatus, + getRegistrationAllowed, + updateRegistrationAllowed, + getPasswordLoginAllowed, + updatePasswordLoginAllowed, + getPasswordResetAllowed, + updatePasswordResetAllowed, + getSessionTimeout, + updateSessionTimeout, + getGlobalMonitoringSettings, + updateGlobalMonitoringSettings, + getLogLevel, + updateLogLevel, + getGuacamoleSettings, + updateGuacamoleSettings, + getAdminOIDCConfig, + updateOIDCConfig, + disableOIDCConfig, + isElectron, } from "@/main-axios"; -import type { ApiKey } from "@/main-axios"; +import type { ApiKey, CreatedApiKey } from "@/main-axios"; import type React from "react"; import { Button } from "@/components/button"; import { Input } from "@/components/input"; @@ -24,8 +48,10 @@ import { Activity, AlertCircle, ChevronDown, + Copy, Database, Eye, + EyeOff, KeyRound, Network, Pencil, @@ -36,11 +62,12 @@ import { Shield, Trash2, User, - X, } from "lucide-react"; import { SettingRow } from "@/components/section-card"; import type { AdminSection } from "@/types/ui-types"; import type { Role } from "@/main-axios"; +import { toast } from "sonner"; +import { getBasePath } from "@/lib/base-path"; function AdminToggle({ on, onToggle }: { on: boolean; onToggle: () => void }) { return ( @@ -102,42 +129,529 @@ export function AdminSettingsPanel() { const [guacEnabled, setGuacEnabled] = useState(false); const [guacUrl, setGuacUrl] = useState("guacd:4822"); const [logLevel, setLogLevel] = useState("info"); - const [importFile, setImportFile] = useState(null); - const [showCreateRole, setShowCreateRole] = useState(false); - const [showCreateKey, setShowCreateKey] = useState(false); + + // OIDC state + const [oidcClientId, setOidcClientId] = useState(""); + const [oidcClientSecret, setOidcClientSecret] = useState(""); + const [oidcAuthUrl, setOidcAuthUrl] = useState(""); + const [oidcIssuerUrl, setOidcIssuerUrl] = useState(""); + const [oidcTokenUrl, setOidcTokenUrl] = useState(""); + const [oidcUserIdentifier, setOidcUserIdentifier] = useState("sub"); + const [oidcDisplayName, setOidcDisplayName] = useState("name"); + const [oidcScopes, setOidcScopes] = useState("openid email profile"); + const [oidcUserinfoUrl, setOidcUserinfoUrl] = useState(""); + const [oidcAllowedUsers, setOidcAllowedUsers] = useState(""); + const [oidcSaving, setOidcSaving] = useState(false); + + // 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); + + // Link account dialog const [linkAccountOpen, setLinkAccountOpen] = useState(false); const [linkAccountTarget, setLinkAccountTarget] = 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); + const [users, setUsers] = useState([]); const [sessions, setSessions] = useState([]); const [roles, setRoles] = useState([]); const [apiKeys, setApiKeys] = useState([]); useEffect(() => { + loadUsers(); + loadSessions(); + loadRoles(); + loadApiKeys(); + loadGeneralSettings(); + loadOidcConfig(); + }, []); + + function loadUsers() { getUserList() .then(({ users: u }) => setUsers(u)) .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] = + await Promise.allSettled([ + getRegistrationAllowed(), + getPasswordLoginAllowed(), + getPasswordResetAllowed(), + getSessionTimeout(), + getGlobalMonitoringSettings(), + getLogLevel(), + getGuacamoleSettings(), + ]); + + if (reg.status === "fulfilled") setAllowRegistration(reg.value.allowed); + if (pwLogin.status === "fulfilled") + setAllowPasswordLogin(pwLogin.value.allowed); + 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)); + } + if (level.status === "fulfilled") setLogLevel(level.value.level); + if (guac.status === "fulfilled") { + setGuacEnabled(guac.value.enabled); + setGuacUrl(guac.value.url || "guacd:4822"); + } + } catch { + // non-fatal + } + } + + async function loadOidcConfig() { + try { + const config = await getAdminOIDCConfig(); + if (!config) return; + setOidcClientId((config.clientId as string) ?? ""); + setOidcClientSecret((config.clientSecret as string) ?? ""); + setOidcAuthUrl((config.authorizationUrl as string) ?? ""); + setOidcIssuerUrl((config.issuerUrl as string) ?? ""); + setOidcTokenUrl((config.tokenUrl as string) ?? ""); + setOidcUserIdentifier((config.userIdentifierPath as string) ?? "sub"); + setOidcDisplayName((config.displayNamePath as string) ?? "name"); + setOidcScopes((config.scopes as string) ?? "openid email profile"); + setOidcUserinfoUrl((config.overrideUserinfoUrl as string) ?? ""); + setOidcAllowedUsers( + Array.isArray(config.allowedUsers) + ? (config.allowedUsers as string[]).join("\n") + : "", + ); + } catch { + // no OIDC configured yet + } + } function toggle(id: AdminSection) { setOpenSection((prev) => (prev === id ? null : id)); } + async function handleToggleRegistration() { + const newVal = !allowRegistration; + setAllowRegistration(newVal); + try { + await updateRegistrationAllowed(newVal); + } catch { + setAllowRegistration(!newVal); + toast.error("Failed to update registration setting"); + } + } + + async function handleTogglePasswordLogin() { + const newVal = !allowPasswordLogin; + setAllowPasswordLogin(newVal); + try { + await updatePasswordLoginAllowed(newVal); + } catch { + setAllowPasswordLogin(!newVal); + toast.error("Failed to update password login setting"); + } + } + + async function handleTogglePasswordReset() { + const newVal = !allowPasswordReset; + setAllowPasswordReset(newVal); + try { + await updatePasswordResetAllowed(newVal); + } catch { + setAllowPasswordReset(!newVal); + toast.error("Failed to update password reset setting"); + } + } + + async function handleSaveSessionTimeout() { + const hours = parseInt(sessionTimeout, 10); + if (isNaN(hours) || hours < 1 || hours > 720) { + toast.error("Session timeout must be between 1 and 720 hours"); + return; + } + try { + await updateSessionTimeout(hours); + toast.success("Session timeout saved"); + } catch { + toast.error("Failed to save session timeout"); + } + } + + async function handleSaveMonitoring() { + const status = parseInt(statusInterval, 10); + const metrics = parseInt(metricsInterval, 10); + if (isNaN(status) || isNaN(metrics)) { + toast.error("Invalid interval values"); + return; + } + try { + await updateGlobalMonitoringSettings({ + statusCheckInterval: status, + metricsInterval: metrics, + }); + toast.success("Monitoring settings saved"); + } catch { + toast.error("Failed to save monitoring settings"); + } + } + + async function handleSaveGuacamole() { + try { + await updateGuacamoleSettings({ enabled: guacEnabled, url: guacUrl }); + toast.success("Guacamole settings saved"); + } catch { + toast.error("Failed to save Guacamole settings"); + } + } + + async function handleToggleGuacamole() { + const newVal = !guacEnabled; + setGuacEnabled(newVal); + try { + await updateGuacamoleSettings({ enabled: newVal, url: guacUrl }); + } catch { + setGuacEnabled(!newVal); + toast.error("Failed to update Guacamole setting"); + } + } + + async function handleSaveLogLevel(level: string) { + setLogLevel(level); + try { + await updateLogLevel(level); + } catch { + toast.error("Failed to update log level"); + } + } + + async function handleSaveOidc() { + setOidcSaving(true); + try { + await updateOIDCConfig({ + clientId: oidcClientId, + clientSecret: oidcClientSecret, + authorizationUrl: oidcAuthUrl, + issuerUrl: oidcIssuerUrl, + tokenUrl: oidcTokenUrl, + userIdentifierPath: oidcUserIdentifier, + displayNamePath: oidcDisplayName, + scopes: oidcScopes, + overrideUserinfoUrl: oidcUserinfoUrl || null, + allowedUsers: oidcAllowedUsers + ? oidcAllowedUsers.split("\n").filter(Boolean) + : [], + }); + toast.success("OIDC configuration saved"); + } catch (e: any) { + toast.error(e?.response?.data?.error || "Failed to save OIDC config"); + } finally { + setOidcSaving(false); + } + } + + async function handleRemoveOidc() { + try { + await disableOIDCConfig(); + setOidcClientId(""); + setOidcClientSecret(""); + setOidcAuthUrl(""); + setOidcIssuerUrl(""); + setOidcTokenUrl(""); + setOidcUserIdentifier("sub"); + setOidcDisplayName("name"); + setOidcScopes("openid email profile"); + setOidcUserinfoUrl(""); + setOidcAllowedUsers(""); + toast.success("OIDC configuration removed"); + } catch { + toast.error("Failed to remove OIDC config"); + } + } + + async function handleCreateUser() { + if (!newUsername.trim() || !newPassword.trim()) { + toast.error("Username and password are required"); + return; + } + if (newPassword.length < 6) { + toast.error("Password must be at least 6 characters"); + return; + } + setCreateUserLoading(true); + try { + await registerUser(newUsername.trim(), newPassword); + toast.success(`User "${newUsername}" created`); + setCreateUserOpen(false); + setNewUsername(""); + setNewPassword(""); + loadUsers(); + } catch (e: any) { + toast.error(e?.response?.data?.error || "Failed to create user"); + } finally { + setCreateUserLoading(false); + } + } + + async function handleToggleAdmin(user: any) { + setEditUserLoading(true); + try { + if (user.isAdmin) { + await removeAdminStatus(user.id); + setEditUserTarget((prev: any) => ({ ...prev, isAdmin: false })); + setUsers((prev) => + prev.map((u) => (u.id === user.id ? { ...u, isAdmin: false } : u)), + ); + } else { + await makeUserAdmin(user.id); + setEditUserTarget((prev: any) => ({ ...prev, isAdmin: true })); + setUsers((prev) => + prev.map((u) => (u.id === user.id ? { ...u, isAdmin: true } : u)), + ); + } + } catch { + toast.error("Failed to update admin status"); + } finally { + setEditUserLoading(false); + } + } + + async function handleRevokeUserSessions(userId: string) { + try { + await revokeAllUserSessions(userId); + toast.success("All sessions revoked"); + loadSessions(); + } catch { + toast.error("Failed to revoke sessions"); + } + } + + 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(`User "${editUserTarget.username}" deleted`); + } catch (e: any) { + toast.error(e?.response?.data?.error || "Failed to delete user"); + } finally { + setEditUserLoading(false); + } + } + + async function handleCreateRole() { + if (!newRoleName.trim() || !newRoleDisplayName.trim()) { + toast.error("Name and display name are required"); + return; + } + setCreateRoleLoading(true); + try { + const { role } = await createRole({ + name: newRoleName.trim(), + displayName: newRoleDisplayName.trim(), + description: newRoleDescription.trim() || null, + }); + setRoles((prev) => [...prev, role]); + setShowCreateRole(false); + setNewRoleName(""); + setNewRoleDisplayName(""); + setNewRoleDescription(""); + toast.success(`Role "${role.displayName}" created`); + } catch (e: any) { + toast.error(e?.response?.data?.error || "Failed to create role"); + } finally { + setCreateRoleLoading(false); + } + } + + async function handleCreateApiKey() { + if (!newKeyName.trim()) { + toast.error("Key name is required"); + return; + } + if (!newKeyUserId.trim()) { + toast.error("User ID is required"); + return; + } + setNewKeyLoading(true); + try { + const created: CreatedApiKey = await createApiKey( + newKeyName.trim(), + newKeyUserId.trim(), + newKeyExpiry ? new Date(newKeyExpiry).toISOString() : undefined, + ); + setApiKeys((prev) => [created, ...prev]); + setCreatedKeyToken(created.token); + setNewKeyName(""); + setNewKeyUserId(""); + setNewKeyExpiry(""); + toast.success(`API key "${created.name}" created`); + } catch (e: any) { + toast.error(e?.response?.data?.error || "Failed to create API key"); + } finally { + setNewKeyLoading(false); + } + } + + async function handleExportDatabase() { + setExportLoading(true); + try { + const isDev = + !isElectron() && + (window.location.port === "5173" || + window.location.hostname === "localhost" || + window.location.hostname === "127.0.0.1"); + + const apiUrl = isElectron() + ? `${(window as any).configuredServerUrl}/database/export` + : isDev + ? `http://localhost:30001/database/export` + : `${window.location.protocol}//${window.location.host}${getBasePath()}/database/export`; + + 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("Database exported successfully"); + } else { + const err = await response.json().catch(() => ({})); + toast.error(err.error || "Database export failed"); + } + } catch { + toast.error("Database export failed"); + } finally { + setExportLoading(false); + } + } + + async function handleImportDatabase() { + if (!importFile) { + toast.error("Please select a file first"); + return; + } + setImportLoading(true); + try { + const isDev = + !isElectron() && + (window.location.port === "5173" || + window.location.hostname === "localhost" || + window.location.hostname === "127.0.0.1"); + + const apiUrl = isElectron() + ? `${(window as any).configuredServerUrl}/database/import` + : isDev + ? `http://localhost:30001/database/import` + : `${window.location.protocol}//${window.location.host}${getBasePath()}/database/import`; + + 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( + `Import completed: ${total} items imported, ${s.skippedItems || 0} skipped`, + ); + setImportFile(null); + setTimeout(() => window.location.reload(), 1500); + } else { + toast.error( + `Import failed: ${result.summary?.errors?.join(", ") || "Unknown error"}`, + ); + } + } else { + const err = await response.json().catch(() => ({})); + toast.error(err.error || "Database import failed"); + } + } catch { + toast.error("Database import failed"); + } finally { + setImportLoading(false); + } + } + return (
{/* General */} @@ -154,7 +668,7 @@ export function AdminSettingsPanel() { > setAllowRegistration((o) => !o)} + onToggle={handleToggleRegistration} /> setAllowPasswordLogin((o) => !o)} + onToggle={handleTogglePasswordLogin} /> setAllowPasswordReset((o) => !o)} + onToggle={handleTogglePasswordReset} /> @@ -190,6 +704,14 @@ export function AdminSettingsPanel() { className="w-20 text-sm" /> hours +
Min 1h · Max 720h @@ -226,6 +748,14 @@ export function AdminSettingsPanel() { className="w-20 text-sm" /> sec + @@ -235,22 +765,29 @@ export function AdminSettingsPanel() { label="Enable Guacamole" description="RDP/VNC remote desktop" > - setGuacEnabled((o) => !o)} - /> + {guacEnabled && (
- setGuacUrl(e.target.value)} - placeholder="guacd:4822" - className="text-sm" - /> +
+ setGuacUrl(e.target.value)} + placeholder="guacd:4822" + className="text-sm" + /> + +
)} @@ -263,7 +800,7 @@ export function AdminSettingsPanel() { {["debug", "info", "warn", "error"].map((l) => (