From c26288e02427845c4e7deb235a1e55d0500b7351 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Thu, 14 May 2026 00:55:12 -0500 Subject: [PATCH] feat: continued improvements --- package.json | 9 +- src/backend/database/routes/host.ts | 21 +- src/backend/package.json | 3 + src/package.json | 3 + src/ui/AppShell.tsx | 77 +- src/ui/admin/AdminSettings.tsx | 505 ---- src/ui/admin/dialogs/CreateUserDialog.tsx | 163 -- src/ui/admin/dialogs/LinkAccountDialog.tsx | 143 -- src/ui/admin/dialogs/UserEditDialog.tsx | 538 ----- src/ui/admin/tabs/ApiKeysTab.tsx | 508 ---- src/ui/admin/tabs/DatabaseSecurityTab.tsx | 223 -- src/ui/admin/tabs/GeneralSettingsTab.tsx | 536 ----- src/ui/admin/tabs/OIDCSettingsTab.tsx | 339 --- src/ui/admin/tabs/RolesTab.tsx | 301 --- src/ui/admin/tabs/SessionManagementTab.tsx | 209 -- src/ui/admin/tabs/UserManagementTab.tsx | 177 -- src/ui/features/FullScreenAppWrapper.tsx | 22 +- src/ui/features/docker/DockerApp.tsx | 8 +- .../features/file-manager/FileManagerApp.tsx | 8 +- src/ui/features/guacamole/GuacamoleApp.tsx | 104 +- .../features/guacamole/GuacamoleDisplay.tsx | 48 +- .../features/server-stats/ServerStatsApp.tsx | 8 +- src/ui/features/terminal/Terminal.tsx | 35 +- src/ui/features/terminal/TerminalApp.tsx | 8 +- src/ui/features/terminal/TerminalPreview.tsx | 122 - src/ui/features/tunnel/Tunnel.tsx | 248 -- src/ui/features/tunnel/TunnelApp.tsx | 56 +- .../features/tunnel/TunnelInlineControls.tsx | 28 +- src/ui/features/tunnel/TunnelManager.tsx | 169 -- src/ui/features/tunnel/TunnelModeSelector.tsx | 32 +- src/ui/features/tunnel/TunnelObject.tsx | 532 ----- src/ui/features/tunnel/TunnelViewer.tsx | 64 - src/ui/index.css | 12 +- src/ui/locales/en.json | 1648 ++++++------- src/ui/shell/CommandPalette.tsx | 59 +- src/ui/shell/SplitView.tsx | 9 +- src/ui/shell/tabUtils.tsx | 7 +- src/ui/sidebar/Host.tsx | 411 ---- src/ui/sidebar/HostManager.tsx | 2118 ++++++++++------- src/ui/sidebar/HostsPanel.tsx | 4 +- .../ssh/dialogs/HostKeyVerificationDialog.tsx | 177 +- src/ui/ssh/dialogs/OPKSSHDialog.tsx | 122 +- src/ui/ssh/dialogs/PassphraseDialog.tsx | 62 +- src/ui/ssh/dialogs/SSHAuthDialog.tsx | 253 +- src/ui/ssh/dialogs/TOTPDialog.tsx | 76 +- src/ui/ssh/dialogs/TmuxSessionPicker.tsx | 45 +- src/ui/ssh/dialogs/WarpgateDialog.tsx | 69 +- src/ui/user/C2STunnelPresetManager.tsx | 36 +- src/ui/user/PasswordReset.tsx | 152 -- src/ui/user/TOTPSetup.tsx | 479 ---- src/ui/user/UserProfile.tsx | 1050 -------- 51 files changed, 2933 insertions(+), 9103 deletions(-) create mode 100644 src/backend/package.json create mode 100644 src/package.json delete mode 100644 src/ui/admin/AdminSettings.tsx delete mode 100644 src/ui/admin/dialogs/CreateUserDialog.tsx delete mode 100644 src/ui/admin/dialogs/LinkAccountDialog.tsx delete mode 100644 src/ui/admin/dialogs/UserEditDialog.tsx delete mode 100644 src/ui/admin/tabs/ApiKeysTab.tsx delete mode 100644 src/ui/admin/tabs/DatabaseSecurityTab.tsx delete mode 100644 src/ui/admin/tabs/GeneralSettingsTab.tsx delete mode 100644 src/ui/admin/tabs/OIDCSettingsTab.tsx delete mode 100644 src/ui/admin/tabs/RolesTab.tsx delete mode 100644 src/ui/admin/tabs/SessionManagementTab.tsx delete mode 100644 src/ui/admin/tabs/UserManagementTab.tsx delete mode 100644 src/ui/features/terminal/TerminalPreview.tsx delete mode 100644 src/ui/features/tunnel/Tunnel.tsx delete mode 100644 src/ui/features/tunnel/TunnelManager.tsx delete mode 100644 src/ui/features/tunnel/TunnelObject.tsx delete mode 100644 src/ui/features/tunnel/TunnelViewer.tsx delete mode 100644 src/ui/sidebar/Host.tsx delete mode 100644 src/ui/user/PasswordReset.tsx delete mode 100644 src/ui/user/TOTPSetup.tsx delete mode 100644 src/ui/user/UserProfile.tsx diff --git a/package.json b/package.json index ace8a8dc..0afda8a2 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,6 @@ "description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities", "author": "Karmaa", "main": "electron/main.cjs", - "type": "module", "engines": { "node": ">=22.12.0", "npm": ">=11" @@ -19,12 +18,12 @@ "lint:fix": "eslint --fix .", "type-check": "tsc --noEmit", "dev": "vite", - "build": "vite build && tsc -p tsconfig.node.json", - "build:backend": "tsc -p tsconfig.node.json", - "dev:backend": "tsc -p tsconfig.node.json && node ./dist/backend/backend/starter.js", + "build": "vite build && tsc -p tsconfig.node.json && powershell -c \"Copy-Item src/backend/package.json dist/backend/package.json\"", + "build:backend": "tsc -p tsconfig.node.json && powershell -c \"Copy-Item src/backend/package.json dist/backend/package.json\"", + "dev:backend": "tsc -p tsconfig.node.json && powershell -c \"Copy-Item src/backend/package.json dist/backend/package.json\" && node ./dist/backend/backend/starter.js", "dev:docker": "docker stop termix-dev 2>nul & docker rm termix-dev 2>nul & docker build -f docker/Dockerfile -t termix:dev --no-cache . && docker run -d --name termix-dev -p 3000:3000 -p 8080:8080 -p 30001-30006:30001-30006 -v \"%cd%\\db\\data:/app/data\" termix:dev", "dev:docker:restart": "docker stop termix-dev 2>nul & docker rm termix-dev 2>nul & docker run -d --name termix-dev -p 8080:8080 -p 30001-30006:30001-30006 -v \"%cd%\\db\\data:/app/data\" termix:dev", - "generate:openapi": "tsc -p tsconfig.node.json && node ./dist/backend/backend/swagger.js", + "generate:openapi": "tsc -p tsconfig.node.json && powershell -c \"Copy-Item src/backend/package.json dist/backend/package.json\" && node ./dist/backend/backend/swagger.js", "preview": "vite preview", "electron:dev": "concurrently \"npm run dev\" \"powershell -c \\\"Start-Sleep -Seconds 5\\\" && electron .\"", "electron:patch-builder": "node scripts/patch-app-builder-lib.cjs", diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index 8efb3088..2ba14a75 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -291,10 +291,23 @@ function transformHostResponse( showTunnelInSidebar: !!host.showTunnelInSidebar, showDockerInSidebar: !!host.showDockerInSidebar, showServerStatsInSidebar: !!host.showServerStatsInSidebar, - enableSsh: host.enableSsh !== undefined ? !!host.enableSsh : true, - enableRdp: !!host.enableRdp, - enableVnc: !!host.enableVnc, - enableTelnet: !!host.enableTelnet, + // Old hosts only had connection_type set; the per-protocol enable flags didn't exist yet. + // The schema defaults (enableSsh=true, others=false) wrongly mark every old host as SSH. + // Detect this migration case: if no non-SSH protocol is explicitly enabled AND + // connectionType is set to a non-SSH value, fall back to inferring from connectionType. + ...(() => { + const ct = host.connectionType; + const rdp = !!host.enableRdp; + const vnc = !!host.enableVnc; + const tel = !!host.enableTelnet; + const isMigratedNonSsh = !rdp && !vnc && !tel && ct && ct !== "ssh"; + return { + enableSsh: isMigratedNonSsh ? false : !!host.enableSsh, + enableRdp: isMigratedNonSsh ? ct === "rdp" : rdp, + enableVnc: isMigratedNonSsh ? ct === "vnc" : vnc, + enableTelnet: isMigratedNonSsh ? ct === "telnet" : tel, + }; + })(), sshPort: host.sshPort ?? host.port ?? 22, rdpPort: host.rdpPort ?? 3389, vncPort: host.vncPort ?? 5900, diff --git a/src/backend/package.json b/src/backend/package.json new file mode 100644 index 00000000..3dbc1ca5 --- /dev/null +++ b/src/backend/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/src/package.json b/src/package.json new file mode 100644 index 00000000..3dbc1ca5 --- /dev/null +++ b/src/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index 672b8e5e..f20ecfaa 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -149,7 +149,7 @@ export function AppShell({ const [railView, setRailView] = useState("hosts"); const [profileDropdownOpen, setProfileDropdownOpen] = useState(false); const [hostManagerExpanded, setHostManagerExpanded] = useState(false); - const [sidebarWidth, setSidebarWidth] = useState(256); + const [sidebarWidth, setSidebarWidth] = useState(266); const [sidebarDragging, setSidebarDragging] = useState(false); const isMobile = useIsMobile(); @@ -205,6 +205,24 @@ export function AppShell({ return () => dbHealthMonitor.off("session-expired", handleSessionExpired); }, [onLogout]); + useEffect(() => { + const activeTab = tabs.find((t) => t.id === activeTabId); + if (!activeTab?.terminalRef) return; + let innerRafId: number; + const outerRafId = requestAnimationFrame(() => { + innerRafId = requestAnimationFrame(() => { + const ref = activeTab.terminalRef?.current; + ref?.fit?.(); + ref?.notifyResize?.(); + ref?.refresh?.(); + }); + }); + return () => { + cancelAnimationFrame(outerRafId); + cancelAnimationFrame(innerRafId); + }; + }, [activeTabId]); + useEffect(() => { const handleDegraded = () => { toast.loading(t("common.connectionDegraded"), { @@ -513,7 +531,7 @@ export function AppShell({ size="icon" className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground" title="Reset width" - onClick={() => setSidebarWidth(256)} + onClick={() => setSidebarWidth(266)} > @@ -616,7 +634,7 @@ export function AppShell({ onCloseTab={closeTab} onReorderTabs={setTabs} /> -
+
{isSplit && !isMobile ? ( - ) : activeTab ? ( - renderTabContent(activeTab, openSingletonTab, openTab, closeTab) - ) : null} + ) : ( + <> + {/* Terminal tabs: always in DOM, absolutely positioned so xterm always has real dimensions */} + {tabs + .filter((tab) => tab.type === "terminal") + .map((tab) => { + const visible = tab.id === activeTabId; + return ( +
+ {renderTabContent( + tab, + openSingletonTab, + openTab, + closeTab, + visible, + )} +
+ ); + })} + {/* Non-terminal tabs: display:none when inactive */} + {tabs + .filter((tab) => tab.type !== "terminal") + .map((tab) => { + const visible = tab.id === activeTabId; + return ( +
+ {renderTabContent( + tab, + openSingletonTab, + openTab, + closeTab, + visible, + )} +
+ ); + })} + + )}
diff --git a/src/ui/admin/AdminSettings.tsx b/src/ui/admin/AdminSettings.tsx deleted file mode 100644 index b939245c..00000000 --- a/src/ui/admin/AdminSettings.tsx +++ /dev/null @@ -1,505 +0,0 @@ -import React from "react"; -import { useSidebar } from "@/components/sidebar.tsx"; -import { Separator } from "@/components/separator.tsx"; -import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "@/components/tabs.tsx"; -import { Shield, Users, Database, Clock, Key } from "lucide-react"; -import { toast } from "sonner"; -import { useTranslation } from "react-i18next"; -import { useConfirmation } from "@/hooks/use-confirmation.ts"; -import { - getAdminOIDCConfig, - getRegistrationAllowed, - getPasswordLoginAllowed, - getPasswordResetAllowed, - getUserList, - getUserInfo, - isElectron, - getSessions, - unlinkOIDCFromPasswordAccount, -} from "@/main-axios.ts"; -import { SimpleLoader } from "@/lib/SimpleLoader.tsx"; -import { RolesTab } from "@/admin/tabs/RolesTab.tsx"; -import { GeneralSettingsTab } from "@/admin/tabs/GeneralSettingsTab.tsx"; -import { OIDCSettingsTab } from "@/admin/tabs/OIDCSettingsTab.tsx"; -import { UserManagementTab } from "@/admin/tabs/UserManagementTab.tsx"; -import { SessionManagementTab } from "@/admin/tabs/SessionManagementTab.tsx"; -import { DatabaseSecurityTab } from "@/admin/tabs/DatabaseSecurityTab.tsx"; -import { ApiKeysTab } from "@/admin/tabs/ApiKeysTab.tsx"; -import { CreateUserDialog } from "./dialogs/CreateUserDialog.tsx"; -import { UserEditDialog } from "./dialogs/UserEditDialog.tsx"; -import { LinkAccountDialog } from "./dialogs/LinkAccountDialog.tsx"; - -interface AdminSettingsProps { - isTopbarOpen?: boolean; - rightSidebarOpen?: boolean; - rightSidebarWidth?: number; -} - -export function AdminSettings({ - isTopbarOpen = true, - rightSidebarOpen = false, - rightSidebarWidth = 400, -}: AdminSettingsProps): React.ReactElement { - const { t } = useTranslation(); - const { confirmWithToast } = useConfirmation(); - const { state: sidebarState } = useSidebar(); - - const [loading, setLoading] = React.useState(true); - const [allowRegistration, setAllowRegistration] = React.useState(true); - const [allowPasswordLogin, setAllowPasswordLogin] = React.useState(true); - const [allowPasswordReset, setAllowPasswordReset] = React.useState(true); - - const [oidcConfig, setOidcConfig] = React.useState({ - client_id: "", - client_secret: "", - issuer_url: "", - authorization_url: "", - token_url: "", - identifier_path: "sub", - name_path: "name", - scopes: "openid email profile", - userinfo_url: "", - allowed_users: "", - }); - - const [users, setUsers] = React.useState< - Array<{ - id: string; - username: string; - isAdmin: boolean; - isOidc: boolean; - passwordHash?: string; - }> - >([]); - const [usersLoading, setUsersLoading] = React.useState(false); - - const [createUserDialogOpen, setCreateUserDialogOpen] = React.useState(false); - const [userEditDialogOpen, setUserEditDialogOpen] = React.useState(false); - const [selectedUserForEdit, setSelectedUserForEdit] = React.useState<{ - id: string; - username: string; - isAdmin: boolean; - isOidc: boolean; - passwordHash?: string; - } | null>(null); - - const [currentUser, setCurrentUser] = React.useState<{ - id: string; - username: string; - is_admin: boolean; - is_oidc: boolean; - } | null>(null); - - const [sessions, setSessions] = React.useState< - Array<{ - id: string; - userId: string; - username?: string; - deviceType: string; - deviceInfo: string; - createdAt: string; - expiresAt: string; - lastActiveAt: string; - isRevoked?: boolean; - isCurrentSession?: boolean; - }> - >([]); - const [sessionsLoading, setSessionsLoading] = React.useState(false); - - const [linkAccountAlertOpen, setLinkAccountAlertOpen] = React.useState(false); - const [linkOidcUser, setLinkOidcUser] = React.useState<{ - id: string; - username: string; - } | null>(null); - - React.useEffect(() => { - if (isElectron()) { - const serverUrl = (window as { configuredServerUrl?: string }) - .configuredServerUrl; - if (!serverUrl) { - setLoading(false); - return; - } - } - - Promise.allSettled([ - getAdminOIDCConfig() - .then((res) => { - if (res) - setOidcConfig((prev) => ({ - ...prev, - ...res, - identifier_path: (res.identifier_path as string) || "sub", - name_path: (res.name_path as string) || "name", - scopes: (res.scopes as string) || "openid email profile", - })); - }) - .catch((err) => { - if (!err.message?.includes("No server configured")) { - toast.error(t("admin.failedToFetchOidcConfig")); - } - }), - getUserInfo() - .then((info) => { - if (info) { - setCurrentUser({ - id: info.userId, - username: info.username, - is_admin: info.is_admin, - is_oidc: info.is_oidc, - }); - } - }) - .catch((err) => { - if (!err?.message?.includes("No server configured")) { - console.warn("Failed to fetch current user info", err); - } - }), - getSessions() - .then((data) => setSessions(data.sessions || [])) - .catch((err) => { - if (!err?.message?.includes("No server configured")) { - toast.error(t("admin.failedToFetchSessions")); - } - }), - ]).finally(() => setLoading(false)); - }, []); - - React.useEffect(() => { - if (isElectron()) { - const serverUrl = (window as { configuredServerUrl?: string }) - .configuredServerUrl; - if (!serverUrl) { - return; - } - } - - getRegistrationAllowed() - .then((res) => { - if (typeof res?.allowed === "boolean") { - setAllowRegistration(res.allowed); - } - }) - .catch((err) => { - if (!err.message?.includes("No server configured")) { - toast.error(t("admin.failedToFetchRegistrationStatus")); - } - }); - }, []); - - React.useEffect(() => { - if (isElectron()) { - const serverUrl = (window as { configuredServerUrl?: string }) - .configuredServerUrl; - if (!serverUrl) { - return; - } - } - - getPasswordLoginAllowed() - .then((res) => { - if (typeof res?.allowed === "boolean") { - setAllowPasswordLogin(res.allowed); - } - }) - .catch((err) => { - if (err.code !== "NO_SERVER_CONFIGURED") { - toast.error(t("admin.failedToFetchPasswordLoginStatus")); - } - }); - }, []); - - React.useEffect(() => { - if (isElectron()) { - const serverUrl = (window as { configuredServerUrl?: string }) - .configuredServerUrl; - if (!serverUrl) { - return; - } - } - - getPasswordResetAllowed() - .then((res) => { - if (typeof res === "boolean") { - setAllowPasswordReset(res); - } - }) - .catch((err) => { - if (err.code !== "NO_SERVER_CONFIGURED") { - console.warn("Failed to fetch password reset status", err); - } - }); - }, []); - - const fetchUsers = async () => { - if (isElectron()) { - const serverUrl = (window as { configuredServerUrl?: string }) - .configuredServerUrl; - if (!serverUrl) { - return; - } - } - - setUsersLoading(true); - try { - const response = await getUserList(); - setUsers(response.users); - } catch (err) { - if (!err.message?.includes("No server configured")) { - toast.error(t("admin.failedToFetchUsers")); - } - } finally { - setUsersLoading(false); - } - }; - - const handleEditUser = (user: (typeof users)[0]) => { - setSelectedUserForEdit(user); - setUserEditDialogOpen(true); - }; - - const handleCreateUserSuccess = () => { - fetchUsers(); - setCreateUserDialogOpen(false); - }; - - const handleEditUserSuccess = () => { - fetchUsers(); - setUserEditDialogOpen(false); - setSelectedUserForEdit(null); - }; - - const fetchSessions = async () => { - if (isElectron()) { - const serverUrl = (window as { configuredServerUrl?: string }) - .configuredServerUrl; - if (!serverUrl) { - return; - } - } - - setSessionsLoading(true); - try { - const data = await getSessions(); - setSessions(data.sessions || []); - } catch (err) { - if (!err?.message?.includes("No server configured")) { - toast.error(t("admin.failedToFetchSessions")); - } - } finally { - setSessionsLoading(false); - } - }; - - const handleLinkOIDCUser = (user: { id: string; username: string }) => { - setLinkOidcUser(user); - setLinkAccountAlertOpen(true); - }; - - const handleLinkSuccess = () => { - fetchUsers(); - fetchSessions(); - }; - - const handleUnlinkOIDC = async (userId: string, username: string) => { - confirmWithToast( - t("admin.unlinkOIDCDescription", { username }), - async () => { - try { - const result = await unlinkOIDCFromPasswordAccount(userId); - - toast.success( - result.message || t("admin.unlinkOIDCSuccess", { username }), - ); - fetchUsers(); - fetchSessions(); - } catch (error: unknown) { - const err = error as { - response?: { data?: { error?: string; code?: string } }; - }; - toast.error( - err.response?.data?.error || t("admin.failedToUnlinkOIDC"), - ); - } - }, - "destructive", - ); - }; - - const topMarginPx = isTopbarOpen ? 74 : 26; - const leftMarginPx = sidebarState === "collapsed" ? 26 : 8; - const bottomMarginPx = 8; - const wrapperStyle: React.CSSProperties = { - marginLeft: leftMarginPx, - marginRight: rightSidebarOpen - ? `calc(var(--right-sidebar-width, ${rightSidebarWidth}px) + 8px)` - : 17, - marginTop: topMarginPx, - marginBottom: bottomMarginPx, - height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`, - transition: - "margin-left 200ms linear, margin-right 200ms linear, margin-top 200ms linear", - }; - - return ( -
- -
-
-

{t("admin.title")}

-
- - -
- { - if (value === "users") { - fetchUsers(); - } - }} - className="w-full" - > - - - - {t("admin.general")} - - - - OIDC - - - - {t("admin.users")} - - - - Sessions - - - - {t("rbac.roles.label")} - - - - {t("admin.databaseSecurity")} - - - - {t("admin.apiKeys.tabLabel")} - - - - - - - - - - - - - setCreateUserDialogOpen(true)} - onEditUser={handleEditUser} - onLinkOIDCUser={handleLinkOIDCUser} - onUnlinkOIDC={handleUnlinkOIDC} - /> - - - - - - - -
- -
-
- - - - - - - - -
-
-
- - - - - - -
- ); -} - -export default AdminSettings; diff --git a/src/ui/admin/dialogs/CreateUserDialog.tsx b/src/ui/admin/dialogs/CreateUserDialog.tsx deleted file mode 100644 index d713db7c..00000000 --- a/src/ui/admin/dialogs/CreateUserDialog.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogFooter, -} from "@/components/dialog.tsx"; -import { Button } from "@/components/button.tsx"; -import { Label } from "@/components/label.tsx"; -import { Input } from "@/components/input.tsx"; -import { PasswordInput } from "@/components/password-input.tsx"; -import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx"; -import { useTranslation } from "react-i18next"; -import { UserPlus, AlertCircle } from "lucide-react"; -import { toast } from "sonner"; -import { registerUser } from "@/main-axios.ts"; - -interface CreateUserDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - onSuccess: () => void; -} - -export function CreateUserDialog({ - open, - onOpenChange, - onSuccess, -}: CreateUserDialogProps) { - const { t } = useTranslation(); - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - if (!open) { - setUsername(""); - setPassword(""); - setError(null); - } - }, [open]); - - const handleCreateUser = async (e?: React.FormEvent) => { - if (e) { - e.preventDefault(); - } - - if (!username.trim()) { - setError(t("admin.enterUsername")); - return; - } - - if (!password.trim()) { - setError(t("admin.enterPassword")); - return; - } - - if (password.length < 6) { - setError("Password must be at least 6 characters"); - return; - } - - setLoading(true); - setError(null); - - try { - await registerUser(username.trim(), password); - toast.success( - t("admin.userCreatedSuccessfully", { username: username.trim() }), - ); - setUsername(""); - setPassword(""); - onSuccess(); - onOpenChange(false); - } catch (err: unknown) { - const error = err as { response?: { data?: { error?: string } } }; - const errorMessage = - error?.response?.data?.error || t("admin.failedToCreateUser"); - setError(errorMessage); - toast.error(errorMessage); - } finally { - setLoading(false); - } - }; - - return ( - { - if (!loading) { - onOpenChange(newOpen); - } - }} - > - - - - - {t("admin.createUser")} - - - {t("admin.createUserDescription")} - - - -
-
- - { - setUsername(e.target.value); - setError(null); - }} - placeholder={t("admin.enterUsername")} - disabled={loading} - autoFocus - /> -
- -
- - { - setPassword(e.target.value); - setError(null); - }} - placeholder={t("admin.enterPassword")} - disabled={loading} - onKeyDown={(e) => { - if (e.key === "Enter") { - handleCreateUser(); - } - }} - /> -

- {t("admin.passwordMinLength")} -

-
- - {error && ( - - - {t("common.error")} - {error} - - )} -
- - - - -
-
- ); -} diff --git a/src/ui/admin/dialogs/LinkAccountDialog.tsx b/src/ui/admin/dialogs/LinkAccountDialog.tsx deleted file mode 100644 index 4bbfd967..00000000 --- a/src/ui/admin/dialogs/LinkAccountDialog.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/dialog.tsx"; -import { Button } from "@/components/button.tsx"; -import { Label } from "@/components/label.tsx"; -import { Input } from "@/components/input.tsx"; -import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx"; -import { useTranslation } from "react-i18next"; -import { Link2 } from "lucide-react"; -import { toast } from "sonner"; -import { linkOIDCToPasswordAccount } from "@/main-axios.ts"; - -interface LinkAccountDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - oidcUser: { id: string; username: string } | null; - onSuccess: () => void; -} - -export function LinkAccountDialog({ - open, - onOpenChange, - oidcUser, - onSuccess, -}: LinkAccountDialogProps) { - const { t } = useTranslation(); - const [linkTargetUsername, setLinkTargetUsername] = useState(""); - const [linkLoading, setLinkLoading] = useState(false); - - useEffect(() => { - if (!open) { - setLinkTargetUsername(""); - } - }, [open]); - - const handleLinkSubmit = async () => { - if (!oidcUser || !linkTargetUsername.trim()) { - toast.error("Target username is required"); - return; - } - - setLinkLoading(true); - try { - const result = await linkOIDCToPasswordAccount( - oidcUser.id, - linkTargetUsername.trim(), - ); - - toast.success( - result.message || - `OIDC user ${oidcUser.username} linked to ${linkTargetUsername}`, - ); - setLinkTargetUsername(""); - onSuccess(); - onOpenChange(false); - } catch (error: unknown) { - const err = error as { - response?: { data?: { error?: string; code?: string } }; - }; - toast.error(err.response?.data?.error || "Failed to link accounts"); - } finally { - setLinkLoading(false); - } - }; - - return ( - - - - - - {t("admin.linkOidcToPasswordAccount")} - - - {t("admin.linkOidcToPasswordAccountDescription", { - username: oidcUser?.username, - })} - - - -
- - {t("admin.linkOidcWarningTitle")} - - {t("admin.linkOidcWarningDescription")} -
    -
  • {t("admin.linkOidcActionDeleteUser")}
  • -
  • {t("admin.linkOidcActionAddCapability")}
  • -
  • {t("admin.linkOidcActionDualAuth")}
  • -
-
-
- -
- - setLinkTargetUsername(e.target.value)} - placeholder={t("admin.linkTargetUsernamePlaceholder")} - disabled={linkLoading} - onKeyDown={(e) => { - if (e.key === "Enter" && linkTargetUsername.trim()) { - handleLinkSubmit(); - } - }} - /> -
-
- - - - - -
-
- ); -} diff --git a/src/ui/admin/dialogs/UserEditDialog.tsx b/src/ui/admin/dialogs/UserEditDialog.tsx deleted file mode 100644 index 51b79382..00000000 --- a/src/ui/admin/dialogs/UserEditDialog.tsx +++ /dev/null @@ -1,538 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/dialog.tsx"; -import { Button } from "@/components/button.tsx"; -import { Label } from "@/components/label.tsx"; -import { Badge } from "@/components/badge.tsx"; -import { Switch } from "@/components/switch.tsx"; -import { Separator } from "@/components/separator.tsx"; -import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx"; -import { useTranslation } from "react-i18next"; -import { - UserCog, - Trash2, - Plus, - AlertCircle, - Shield, - Clock, -} from "lucide-react"; -import { toast } from "sonner"; -import { useConfirmation } from "@/hooks/use-confirmation.ts"; -import { - getUserRoles, - getRoles, - assignRoleToUser, - removeRoleFromUser, - makeUserAdmin, - removeAdminStatus, - revokeAllUserSessions, - deleteUser, - type UserRole, - type Role, -} from "@/main-axios.ts"; - -interface User { - id: string; - username: string; - isAdmin: boolean; - isOidc: boolean; - passwordHash?: string; -} - -interface UserEditDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - user: User | null; - currentUser: { id: string; username: string } | null; - onSuccess: () => void; -} - -export function UserEditDialog({ - open, - onOpenChange, - user, - currentUser, - onSuccess, -}: UserEditDialogProps) { - const { t } = useTranslation(); - const { confirmWithToast } = useConfirmation(); - - const [adminLoading, setAdminLoading] = useState(false); - const [sessionLoading, setSessionLoading] = useState(false); - const [deleteLoading, setDeleteLoading] = useState(false); - const [rolesLoading, setRolesLoading] = useState(false); - - const [userRoles, setUserRoles] = useState([]); - const [availableRoles, setAvailableRoles] = useState([]); - const [isAdmin, setIsAdmin] = useState(false); - - const isCurrentUser = user?.id === currentUser?.id; - - useEffect(() => { - if (open && user) { - setIsAdmin(user.isAdmin); - loadRoles(); - } - }, [open, user]); - - const loadRoles = async () => { - if (!user) return; - - setRolesLoading(true); - try { - const [rolesResponse, allRolesResponse] = await Promise.all([ - getUserRoles(user.id), - getRoles(), - ]); - - setUserRoles(rolesResponse.roles || []); - setAvailableRoles(allRolesResponse.roles || []); - } catch (error) { - console.error("Failed to load roles:", error); - toast.error(t("rbac.failedToLoadRoles")); - } finally { - setRolesLoading(false); - } - }; - - const handleToggleAdmin = async (checked: boolean) => { - if (!user) return; - - if (isCurrentUser) { - toast.error(t("admin.cannotRemoveOwnAdmin")); - return; - } - - const userToUpdate = user; - onOpenChange(false); - - const confirmed = await confirmWithToast({ - title: checked ? t("admin.makeUserAdmin") : t("admin.removeAdmin"), - description: checked - ? t("admin.confirmMakeAdmin", { username: userToUpdate.username }) - : t("admin.confirmRemoveAdmin", { username: userToUpdate.username }), - confirmText: checked ? t("admin.makeAdmin") : t("admin.removeAdmin"), - cancelText: t("common.cancel"), - variant: checked ? "default" : "destructive", - }); - - if (!confirmed) { - onOpenChange(true); - return; - } - - setAdminLoading(true); - try { - if (checked) { - await makeUserAdmin(userToUpdate.id); - toast.success( - t("admin.userIsNowAdmin", { username: userToUpdate.username }), - ); - } else { - await removeAdminStatus(userToUpdate.id); - toast.success( - t("admin.adminStatusRemoved", { username: userToUpdate.username }), - ); - } - setIsAdmin(checked); - onSuccess(); - } catch (error) { - console.error("Failed to toggle admin status:", error); - toast.error( - checked - ? t("admin.failedToMakeUserAdmin") - : t("admin.failedToRemoveAdminStatus"), - ); - onOpenChange(true); - } finally { - setAdminLoading(false); - } - }; - - const handleAssignRole = async (roleId: number) => { - if (!user) return; - - try { - await assignRoleToUser(user.id, roleId); - toast.success( - t("rbac.roleAssignedSuccessfully", { username: user.username }), - ); - await loadRoles(); - } catch (error) { - console.error("Failed to assign role:", error); - toast.error(t("rbac.failedToAssignRole")); - } - }; - - const handleRemoveRole = async (roleId: number) => { - if (!user) return; - - const userToUpdate = user; - onOpenChange(false); - - const confirmed = await confirmWithToast({ - title: t("rbac.confirmRemoveRole"), - description: t("rbac.confirmRemoveRoleDescription"), - confirmText: t("common.remove"), - cancelText: t("common.cancel"), - variant: "destructive", - }); - - if (!confirmed) { - onOpenChange(true); - return; - } - - try { - await removeRoleFromUser(userToUpdate.id, roleId); - toast.success( - t("rbac.roleRemovedSuccessfully", { username: userToUpdate.username }), - ); - await loadRoles(); - onOpenChange(true); - } catch (error) { - console.error("Failed to remove role:", error); - toast.error(t("rbac.failedToRemoveRole")); - onOpenChange(true); - } - }; - - const handleRevokeAllSessions = async () => { - if (!user) return; - - const isRevokingSelf = isCurrentUser; - - const userToUpdate = user; - onOpenChange(false); - - const confirmed = await confirmWithToast({ - title: t("admin.revokeAllSessions"), - description: isRevokingSelf - ? t("admin.confirmRevokeOwnSessions") - : t("admin.confirmRevokeAllSessions"), - confirmText: t("admin.revoke"), - cancelText: t("common.cancel"), - variant: "destructive", - }); - - if (!confirmed) { - onOpenChange(true); - return; - } - - setSessionLoading(true); - try { - const data = await revokeAllUserSessions(userToUpdate.id); - toast.success(data.message || t("admin.sessionsRevokedSuccessfully")); - - if (isRevokingSelf) { - setTimeout(() => { - window.location.reload(); - }, 1000); - } else { - onSuccess(); - onOpenChange(true); - } - } catch (error) { - console.error("Failed to revoke sessions:", error); - toast.error(t("admin.failedToRevokeSessions")); - onOpenChange(true); - } finally { - setSessionLoading(false); - } - }; - - const handleDeleteUser = async () => { - if (!user) return; - - if (isCurrentUser) { - toast.error(t("admin.cannotDeleteSelf")); - return; - } - - const userToDelete = user; - onOpenChange(false); - - const confirmed = await confirmWithToast({ - title: t("admin.deleteUserTitle"), - description: t("admin.deleteUser", { username: userToDelete.username }), - confirmText: t("common.delete"), - cancelText: t("common.cancel"), - variant: "destructive", - }); - - if (!confirmed) { - onOpenChange(true); - return; - } - - setDeleteLoading(true); - try { - await deleteUser(userToDelete.username); - toast.success( - t("admin.userDeletedSuccessfully", { username: userToDelete.username }), - ); - onSuccess(); - } catch (error) { - console.error("Failed to delete user:", error); - toast.error(t("admin.failedToDeleteUser")); - onOpenChange(true); - } finally { - setDeleteLoading(false); - } - }; - - const getAuthTypeDisplay = (): string => { - if (!user) return ""; - if (user.isOidc && user.passwordHash) { - return t("admin.dualAuth"); - } else if (user.isOidc) { - return t("admin.externalOIDC"); - } else { - return t("admin.localPassword"); - } - }; - - if (!user) return null; - - return ( - - - - - - {t("admin.manageUser")}: {user.username} - - - {t("admin.manageUserDescription")} - - - -
-
-
- -

{user.username}

-
-
- -

{getAuthTypeDisplay()}

-
-
- -

- {isAdmin ? ( - {t("admin.adminBadge")} - ) : ( - t("admin.regularUser") - )} -

-
-
- -

{user.id}

-
-
- - - -
- -
-
-

{t("admin.administratorRole")}

-

- {t("admin.administratorRoleDescription")} -

-
- -
- {isCurrentUser && ( -

- {t("admin.cannotModifyOwnAdminStatus")} -

- )} -
- - - -
- - - {rolesLoading ? ( -
- {t("common.loading")} -
- ) : ( - <> -
- - {userRoles.length === 0 ? ( -

- {t("rbac.noRolesAssigned")} -

- ) : ( -
- {userRoles.map((role) => ( -
-
-

- {role.roleDisplayName - ? t(role.roleDisplayName) - : role.roleName} -

-

- {role.roleName} -

-
-
- {role.isSystem && ( - - {t("rbac.systemRole")} - - )} - {!role.isSystem && ( - - )} -
-
- ))} -
- )} -
- -
- -
- {availableRoles - .filter( - (role) => - !role.isSystem && - !userRoles.some((ur) => ur.roleId === role.id), - ) - .map((role) => ( - - ))} - {availableRoles.filter( - (role) => - !role.isSystem && - !userRoles.some((ur) => ur.roleId === role.id), - ).length === 0 && ( -

- {t("rbac.noCustomRolesToAssign")} -

- )} -
-
- - )} -
- - - -
- -
-
-

- {t("admin.revokeAllSessions")} -

-

- {t("admin.revokeAllSessionsDescription")} -

-
- -
-
- - - -
- - - - {t("admin.deleteUserTitle")} - - {t("admin.deleteUserWarning")} - - - - {isCurrentUser && ( -

- {t("admin.cannotDeleteSelf")} -

- )} -
-
-
-
- ); -} diff --git a/src/ui/admin/tabs/ApiKeysTab.tsx b/src/ui/admin/tabs/ApiKeysTab.tsx deleted file mode 100644 index 18e95ea8..00000000 --- a/src/ui/admin/tabs/ApiKeysTab.tsx +++ /dev/null @@ -1,508 +0,0 @@ -import React from "react"; -import { Button } from "@/components/button.tsx"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/table.tsx"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/dialog.tsx"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/popover.tsx"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/command.tsx"; -import { Input } from "@/components/input.tsx"; -import { Label } from "@/components/label.tsx"; -import { Badge } from "@/components/badge.tsx"; -import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx"; -import { - Key, - Plus, - Trash2, - Copy, - Check, - ChevronsUpDown, - AlertCircle, - RefreshCw, -} from "lucide-react"; -import { cn } from "@/lib/utils.ts"; -import { toast } from "sonner"; -import { useTranslation } from "react-i18next"; -import { useConfirmation } from "@/hooks/use-confirmation.ts"; -import { - getApiKeys, - createApiKey, - deleteApiKey, - getUserList, - type ApiKey, - type CreatedApiKey, -} from "@/main-axios.ts"; - -interface UserOption { - id: string; - username: string; -} - -function UserCombobox({ - users, - value, - onChange, - disabled, -}: { - users: UserOption[]; - value: string; - onChange: (id: string) => void; - disabled?: boolean; -}) { - const { t } = useTranslation(); - const [open, setOpen] = React.useState(false); - const selected = users.find((u) => u.id === value); - - return ( - - - - - - - - - {t("admin.apiKeys.noUsersFound")} - - {users.map((user) => ( - { - onChange(user.id); - setOpen(false); - }} - > - - {user.username} - - ))} - - - - - - ); -} - -function CreateApiKeyDialog({ - open, - onOpenChange, - onCreated, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; - onCreated: () => void; -}) { - const { t } = useTranslation(); - const [name, setName] = React.useState(""); - const [selectedUserId, setSelectedUserId] = React.useState(""); - const [expiresAt, setExpiresAt] = React.useState(""); - const [loading, setLoading] = React.useState(false); - const [usersLoading, setUsersLoading] = React.useState(false); - const [users, setUsers] = React.useState([]); - const [createdKey, setCreatedKey] = React.useState( - null, - ); - const [copied, setCopied] = React.useState(false); - - React.useEffect(() => { - if (!open) return; - if (createdKey) return; - - setUsersLoading(true); - getUserList() - .then((res) => - setUsers( - res.users.map((u) => ({ - id: (u as unknown as { id: string }).id, - username: u.username, - })), - ), - ) - .catch(() => toast.error(t("admin.failedToFetchUsers"))) - .finally(() => setUsersLoading(false)); - }, [open]); - - const handleClose = () => { - const wasCreated = createdKey !== null; - setCreatedKey(null); - setName(""); - setSelectedUserId(""); - setExpiresAt(""); - setCopied(false); - onOpenChange(false); - if (wasCreated) onCreated(); - }; - - const handleCreate = async () => { - if (!name.trim()) { - toast.error(t("admin.apiKeys.nameRequired")); - return; - } - if (!selectedUserId) { - toast.error(t("admin.apiKeys.userRequired")); - return; - } - - setLoading(true); - try { - const result = await createApiKey( - name.trim(), - selectedUserId, - expiresAt || undefined, - ); - setCreatedKey(result); - } catch (err: unknown) { - const e = err as { response?: { data?: { error?: string } } }; - toast.error( - e?.response?.data?.error || t("admin.apiKeys.failedToCreate"), - ); - } finally { - setLoading(false); - } - }; - - const handleCopy = async () => { - if (!createdKey) return; - await navigator.clipboard.writeText(createdKey.token); - setCopied(true); - toast.success(t("admin.apiKeys.tokenCopied")); - setTimeout(() => setCopied(false), 2000); - }; - - return ( - { - if (!isOpen) handleClose(); - }} - > - - - - - {createdKey - ? t("admin.apiKeys.keyCreated") - : t("admin.apiKeys.createApiKey")} - - - {createdKey - ? t("admin.apiKeys.keyCreatedDescription") - : t("admin.apiKeys.createApiKeyDescription")} - - - - {!createdKey ? ( -
-
- - setName(e.target.value)} - placeholder={t("admin.apiKeys.keyNamePlaceholder")} - disabled={loading} - autoFocus - /> -
- -
- - {usersLoading ? ( -

- {t("admin.loading")} -

- ) : ( - - )} -
- -
- - setExpiresAt(e.target.value)} - disabled={loading} - min={new Date().toISOString().split("T")[0]} - /> -

- {t("admin.apiKeys.expiresAtHelp")} -

-
-
- ) : ( -
- - - {t("admin.apiKeys.copyWarningTitle")} - - {t("admin.apiKeys.copyWarningDescription")} - - -
- -
- - {createdKey.token} - - -
-
-
- )} - - - {!createdKey ? ( - <> - - - - ) : ( - - )} - -
-
- ); -} - -export function ApiKeysTab(): React.ReactElement { - const { t } = useTranslation(); - const { confirmWithToast } = useConfirmation(); - const [keys, setKeys] = React.useState([]); - const [loading, setLoading] = React.useState(false); - const [createDialogOpen, setCreateDialogOpen] = React.useState(false); - - const fetchKeys = React.useCallback(async () => { - setLoading(true); - try { - const data = await getApiKeys(); - setKeys(data.apiKeys); - } catch { - toast.error(t("admin.apiKeys.failedToFetch")); - } finally { - setLoading(false); - } - }, [t]); - - React.useEffect(() => { - fetchKeys(); - }, [fetchKeys]); - - const handleDelete = (keyId: string, keyName: string) => { - confirmWithToast( - t("admin.apiKeys.confirmRevoke", { name: keyName }), - async () => { - try { - await deleteApiKey(keyId); - toast.success(t("admin.apiKeys.revokedSuccessfully")); - fetchKeys(); - } catch { - toast.error(t("admin.apiKeys.failedToRevoke")); - } - }, - "destructive", - ); - }; - - const formatDate = (iso: string | null) => { - if (!iso) return t("admin.apiKeys.never"); - const d = new Date(iso); - return ( - d.toLocaleDateString() + - " " + - d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) - ); - }; - - const isExpired = (expiresAt: string | null) => - expiresAt ? new Date(expiresAt) < new Date() : false; - - return ( -
-
-

{t("admin.apiKeys.title")}

-
- - - -
-
- - {loading && keys.length === 0 ? ( -
- {t("admin.loading")} -
- ) : keys.length === 0 ? ( -
- {t("admin.apiKeys.noKeys")} -
- ) : ( - - - - {t("admin.apiKeys.name")} - {t("admin.user")} - {t("admin.apiKeys.prefix")} - {t("admin.created")} - {t("admin.expires")} - {t("admin.apiKeys.lastUsed")} - {t("admin.actions")} - - - - {keys.map((key) => ( - - - {key.name} - {!key.isActive && ( - - {t("admin.revoked")} - - )} - - - {key.username || key.userId} - - - - {key.tokenPrefix}… - - - - {formatDate(key.createdAt)} - - - - {formatDate(key.expiresAt)} - - - - {formatDate(key.lastUsedAt)} - - - - - - ))} - -
- )} - - -
- ); -} diff --git a/src/ui/admin/tabs/DatabaseSecurityTab.tsx b/src/ui/admin/tabs/DatabaseSecurityTab.tsx deleted file mode 100644 index 916f6eb7..00000000 --- a/src/ui/admin/tabs/DatabaseSecurityTab.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import React from "react"; -import { Button } from "@/components/button.tsx"; -import { Download, Upload } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { toast } from "sonner"; -import { isElectron } from "@/main-axios.ts"; -import { getBasePath } from "@/lib/base-path"; - -export function DatabaseSecurityTab(): React.ReactElement { - const { t } = useTranslation(); - - const [exportLoading, setExportLoading] = React.useState(false); - const [importLoading, setImportLoading] = React.useState(false); - const [importFile, setImportFile] = React.useState(null); - - const handleExportDatabase = async () => { - setExportLoading(true); - try { - const isDev = - !isElectron() && - process.env.NODE_ENV === "development" && - (window.location.port === "3000" || - window.location.port === "5173" || - window.location.port === "" || - window.location.hostname === "localhost" || - window.location.hostname === "127.0.0.1"); - - const apiUrl = isElectron() - ? `${(window as { configuredServerUrl?: string }).configuredServerUrl}/database/export` - : isDev - ? `http://localhost:30001/database/export` - : `${window.location.protocol}//${window.location.host}${getBasePath()}/database/export`; - - const headers: Record = { - "Content-Type": "application/json", - }; - - const response = await fetch(apiUrl, { - method: "POST", - headers, - 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.databaseExportedSuccessfully")); - } else { - const error = await response.json(); - toast.error(error.error || t("admin.databaseExportFailed")); - } - } catch { - toast.error(t("admin.databaseExportFailed")); - } finally { - setExportLoading(false); - } - }; - - const handleImportDatabase = async () => { - if (!importFile) { - toast.error(t("admin.pleaseSelectImportFile")); - return; - } - - setImportLoading(true); - try { - const isDev = - !isElectron() && - process.env.NODE_ENV === "development" && - (window.location.port === "3000" || - window.location.port === "5173" || - window.location.port === "" || - window.location.hostname === "localhost" || - window.location.hostname === "127.0.0.1"); - - const apiUrl = isElectron() - ? `${(window as { configuredServerUrl?: string }).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 summary = result.summary; - const imported = - summary.sshHostsImported + - summary.sshCredentialsImported + - summary.fileManagerItemsImported + - summary.dismissedAlertsImported + - (summary.settingsImported || 0); - const skipped = summary.skippedItems; - - const details = []; - if (summary.sshHostsImported > 0) - details.push(`${summary.sshHostsImported} SSH hosts`); - if (summary.sshCredentialsImported > 0) - details.push(`${summary.sshCredentialsImported} credentials`); - if (summary.fileManagerItemsImported > 0) - details.push( - `${summary.fileManagerItemsImported} file manager items`, - ); - if (summary.dismissedAlertsImported > 0) - details.push(`${summary.dismissedAlertsImported} alerts`); - if (summary.settingsImported > 0) - details.push(`${summary.settingsImported} settings`); - - toast.success( - `Import completed: ${imported} items imported${details.length > 0 ? ` (${details.join(", ")})` : ""}, ${skipped} items skipped`, - ); - setImportFile(null); - - setTimeout(() => { - window.location.reload(); - }, 1500); - } else { - toast.error( - `${t("admin.databaseImportFailed")}: ${result.summary?.errors?.join(", ") || "Unknown error"}`, - ); - } - } else { - const error = await response.json(); - toast.error(error.error || t("admin.databaseImportFailed")); - } - } catch { - toast.error(t("admin.databaseImportFailed")); - } finally { - setImportLoading(false); - } - }; - - return ( -
-

{t("admin.databaseSecurity")}

- -
-
-
-
- -

{t("admin.export")}

-
-

- {t("admin.exportDescription")} -

- -
-
- -
-
-
- -

{t("admin.import")}

-
-

- {t("admin.importDescription")} -

-
- setImportFile(e.target.files?.[0] || null)} - className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" - /> - -
- -
-
-
-
- ); -} diff --git a/src/ui/admin/tabs/GeneralSettingsTab.tsx b/src/ui/admin/tabs/GeneralSettingsTab.tsx deleted file mode 100644 index 84fe24eb..00000000 --- a/src/ui/admin/tabs/GeneralSettingsTab.tsx +++ /dev/null @@ -1,536 +0,0 @@ -import React from "react"; -import { Checkbox } from "@/components/checkbox.tsx"; -import { Input } from "@/components/input.tsx"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/select.tsx"; -import { useTranslation } from "react-i18next"; -import { toast } from "sonner"; -import { useConfirmation } from "@/hooks/use-confirmation.ts"; -import { - updateRegistrationAllowed, - updatePasswordLoginAllowed, - updatePasswordResetAllowed, - getGlobalMonitoringSettings, - updateGlobalMonitoringSettings, - getGuacamoleSettings, - updateGuacamoleSettings, - getLogLevel, - updateLogLevel, - getSessionTimeout, - updateSessionTimeout, -} from "@/main-axios.ts"; -import { Button } from "@/components/button.tsx"; - -interface GeneralSettingsTabProps { - allowRegistration: boolean; - setAllowRegistration: (value: boolean) => void; - allowPasswordLogin: boolean; - setAllowPasswordLogin: (value: boolean) => void; - allowPasswordReset: boolean; - setAllowPasswordReset: (value: boolean) => void; - oidcConfig: { - client_id: string; - client_secret: string; - issuer_url: string; - authorization_url: string; - token_url: string; - }; -} - -export function GeneralSettingsTab({ - allowRegistration, - setAllowRegistration, - allowPasswordLogin, - setAllowPasswordLogin, - allowPasswordReset, - setAllowPasswordReset, - oidcConfig, -}: GeneralSettingsTabProps): React.ReactElement { - const { t } = useTranslation(); - const { confirmWithToast } = useConfirmation(); - - const [regLoading, setRegLoading] = React.useState(false); - const [passwordLoginLoading, setPasswordLoginLoading] = React.useState(false); - const [passwordResetLoading, setPasswordResetLoading] = React.useState(false); - - const [statusInterval, setStatusInterval] = React.useState(60); - const [metricsInterval, setMetricsInterval] = React.useState(30); - const [statusInputValue, setStatusInputValue] = React.useState("60"); - const [metricsInputValue, setMetricsInputValue] = React.useState("30"); - const [statusUnit, setStatusUnit] = React.useState<"seconds" | "minutes">( - "seconds", - ); - const [metricsUnit, setMetricsUnit] = React.useState<"seconds" | "minutes">( - "seconds", - ); - const [monitoringLoading, setMonitoringLoading] = React.useState(false); - - const [logLevel, setLogLevel] = React.useState("info"); - const [logLevelLoading, setLogLevelLoading] = React.useState(false); - - const [, setSessionTimeoutHours] = React.useState(24); - const [sessionTimeoutInput, setSessionTimeoutInput] = React.useState("24"); - const [sessionTimeoutLoading, setSessionTimeoutLoading] = - React.useState(false); - - const [guacEnabled, setGuacEnabled] = React.useState(true); - const [guacUrl, setGuacUrl] = React.useState("guacd:4822"); - const [guacLoading, setGuacLoading] = React.useState(false); - - React.useEffect(() => { - getLogLevel() - .then((data) => { - setLogLevel(data.level); - }) - .catch(() => {}); - }, []); - - React.useEffect(() => { - getSessionTimeout() - .then((data) => { - setSessionTimeoutHours(data.timeoutHours); - setSessionTimeoutInput(String(data.timeoutHours)); - }) - .catch(() => {}); - }, []); - - const handleLogLevelChange = async (value: string) => { - setLogLevel(value); - setLogLevelLoading(true); - try { - await updateLogLevel(value); - toast.success(t("admin.logLevelSaved")); - } catch { - toast.error(t("admin.failedToSaveLogLevel")); - } finally { - setLogLevelLoading(false); - } - }; - - const handleSessionTimeoutBlur = async () => { - const num = parseInt(sessionTimeoutInput) || 24; - const clamped = Math.max(1, Math.min(720, num)); - setSessionTimeoutHours(clamped); - setSessionTimeoutInput(String(clamped)); - setSessionTimeoutLoading(true); - try { - await updateSessionTimeout(clamped); - toast.success(t("admin.sessionTimeoutSaved")); - } catch { - toast.error(t("admin.failedToSaveSessionTimeout")); - } finally { - setSessionTimeoutLoading(false); - } - }; - - React.useEffect(() => { - getGuacamoleSettings() - .then((data) => { - setGuacEnabled(data.enabled); - setGuacUrl(data.url); - }) - .catch(() => { - toast.error(t("admin.failedToLoadGuacamoleSettings")); - }); - }, [t]); - - const saveGuacDebounce = React.useRef(null); - - const saveGuacSettings = React.useCallback( - (newEnabled: boolean, newUrl: string) => { - if (saveGuacDebounce.current) { - clearTimeout(saveGuacDebounce.current); - } - saveGuacDebounce.current = setTimeout(async () => { - setGuacLoading(true); - try { - await updateGuacamoleSettings({ enabled: newEnabled, url: newUrl }); - toast.success(t("admin.guacamoleSettingsSaved")); - } catch { - toast.error(t("admin.failedToSaveGuacamoleSettings")); - } finally { - setGuacLoading(false); - } - }, 800); - }, - [t], - ); - - React.useEffect(() => { - getGlobalMonitoringSettings() - .then((data) => { - setStatusInterval(data.statusCheckInterval); - setMetricsInterval(data.metricsInterval); - setStatusInputValue(String(data.statusCheckInterval)); - setMetricsInputValue(String(data.metricsInterval)); - }) - .catch(() => { - // Use defaults silently - }); - }, []); - - const saveMonitoringSettings = React.useCallback( - async (newStatus: number, newMetrics: number) => { - setMonitoringLoading(true); - try { - await updateGlobalMonitoringSettings({ - statusCheckInterval: newStatus, - metricsInterval: newMetrics, - }); - toast.success(t("admin.globalSettingsSaved")); - } catch (error) { - const errorMessage = - error instanceof Error - ? error.message - : t("admin.failedToSaveGlobalSettings"); - toast.error(errorMessage); - } finally { - setMonitoringLoading(false); - } - }, - [t], - ); - - const handleStatusBlur = () => { - const num = parseInt(statusInputValue) || 0; - const seconds = statusUnit === "minutes" ? num * 60 : num; - const clamped = Math.max(5, Math.min(3600, seconds)); - setStatusInterval(clamped); - setStatusInputValue( - statusUnit === "minutes" - ? String(Math.round(clamped / 60)) - : String(clamped), - ); - saveMonitoringSettings(clamped, metricsInterval); - }; - - const handleMetricsBlur = () => { - const num = parseInt(metricsInputValue) || 0; - const seconds = metricsUnit === "minutes" ? num * 60 : num; - const clamped = Math.max(5, Math.min(3600, seconds)); - setMetricsInterval(clamped); - setMetricsInputValue( - metricsUnit === "minutes" - ? String(Math.round(clamped / 60)) - : String(clamped), - ); - saveMonitoringSettings(statusInterval, clamped); - }; - - const handleToggleRegistration = async (checked: boolean) => { - setRegLoading(true); - try { - await updateRegistrationAllowed(checked); - setAllowRegistration(checked); - } finally { - setRegLoading(false); - } - }; - - const handleTogglePasswordLogin = async (checked: boolean) => { - if (!checked) { - const hasOIDCConfigured = - oidcConfig.client_id && - oidcConfig.client_secret && - oidcConfig.issuer_url && - oidcConfig.authorization_url && - oidcConfig.token_url; - - if (!hasOIDCConfigured) { - toast.error(t("admin.cannotDisablePasswordLoginWithoutOIDC"), { - duration: 5000, - }); - return; - } - - confirmWithToast( - t("admin.confirmDisablePasswordLogin"), - async () => { - setPasswordLoginLoading(true); - try { - await updatePasswordLoginAllowed(checked); - setAllowPasswordLogin(checked); - - if (allowRegistration) { - await updateRegistrationAllowed(false); - setAllowRegistration(false); - toast.success(t("admin.passwordLoginAndRegistrationDisabled")); - } else { - toast.success(t("admin.passwordLoginDisabled")); - } - } catch { - toast.error(t("admin.failedToUpdatePasswordLoginStatus")); - } finally { - setPasswordLoginLoading(false); - } - }, - "destructive", - ); - return; - } - - setPasswordLoginLoading(true); - try { - await updatePasswordLoginAllowed(checked); - setAllowPasswordLogin(checked); - } finally { - setPasswordLoginLoading(false); - } - }; - - const handleTogglePasswordReset = async (checked: boolean) => { - setPasswordResetLoading(true); - try { - await updatePasswordResetAllowed(checked); - setAllowPasswordReset(checked); - } finally { - setPasswordResetLoading(false); - } - }; - - return ( -
-
-

{t("admin.userRegistration")}

- - -
- -

- {t("admin.allowPasswordResetDesc")} -

-
-
- -
-

{t("admin.sessionTimeout")}

-

- {t("admin.sessionTimeoutDesc")} -

-
- -
- setSessionTimeoutInput(e.target.value)} - onBlur={handleSessionTimeoutBlur} - disabled={sessionTimeoutLoading} - className="flex-1" - /> - {t("admin.hours")} -
-

- {t("admin.sessionTimeoutNote")} -

-
-
- -
-

- {t("admin.monitoringDefaults")} -

-

- {t("admin.monitoringDefaultsDesc")} -

-
-
- -
- setStatusInputValue(e.target.value)} - onBlur={handleStatusBlur} - disabled={monitoringLoading} - className="flex-1" - /> - -
-
-
- -
- setMetricsInputValue(e.target.value)} - onBlur={handleMetricsBlur} - disabled={monitoringLoading} - className="flex-1" - /> - -
-
-
-
- -
-

- {t("admin.guacamoleIntegration")} -

-

- {t("admin.guacamoleIntegrationDesc")} -

- - - {guacEnabled && ( -
- - { - setGuacUrl(e.target.value); - saveGuacSettings(guacEnabled, e.target.value); - }} - /> -

- {t("admin.guacdUrlNote")} -

-
- )} -
- -
-

{t("admin.logLevel")}

-

- {t("admin.logLevelDesc")} -

-
- - -

- {t("admin.logLevelNote")} -

-
-
-
- ); -} diff --git a/src/ui/admin/tabs/OIDCSettingsTab.tsx b/src/ui/admin/tabs/OIDCSettingsTab.tsx deleted file mode 100644 index 71318212..00000000 --- a/src/ui/admin/tabs/OIDCSettingsTab.tsx +++ /dev/null @@ -1,339 +0,0 @@ -import React from "react"; -import { Button } from "@/components/button.tsx"; -import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx"; -import { Input } from "@/components/input.tsx"; -import { PasswordInput } from "@/components/password-input.tsx"; -import { Label } from "@/components/label.tsx"; -import { useTranslation } from "react-i18next"; -import { toast } from "sonner"; -import { useConfirmation } from "@/hooks/use-confirmation.ts"; -import { Textarea } from "@/components/textarea.tsx"; -import { updateOIDCConfig, disableOIDCConfig } from "@/main-axios.ts"; - -interface OIDCSettingsTabProps { - allowPasswordLogin: boolean; - oidcConfig: { - client_id: string; - client_secret: string; - issuer_url: string; - authorization_url: string; - token_url: string; - identifier_path: string; - name_path: string; - scopes: string; - userinfo_url: string; - allowed_users: string; - }; - setOidcConfig: React.Dispatch< - React.SetStateAction<{ - client_id: string; - client_secret: string; - issuer_url: string; - authorization_url: string; - token_url: string; - identifier_path: string; - name_path: string; - scopes: string; - userinfo_url: string; - allowed_users: string; - }> - >; -} - -export function OIDCSettingsTab({ - allowPasswordLogin, - oidcConfig, - setOidcConfig, -}: OIDCSettingsTabProps): React.ReactElement { - const { t } = useTranslation(); - const { confirmWithToast } = useConfirmation(); - - const [oidcLoading, setOidcLoading] = React.useState(false); - const [oidcError, setOidcError] = React.useState(null); - - const handleOIDCConfigSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setOidcLoading(true); - setOidcError(null); - - const required = [ - "client_id", - "client_secret", - "issuer_url", - "authorization_url", - "token_url", - ]; - const missing = required.filter( - (f) => !oidcConfig[f as keyof typeof oidcConfig], - ); - if (missing.length > 0) { - setOidcError( - t("admin.missingRequiredFields", { fields: missing.join(", ") }), - ); - setOidcLoading(false); - return; - } - - try { - await updateOIDCConfig(oidcConfig); - toast.success(t("admin.oidcConfigurationUpdated")); - } catch (err: unknown) { - setOidcError( - (err as { response?: { data?: { error?: string } } })?.response?.data - ?.error || t("admin.failedToUpdateOidcConfig"), - ); - } finally { - setOidcLoading(false); - } - }; - - const handleOIDCConfigChange = (field: string, value: string) => { - setOidcConfig((prev) => ({ ...prev, [field]: value })); - }; - - const handleResetConfig = async () => { - if (!allowPasswordLogin) { - confirmWithToast( - t("admin.confirmDisableOIDCWarning"), - async () => { - const emptyConfig = { - client_id: "", - client_secret: "", - issuer_url: "", - authorization_url: "", - token_url: "", - identifier_path: "", - name_path: "", - scopes: "", - userinfo_url: "", - allowed_users: "", - }; - setOidcConfig(emptyConfig); - setOidcError(null); - setOidcLoading(true); - try { - await disableOIDCConfig(); - toast.success(t("admin.oidcConfigurationDisabled")); - } catch (err: unknown) { - setOidcError( - ( - err as { - response?: { data?: { error?: string } }; - } - )?.response?.data?.error || t("admin.failedToDisableOidcConfig"), - ); - } finally { - setOidcLoading(false); - } - }, - "destructive", - ); - return; - } - - const emptyConfig = { - client_id: "", - client_secret: "", - issuer_url: "", - authorization_url: "", - token_url: "", - identifier_path: "", - name_path: "", - scopes: "", - userinfo_url: "", - allowed_users: "", - }; - setOidcConfig(emptyConfig); - setOidcError(null); - setOidcLoading(true); - try { - await disableOIDCConfig(); - toast.success(t("admin.oidcConfigurationDisabled")); - } catch (err: unknown) { - setOidcError( - ( - err as { - response?: { data?: { error?: string } }; - } - )?.response?.data?.error || t("admin.failedToDisableOidcConfig"), - ); - } finally { - setOidcLoading(false); - } - }; - - return ( -
-

- {t("admin.externalAuthentication")} -

-
-

- {t("admin.configureExternalProvider")} -

- -
- - {!allowPasswordLogin && ( - - {t("admin.criticalWarning")} - {t("admin.oidcRequiredWarning")} - - )} - - {oidcError && ( - - {t("common.error")} - {oidcError} - - )} - -
-
- - - handleOIDCConfigChange("client_id", e.target.value) - } - placeholder={t("placeholders.clientId")} - required - /> -
-
- - - handleOIDCConfigChange("client_secret", e.target.value) - } - placeholder={t("placeholders.clientSecret")} - required - /> -
-
- - - handleOIDCConfigChange("authorization_url", e.target.value) - } - placeholder={t("placeholders.authUrl")} - required - /> -
-
- - - handleOIDCConfigChange("issuer_url", e.target.value) - } - placeholder={t("placeholders.redirectUrl")} - required - /> -
-
- - - handleOIDCConfigChange("token_url", e.target.value) - } - placeholder={t("placeholders.tokenUrl")} - required - /> -
-
- - - handleOIDCConfigChange("identifier_path", e.target.value) - } - placeholder={t("placeholders.userIdField")} - required - /> -
-
- - - handleOIDCConfigChange("name_path", e.target.value) - } - placeholder={t("placeholders.usernameField")} - required - /> -
-
- - handleOIDCConfigChange("scopes", e.target.value)} - placeholder={t("placeholders.scopes")} - required - /> -
-
- - - handleOIDCConfigChange("userinfo_url", e.target.value) - } - placeholder="https://your-provider.com/application/o/userinfo/" - /> -
-
- -

- {t("admin.allowedUsersDescription")} -

-