diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts index 58e13c97..8e5bd024 100644 --- a/src/backend/database/routes/credentials.ts +++ b/src/backend/database/routes/credentials.ts @@ -1617,9 +1617,12 @@ async function deploySSHKeyToHost( const escapedKey = actualPublicKey .replace(/\\/g, "\\\\") .replace(/'/g, "'\\''"); + const escapedName = credData.name + .replace(/\\/g, "\\\\") + .replace(/'/g, "'\\''"); conn.exec( - `printf '%s\n' '${escapedKey} ${credData.name}@Termix' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`, + `printf '%s\n' '${escapedKey} ${escapedName}@Termix' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`, (err, stream) => { if (err) { clearTimeout(addTimeout); diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index d998f752..c24fc9a3 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -43,6 +43,8 @@ const router = express.Router(); const upload = multer({ storage: multer.memoryStorage() }); +const STATS_SERVER_URL = "http://localhost:30005"; + function notifyStatsHostUpdated( hostId: number, headers: Pick, @@ -50,7 +52,7 @@ function notifyStatsHostUpdated( ): void { axios .post( - "http://localhost:30005/host-updated", + `${STATS_SERVER_URL}/host-updated`, { hostId }, { headers: { @@ -1926,9 +1928,21 @@ router.get( const exportData = isRemoteDesktop ? { ...baseExportData, - domain: resolvedHost.domain || null, - security: resolvedHost.security || null, - ignoreCert: !!resolvedHost.ignoreCert, + enableRdp: !!resolvedHost.enableRdp, + enableVnc: !!resolvedHost.enableVnc, + enableTelnet: !!resolvedHost.enableTelnet, + rdpPort: resolvedHost.rdpPort || 3389, + vncPort: resolvedHost.vncPort || 5900, + telnetPort: resolvedHost.telnetPort || 23, + rdpUser: resolvedHost.rdpUser || null, + rdpPassword: resolvedHost.rdpPassword || null, + rdpDomain: resolvedHost.rdpDomain || null, + rdpSecurity: resolvedHost.rdpSecurity || null, + rdpIgnoreCert: !!resolvedHost.rdpIgnoreCert, + vncUser: resolvedHost.vncUser || null, + vncPassword: resolvedHost.vncPassword || null, + telnetUser: resolvedHost.telnetUser || null, + telnetPassword: resolvedHost.telnetPassword || null, guacamoleConfig: resolvedHost.guacamoleConfig ? JSON.parse(resolvedHost.guacamoleConfig as string) : null, @@ -2252,9 +2266,8 @@ router.delete( try { const axios = (await import("axios")).default; - const statsPort = 30005; await axios.post( - `http://localhost:${statsPort}/host-deleted`, + `${STATS_SERVER_URL}/host-deleted`, { hostId: numericHostId }, { headers: { @@ -3429,11 +3442,10 @@ router.delete( try { const axios = (await import("axios")).default; - const statsPort = 30005; for (const host of hostsToDelete) { try { await axios.post( - `http://localhost:${statsPort}/host-deleted`, + `${STATS_SERVER_URL}/host-deleted`, { hostId: host.id }, { headers: { @@ -3853,9 +3865,21 @@ router.post( sshDataObj.key = null; sshDataObj.keyPassword = null; sshDataObj.keyType = null; - sshDataObj.domain = hostData.domain || null; - sshDataObj.security = hostData.security || null; - sshDataObj.ignoreCert = hostData.ignoreCert ? 1 : 0; + sshDataObj.rdpUser = hostData.rdpUser || null; + sshDataObj.rdpPassword = hostData.rdpPassword || null; + sshDataObj.rdpDomain = hostData.rdpDomain || null; + sshDataObj.rdpSecurity = hostData.rdpSecurity || null; + sshDataObj.rdpIgnoreCert = hostData.rdpIgnoreCert ? 1 : 0; + sshDataObj.rdpPort = hostData.rdpPort || 3389; + sshDataObj.vncUser = hostData.vncUser || null; + sshDataObj.vncPassword = hostData.vncPassword || null; + sshDataObj.vncPort = hostData.vncPort || 5900; + sshDataObj.telnetUser = hostData.telnetUser || null; + sshDataObj.telnetPassword = hostData.telnetPassword || null; + sshDataObj.telnetPort = hostData.telnetPort || 23; + sshDataObj.enableRdp = hostData.enableRdp ? 1 : 0; + sshDataObj.enableVnc = hostData.enableVnc ? 1 : 0; + sshDataObj.enableTelnet = hostData.enableTelnet ? 1 : 0; sshDataObj.guacamoleConfig = hostData.guacamoleConfig ? JSON.stringify(hostData.guacamoleConfig) : null; diff --git a/src/backend/guacamole/routes.ts b/src/backend/guacamole/routes.ts index b9841c00..bd8cae6e 100644 --- a/src/backend/guacamole/routes.ts +++ b/src/backend/guacamole/routes.ts @@ -18,18 +18,52 @@ const authManager = AuthManager.getInstance(); router.use(authManager.createAuthMiddleware()); /** - * POST /guacamole/token - * Generate an encrypted connection token for guacamole-lite - * - * Body: { - * type: "rdp" | "vnc" | "telnet", - * hostname: string, - * port?: number, - * username?: string, - * password?: string, - * domain?: string, - * // Additional protocol-specific options - * } + * @openapi + * /guacamole/token: + * post: + * summary: Generate an encrypted Guacamole connection token + * description: Creates an AES-256-CBC encrypted token for guacamole-lite with the given connection parameters + * tags: + * - Guacamole + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - type + * - hostname + * properties: + * type: + * type: string + * enum: [rdp, vnc, telnet] + * hostname: + * type: string + * port: + * type: integer + * username: + * type: string + * password: + * type: string + * domain: + * type: string + * responses: + * 200: + * description: Encrypted connection token + * content: + * application/json: + * schema: + * type: object + * properties: + * token: + * type: string + * 400: + * description: Invalid request + * 500: + * description: Server error */ router.post("/token", async (req, res) => { try { @@ -203,9 +237,35 @@ router.post( let token: string; let hostname = host.ip as string; let port = host.port as number; - const username = (host.username as string) || ""; - const password = (host.password as string) || ""; - const domain = (host.domain as string) || ""; + let username: string; + let password: string; + + switch (connectionType) { + case "rdp": + username = + (host.rdpUser as string) || (host.username as string) || ""; + password = + (host.rdpPassword as string) || (host.password as string) || ""; + port = (host.rdpPort as number) || port || 3389; + break; + case "vnc": + username = (host.vncUser as string) || ""; + password = + (host.vncPassword as string) || (host.password as string) || ""; + port = (host.vncPort as number) || port || 5900; + break; + case "telnet": + username = (host.telnetUser as string) || ""; + password = + (host.telnetPassword as string) || (host.password as string) || ""; + port = (host.telnetPort as number) || port || 23; + break; + default: + username = ""; + password = ""; + } + const domain = + (host.rdpDomain as string) || (host.domain as string) || ""; // Establish SSH tunnel if jump hosts are configured let jumpHosts: Array<{ hostId: number }> = []; @@ -299,11 +359,18 @@ router.post( guacConfig["create-drive-path"] = true; } token = tokenService.createRdpToken(hostname, username, password, { - port: port || 3389, + port, domain, - security: (host.security as string) || undefined, + security: + (host.rdpSecurity as string) || + (host.security as string) || + undefined, "ignore-cert": - host.ignoreCert !== undefined ? !!host.ignoreCert : true, + host.rdpIgnoreCert !== undefined + ? !!host.rdpIgnoreCert + : host.ignoreCert !== undefined + ? !!host.ignoreCert + : true, ...guacConfig, }); break; @@ -313,7 +380,7 @@ router.post( username || undefined, password, { - port: port || 5900, + port, security: "any", ...guacConfig, }, @@ -321,7 +388,7 @@ router.post( break; case "telnet": token = tokenService.createTelnetToken(hostname, username, password, { - port: port || 23, + port, ...guacConfig, }); break; diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index ab8e251c..15534e1b 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -693,7 +693,7 @@ export function AppShell({ return (
= ({ hostId }) => { ); } + if (!hostId) { + return ( +
+ + + {t("guacamole.hostNotFound")} + +
+ ); + } + return ( ); @@ -82,9 +103,7 @@ const GuacamoleAppInner: React.FC = ({ getGuacdStatus() .then((status) => { if (status.guacd.status !== "connected") { - setError( - "Remote desktop service (guacd) is not available. Please ensure guacd is running and accessible and configured properly in admin settings.", - ); + setError(t("guacamole.guacdUnavailable")); return; } return getGuacamoleTokenFromHost(hostId); @@ -102,18 +121,6 @@ const GuacamoleAppInner: React.FC = ({ setRetryCount((c) => c + 1); }, []); - const sendCtrlAltDelete = useCallback(() => { - const display = displayRef.current; - if (!display) return; - // keysyms: Control_L=0xffe3, Alt_L=0xffe9, Delete=0xffff - display.sendKey(0xffe3, true); - display.sendKey(0xffe9, true); - display.sendKey(0xffff, true); - display.sendKey(0xffff, false); - display.sendKey(0xffe9, false); - display.sendKey(0xffe3, false); - }, []); - if (error) { return (
= ({
)} setConnectionError(err)} /> - {(protocol === "rdp" || protocol === "vnc") && ( - - )} +
); }; diff --git a/src/ui/features/guacamole/GuacamoleToolbar.tsx b/src/ui/features/guacamole/GuacamoleToolbar.tsx new file mode 100644 index 00000000..103dcb36 --- /dev/null +++ b/src/ui/features/guacamole/GuacamoleToolbar.tsx @@ -0,0 +1,487 @@ +import React, { + useState, + useRef, + useCallback, + useLayoutEffect, + useEffect, +} from "react"; +import { + GripVertical, + Monitor, + RefreshCw, + ChevronLeft, + ChevronRight, + ChevronUp, + ChevronDown, + ChevronsLeftRight, +} from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/tooltip.tsx"; +import type { GuacamoleDisplayHandle } from "@/features/guacamole/GuacamoleDisplay.tsx"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/utils"; + +interface GuacamoleToolbarProps { + displayRef: React.RefObject; + protocol: "rdp" | "vnc" | "telnet"; + onReconnect: () => void; +} + +const MODIFIER_KEYSYMS = { + ctrl: 0xffe3, + alt: 0xffe9, + shift: 0xffe1, + win: 0xff67, +} as const; + +const FKEY_KEYSYMS = Array.from({ length: 12 }, (_, i) => 0xffbe + i); + +const BTN_BASE = + "flex items-center justify-center gap-1 h-7 px-2 text-[10px] font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors rounded-sm whitespace-nowrap select-none"; + +const BTN_ICON = + "flex items-center justify-center size-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors rounded-sm select-none"; + +const SEP = "w-px h-5 bg-border mx-0.5 shrink-0"; + +function TipBtn({ + tooltip, + onClick, + className, + children, +}: { + tooltip: string; + onClick: () => void; + className?: string; + children: React.ReactNode; +}) { + return ( + + + + + + {tooltip} + + + ); +} + +function TipIconBtn({ + tooltip, + onClick, + className, + children, +}: { + tooltip: string; + onClick: () => void; + className?: string; + children: React.ReactNode; +}) { + return ( + + + + + + {tooltip} + + + ); +} + +export const GuacamoleToolbar: React.FC = ({ + displayRef, + protocol, + onReconnect, +}) => { + const { t } = useTranslation(); + const [position, setPosition] = useState({ x: 0, y: 12 }); + const [collapsed, setCollapsed] = useState(false); + const [showFKeys, setShowFKeys] = useState(false); + const [stickyKeys, setStickyKeys] = useState>({ + [MODIFIER_KEYSYMS.ctrl]: false, + [MODIFIER_KEYSYMS.alt]: false, + [MODIFIER_KEYSYMS.shift]: false, + [MODIFIER_KEYSYMS.win]: false, + }); + + const toolbarRef = useRef(null); + const isDraggingRef = useRef(false); + const dragOriginRef = useRef({ mouseX: 0, mouseY: 0, posX: 0, posY: 0 }); + const [isDragging, setIsDragging] = useState(false); + + useLayoutEffect(() => { + const el = toolbarRef.current; + if (!el) return; + const parent = el.offsetParent as HTMLElement | null; + if (!parent) return; + const parentW = parent.clientWidth; + const toolbarW = el.offsetWidth; + setPosition((p) => ({ ...p, x: Math.max(0, (parentW - toolbarW) / 2) })); + }, [collapsed]); + + useEffect(() => { + if (!isDragging) return; + + const onMove = (e: MouseEvent) => { + if (!isDraggingRef.current) return; + const parent = toolbarRef.current?.offsetParent as HTMLElement | null; + const parentW = parent?.clientWidth ?? Infinity; + const parentH = parent?.clientHeight ?? Infinity; + const toolbarW = toolbarRef.current?.offsetWidth ?? 0; + const toolbarH = toolbarRef.current?.offsetHeight ?? 0; + + const dx = e.clientX - dragOriginRef.current.mouseX; + const dy = e.clientY - dragOriginRef.current.mouseY; + setPosition({ + x: Math.max( + 0, + Math.min(dragOriginRef.current.posX + dx, parentW - toolbarW), + ), + y: Math.max( + 0, + Math.min(dragOriginRef.current.posY + dy, parentH - toolbarH), + ), + }); + }; + + const onUp = () => { + isDraggingRef.current = false; + setIsDragging(false); + document.body.style.userSelect = ""; + }; + + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + return () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + }, [isDragging]); + + const startDrag = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + isDraggingRef.current = true; + dragOriginRef.current = { + mouseX: e.clientX, + mouseY: e.clientY, + posX: position.x, + posY: position.y, + }; + document.body.style.userSelect = "none"; + setIsDragging(true); + }, + [position], + ); + + const releaseStickyKeys = useCallback(() => { + const display = displayRef.current; + if (!display) return; + setStickyKeys((prev) => { + const next = { ...prev }; + for (const [ksStr, active] of Object.entries(prev)) { + if (active) { + display.sendKey(Number(ksStr), false); + next[Number(ksStr)] = false; + } + } + return next; + }); + }, [displayRef]); + + const sendCombo = useCallback( + (...keysyms: number[]) => { + const display = displayRef.current; + if (!display) return; + for (const k of keysyms) display.sendKey(k, true); + for (const k of [...keysyms].reverse()) display.sendKey(k, false); + releaseStickyKeys(); + }, + [displayRef, releaseStickyKeys], + ); + + const toggleStickyKey = useCallback( + (keysym: number) => { + const display = displayRef.current; + if (!display) return; + setStickyKeys((prev) => { + const isActive = prev[keysym]; + display.sendKey(keysym, !isActive); + return { ...prev, [keysym]: !isActive }; + }); + }, + [displayRef], + ); + + const isRdpVnc = protocol === "rdp" || protocol === "vnc"; + + const containerStyle: React.CSSProperties = { + position: "absolute", + left: position.x, + top: position.y, + zIndex: 20, + }; + + if (collapsed) { + return ( +
e.preventDefault()} + > + + +
+ +
+ +
+ + + {t("guacamole.toolbar.expand")} + + +
+ ); + } + + return ( + +
e.preventDefault()} + className="flex items-center bg-background/85 backdrop-blur-sm border border-border shadow-lg rounded-sm px-0.5 py-0.5 gap-0" + > + {/* Drag handle */} + + + + + + {t("guacamole.toolbar.dragHandle")} + + + + {/* System combos — RDP/VNC only */} + {isRdpVnc && ( + <> +
+ sendCombo(0xffe3, 0xffe9, 0xffff)} + > + CAD + + sendCombo(0xff67, 0x006c)} + > + Win+L + + sendCombo(0xff67)} + > + Win + + + )} + + {/* Sticky modifiers — RDP/VNC only */} + {isRdpVnc && ( + <> +
+ {( + [ + ["ctrl", MODIFIER_KEYSYMS.ctrl, t("guacamole.toolbar.ctrl")], + ["alt", MODIFIER_KEYSYMS.alt, t("guacamole.toolbar.alt")], + ["shift", MODIFIER_KEYSYMS.shift, t("guacamole.toolbar.shift")], + ["win", MODIFIER_KEYSYMS.win, t("guacamole.toolbar.win")], + ] as [string, number, string][] + ).map(([key, ks, label]) => ( + + + + + + {stickyKeys[ks] + ? t("guacamole.toolbar.stickyActive", { key: label }) + : t("guacamole.toolbar.stickyInactive", { key: label })} + + + ))} + + )} + + {/* Function key toggle */} +
+ setShowFKeys((v) => !v)} + className={cn( + showFKeys && "bg-primary/15 text-primary border border-primary/30", + )} + > + Fn + + + {/* F1-F12 row */} + {showFKeys && + FKEY_KEYSYMS.map((ks, i) => ( + sendCombo(ks)} + > + F{i + 1} + + ))} + + {/* Navigation */} +
+ sendCombo(0xff1b)} + > + Esc + + sendCombo(0xff09)} + > + Tab + + sendCombo(0xff50)} + > + Home + + sendCombo(0xff57)} + > + End + + sendCombo(0xff55)} + > + PgUp + + sendCombo(0xff56)} + > + PgDn + + + {/* Arrow cluster */} +
+
+ sendCombo(0xff52)} + > + + +
+
+ sendCombo(0xff51)} + > + + + sendCombo(0xff54)} + > + + + sendCombo(0xff53)} + > + + +
+
+ + {/* Session */} +
+ + + + + {/* Collapse */} +
+ + + + + + {t("guacamole.toolbar.collapse")} + + +
+ + ); +}; diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 625fd658..ee172558 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -182,6 +182,9 @@ "keyType": "Key Type", "uploadFile": "Upload File", "tabGeneral": "General", + "tabSsh": "SSH", + "tabRdp": "RDP", + "tabVnc": "VNC", "tabTunnels": "Tunnels", "tabDocker": "Docker", "tabFiles": "Files", @@ -721,7 +724,35 @@ "hostNotFound": "Host not found", "noHostSelected": "No host selected", "reconnect": "Reconnect", - "retry": "Retry" + "retry": "Retry", + "guacdUnavailable": "Remote desktop service (guacd) is not available. Please ensure guacd is running and accessible and configured properly in admin settings.", + "ctrlAltDel": "Ctrl+Alt+Del", + "toolbar": { + "ctrlAltDel": "Ctrl+Alt+Del", + "winL": "Win+L (Lock Screen)", + "winKey": "Windows Key", + "ctrl": "Ctrl", + "alt": "Alt", + "shift": "Shift", + "win": "Win", + "stickyActive": "{{key}} (latched - click to release)", + "stickyInactive": "{{key}} (click to latch)", + "esc": "Escape", + "tab": "Tab", + "home": "Home", + "end": "End", + "pageUp": "Page Up", + "pageDown": "Page Down", + "arrowUp": "Arrow Up", + "arrowDown": "Arrow Down", + "arrowLeft": "Arrow Left", + "arrowRight": "Arrow Right", + "fnToggle": "Function Keys", + "reconnect": "Reconnect Session", + "collapse": "Collapse toolbar", + "expand": "Expand toolbar", + "dragHandle": "Drag to reposition" + } }, "terminal": { "connect": "Connect to Host", @@ -1516,6 +1547,176 @@ "consoleTab": "Console", "startContainerToAccess": "Start the container to access the console" }, + "admin": { + "sectionGeneral": "General", + "sectionOidc": "OIDC", + "sectionUsers": "Users", + "sectionSessions": "Sessions", + "sectionRoles": "Roles", + "sectionDatabase": "Database", + "sectionApiKeys": "API Keys", + "allowRegistration": "Allow User Registration", + "allowRegistrationDesc": "Let new users self-register", + "allowPasswordLogin": "Allow Password Login", + "allowPasswordLoginDesc": "Username/password login", + "oidcAutoProvision": "OIDC Auto-Provision", + "oidcAutoProvisionDesc": "Auto-create accounts for OIDC users even when registration is disabled", + "allowPasswordReset": "Allow Password Reset", + "allowPasswordResetDesc": "Reset code via Docker logs", + "sessionTimeout": "Session Timeout", + "hours": "hours", + "sessionTimeoutRange": "Min 1h · Max 720h", + "monitoringDefaults": "Monitoring Defaults", + "statusCheck": "Status Check", + "metrics": "Metrics", + "sec": "sec", + "logLevel": "Log Level", + "enableGuacamole": "Enable Guacamole", + "enableGuacamoleDesc": "RDP/VNC remote desktop", + "guacdUrl": "guacd URL", + "oidcDescription": "Configure OpenID Connect for SSO. Fields marked * are required.", + "oidcClientId": "Client ID", + "oidcClientSecret": "Client Secret", + "oidcAuthUrl": "Authorization URL", + "oidcIssuerUrl": "Issuer URL", + "oidcTokenUrl": "Token URL", + "oidcUserIdentifier": "User Identifier Path", + "oidcDisplayName": "Display Name Path", + "oidcScopes": "Scopes", + "oidcUserinfoUrl": "Override Userinfo URL", + "oidcAllowedUsers": "Allowed Users", + "oidcAllowedUsersDesc": "One email per line. Leave empty to allow all.", + "removeOidc": "Remove", + "usersCount": "{{count}} users", + "createUser": "Create", + "newRole": "New Role", + "roleName": "Name", + "roleDisplayName": "Display Name", + "roleDescription": "Description", + "rolesCount": "{{count}} roles", + "createRole": "Create", + "creating": "Creating...", + "exportDatabase": "Export Database", + "exportDatabaseDesc": "Download a backup of all hosts, credentials, and settings", + "export": "Export", + "exporting": "Exporting...", + "importDatabase": "Import Database", + "importDatabaseDesc": "Restore from a .sqlite backup file", + "importDatabaseSelected": "Selected: {{name}}", + "selectFile": "Select File", + "changeFile": "Change", + "import": "Import", + "importing": "Importing...", + "apiKeysCount": "{{count}} keys", + "newApiKey": "New API Key", + "apiKeyCreatedWarning": "Key created - copy it now, it won't be shown again.", + "apiKeyName": "Name", + "apiKeyUser": "User", + "apiKeySelectUser": "Select a user...", + "apiKeyExpiresAt": "Expires At", + "createKey": "Create Key", + "apiKeyNoExpiry": "No expiry", + "revokedBadge": "REVOKED", + "authTypeDual": "Dual Auth", + "authTypeOidc": "OIDC", + "authTypeLocal": "Local", + "adminStatusAdministrator": "Administrator", + "adminStatusRegularUser": "Regular User", + "adminBadge": "ADMIN", + "systemBadge": "SYS", + "customBadge": "CUSTOM", + "youBadge": "YOU", + "sessionsActive": "{{count}} active", + "sessionActive": "Active: {{time}}", + "sessionExpires": "Exp: {{time}}", + "revokeAll": "All", + "revokeAllSessionsSuccess": "All sessions for user revoked", + "revokeAllSessionsFailed": "Failed to revoke sessions", + "revokeSessionFailed": "Failed to revoke session", + "addRole": "Add role", + "noCustomRoles": "No custom roles defined", + "removeRoleFailed": "Failed to remove role", + "assignRoleFailed": "Failed to assign role", + "deleteRoleFailed": "Failed to delete role", + "userAdminAccess": "Administrator", + "userAdminAccessDesc": "Full access to all admin settings", + "userRoles": "Roles", + "revokeAllUserSessions": "Revoke All Sessions", + "revokeAllUserSessionsDesc": "Force re-login on all devices", + "revoke": "Revoke", + "deleteUserWarning": "Deleting this user is permanent.", + "deleteUser": "Delete {{username}}", + "deleting": "Deleting...", + "deleteUserFailed": "Failed to delete user", + "deleteUserSuccess": "User \"{{username}}\" deleted", + "deleteRoleSuccess": "Role \"{{name}}\" deleted", + "revokeKeySuccess": "Key \"{{name}}\" revoked", + "revokeKeyFailed": "Failed to revoke key", + "copiedToClipboard": "Copied to clipboard", + "done": "Done", + "createUserTitle": "Create User", + "createUserDesc": "Create a new local account.", + "createUserUsername": "Username", + "createUserPassword": "Password", + "createUserPasswordHint": "Minimum 6 characters.", + "createUserEnterUsername": "Enter username", + "createUserEnterPassword": "Enter password", + "createUserSubmit": "Create User", + "editUserTitle": "Manage User: {{username}}", + "editUserDesc": "Edit roles, admin status, sessions, and account settings.", + "editUserUsername": "Username", + "editUserAuthType": "Auth Type", + "editUserAdminStatus": "Admin Status", + "editUserUserId": "User ID", + "linkAccountTitle": "Link OIDC to Password Account", + "linkAccountDesc": "Merge the OIDC account {{username}} with an existing local account.", + "linkAccountWarningTitle": "This will:", + "linkAccountEffect1": "Delete the OIDC-only account", + "linkAccountEffect2": "Add OIDC login to the target account", + "linkAccountEffect3": "Allow both OIDC and password login", + "linkAccountTargetUsername": "Target Username", + "linkAccountTargetPlaceholder": "Enter the local account username to link to", + "linkAccounts": "Link Accounts", + "saving": "Saving...", + "updateRegistrationFailed": "Failed to update registration setting", + "updatePasswordLoginFailed": "Failed to update password login setting", + "updateOidcAutoProvisionFailed": "Failed to update OIDC auto-provision setting", + "updatePasswordResetFailed": "Failed to update password reset setting", + "sessionTimeoutRange2": "Session timeout must be between 1 and 720 hours", + "sessionTimeoutSaved": "Session timeout saved", + "sessionTimeoutSaveFailed": "Failed to save session timeout", + "monitoringIntervalInvalid": "Invalid interval values", + "monitoringSaved": "Monitoring settings saved", + "monitoringSaveFailed": "Failed to save monitoring settings", + "guacamoleSaved": "Guacamole settings saved", + "guacamoleSaveFailed": "Failed to save Guacamole settings", + "guacamoleUpdateFailed": "Failed to update Guacamole setting", + "logLevelUpdateFailed": "Failed to update log level", + "oidcSaved": "OIDC configuration saved", + "oidcSaveFailed": "Failed to save OIDC config", + "oidcRemoved": "OIDC configuration removed", + "oidcRemoveFailed": "Failed to remove OIDC config", + "createUserRequired": "Username and password are required", + "createUserPasswordTooShort": "Password must be at least 6 characters", + "createUserSuccess": "User \"{{username}}\" created", + "createUserFailed": "Failed to create user", + "updateAdminStatusFailed": "Failed to update admin status", + "allSessionsRevoked": "All sessions revoked", + "revokeSessionsFailed": "Failed to revoke sessions", + "createRoleRequired": "Name and display name are required", + "createRoleSuccess": "Role \"{{name}}\" created", + "createRoleFailed": "Failed to create role", + "apiKeyNameRequired": "Key name is required", + "apiKeyUserRequired": "User ID is required", + "apiKeyCreatedSuccess": "API key \"{{name}}\" created", + "apiKeyCreateFailed": "Failed to create API key", + "exportSuccess": "Database exported successfully", + "exportFailed": "Database export failed", + "importSelectFile": "Please select a file first", + "importCompleted": "Import completed: {{total}} items imported, {{skipped}} skipped", + "importFailed": "Import failed: {{error}}", + "importError": "Database import failed" + }, "newUi": { "sidebar": { "quickConnect": { diff --git a/src/ui/sidebar/AdminSettingsPanel.tsx b/src/ui/sidebar/AdminSettingsPanel.tsx index e69a40e9..df183353 100644 --- a/src/ui/sidebar/AdminSettingsPanel.tsx +++ b/src/ui/sidebar/AdminSettingsPanel.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; import { getUserList, getSessions, @@ -122,6 +123,7 @@ function AccordionSection({ } export function AdminSettingsPanel() { + const { t } = useTranslation(); const [openSection, setOpenSection] = useState( "general", ); @@ -241,17 +243,25 @@ export function AdminSettingsPanel() { async function loadGeneralSettings() { try { - const [reg, pwLogin, pwReset, timeout, monitoring, level, guac, oidcProv] = - await Promise.allSettled([ - getRegistrationAllowed(), - getPasswordLoginAllowed(), - getPasswordResetAllowed(), - getSessionTimeout(), - getGlobalMonitoringSettings(), - getLogLevel(), - getGuacamoleSettings(), - getOidcAutoProvision(), - ]); + const [ + reg, + pwLogin, + pwReset, + timeout, + monitoring, + level, + guac, + oidcProv, + ] = await Promise.allSettled([ + getRegistrationAllowed(), + getPasswordLoginAllowed(), + getPasswordResetAllowed(), + getSessionTimeout(), + getGlobalMonitoringSettings(), + getLogLevel(), + getGuacamoleSettings(), + getOidcAutoProvision(), + ]); if (reg.status === "fulfilled") setAllowRegistration(reg.value.allowed); if (pwLogin.status === "fulfilled") @@ -312,7 +322,7 @@ export function AdminSettingsPanel() { await updateRegistrationAllowed(newVal); } catch { setAllowRegistration(!newVal); - toast.error("Failed to update registration setting"); + toast.error(t("admin.updateRegistrationFailed")); } } @@ -323,7 +333,7 @@ export function AdminSettingsPanel() { await updatePasswordLoginAllowed(newVal); } catch { setAllowPasswordLogin(!newVal); - toast.error("Failed to update password login setting"); + toast.error(t("admin.updatePasswordLoginFailed")); } } @@ -334,7 +344,7 @@ export function AdminSettingsPanel() { await updateOidcAutoProvision(newVal); } catch { setOidcAutoProvision(!newVal); - toast.error("Failed to update OIDC auto-provision setting"); + toast.error(t("admin.updateOidcAutoProvisionFailed")); } } @@ -345,21 +355,21 @@ export function AdminSettingsPanel() { await updatePasswordResetAllowed(newVal); } catch { setAllowPasswordReset(!newVal); - toast.error("Failed to update password reset setting"); + toast.error(t("admin.updatePasswordResetFailed")); } } 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"); + toast.error(t("admin.sessionTimeoutRange2")); return; } try { await updateSessionTimeout(hours); - toast.success("Session timeout saved"); + toast.success(t("admin.sessionTimeoutSaved")); } catch { - toast.error("Failed to save session timeout"); + toast.error(t("admin.sessionTimeoutSaveFailed")); } } @@ -367,7 +377,7 @@ export function AdminSettingsPanel() { const status = parseInt(statusInterval, 10); const metrics = parseInt(metricsInterval, 10); if (isNaN(status) || isNaN(metrics)) { - toast.error("Invalid interval values"); + toast.error(t("admin.monitoringIntervalInvalid")); return; } try { @@ -375,18 +385,18 @@ export function AdminSettingsPanel() { statusCheckInterval: status, metricsInterval: metrics, }); - toast.success("Monitoring settings saved"); + toast.success(t("admin.monitoringSaved")); } catch { - toast.error("Failed to save monitoring settings"); + toast.error(t("admin.monitoringSaveFailed")); } } async function handleSaveGuacamole() { try { await updateGuacamoleSettings({ enabled: guacEnabled, url: guacUrl }); - toast.success("Guacamole settings saved"); + toast.success(t("admin.guacamoleSaved")); } catch { - toast.error("Failed to save Guacamole settings"); + toast.error(t("admin.guacamoleSaveFailed")); } } @@ -397,7 +407,7 @@ export function AdminSettingsPanel() { await updateGuacamoleSettings({ enabled: newVal, url: guacUrl }); } catch { setGuacEnabled(!newVal); - toast.error("Failed to update Guacamole setting"); + toast.error(t("admin.guacamoleUpdateFailed")); } } @@ -406,7 +416,7 @@ export function AdminSettingsPanel() { try { await updateLogLevel(level); } catch { - toast.error("Failed to update log level"); + toast.error(t("admin.logLevelUpdateFailed")); } } @@ -427,9 +437,9 @@ export function AdminSettingsPanel() { ? oidcAllowedUsers.split("\n").filter(Boolean).join(",") : "", }); - toast.success("OIDC configuration saved"); + toast.success(t("admin.oidcSaved")); } catch (e: any) { - toast.error(e?.response?.data?.error || "Failed to save OIDC config"); + toast.error(e?.response?.data?.error || t("admin.oidcSaveFailed")); } finally { setOidcSaving(false); } @@ -448,31 +458,31 @@ export function AdminSettingsPanel() { setOidcScopes("openid email profile"); setOidcUserinfoUrl(""); setOidcAllowedUsers(""); - toast.success("OIDC configuration removed"); + toast.success(t("admin.oidcRemoved")); } catch { - toast.error("Failed to remove OIDC config"); + toast.error(t("admin.oidcRemoveFailed")); } } async function handleCreateUser() { if (!newUsername.trim() || !newPassword.trim()) { - toast.error("Username and password are required"); + toast.error(t("admin.createUserRequired")); return; } if (newPassword.length < 6) { - toast.error("Password must be at least 6 characters"); + toast.error(t("admin.createUserPasswordTooShort")); return; } setCreateUserLoading(true); try { await registerUser(newUsername.trim(), newPassword); - toast.success(`User "${newUsername}" created`); + toast.success(t("admin.createUserSuccess", { username: newUsername })); setCreateUserOpen(false); setNewUsername(""); setNewPassword(""); loadUsers(); } catch (e: any) { - toast.error(e?.response?.data?.error || "Failed to create user"); + toast.error(e?.response?.data?.error || t("admin.createUserFailed")); } finally { setCreateUserLoading(false); } @@ -495,7 +505,7 @@ export function AdminSettingsPanel() { ); } } catch { - toast.error("Failed to update admin status"); + toast.error(t("admin.updateAdminStatusFailed")); } finally { setEditUserLoading(false); } @@ -504,10 +514,10 @@ export function AdminSettingsPanel() { async function handleRevokeUserSessions(userId: string) { try { await revokeAllUserSessions(userId); - toast.success("All sessions revoked"); + toast.success(t("admin.allSessionsRevoked")); loadSessions(); } catch { - toast.error("Failed to revoke sessions"); + toast.error(t("admin.revokeSessionsFailed")); } } @@ -519,9 +529,11 @@ export function AdminSettingsPanel() { setUsers((prev) => prev.filter((u) => u.id !== editUserTarget.id)); setEditUserOpen(false); setEditUserTarget(null); - toast.success(`User "${editUserTarget.username}" deleted`); + toast.success( + t("admin.deleteUserSuccess", { username: editUserTarget.username }), + ); } catch (e: any) { - toast.error(e?.response?.data?.error || "Failed to delete user"); + toast.error(e?.response?.data?.error || t("admin.deleteUserFailed")); } finally { setEditUserLoading(false); } @@ -529,7 +541,7 @@ export function AdminSettingsPanel() { async function handleCreateRole() { if (!newRoleName.trim() || !newRoleDisplayName.trim()) { - toast.error("Name and display name are required"); + toast.error(t("admin.createRoleRequired")); return; } setCreateRoleLoading(true); @@ -544,10 +556,10 @@ export function AdminSettingsPanel() { setNewRoleName(""); setNewRoleDisplayName(""); setNewRoleDescription(""); - toast.success(`Role "${displayName}" created`); + toast.success(t("admin.createRoleSuccess", { name: displayName })); loadRoles(); } catch (e: any) { - toast.error(e?.response?.data?.error || "Failed to create role"); + toast.error(e?.response?.data?.error || t("admin.createRoleFailed")); } finally { setCreateRoleLoading(false); } @@ -555,11 +567,11 @@ export function AdminSettingsPanel() { async function handleCreateApiKey() { if (!newKeyName.trim()) { - toast.error("Key name is required"); + toast.error(t("admin.apiKeyNameRequired")); return; } if (!newKeyUserId.trim()) { - toast.error("User ID is required"); + toast.error(t("admin.apiKeyUserRequired")); return; } setNewKeyLoading(true); @@ -574,9 +586,9 @@ export function AdminSettingsPanel() { setNewKeyName(""); setNewKeyUserId(""); setNewKeyExpiry(""); - toast.success(`API key "${created.name}" created`); + toast.success(t("admin.apiKeyCreatedSuccess", { name: created.name })); } catch (e: any) { - toast.error(e?.response?.data?.error || "Failed to create API key"); + toast.error(e?.response?.data?.error || t("admin.apiKeyCreateFailed")); } finally { setNewKeyLoading(false); } @@ -618,13 +630,13 @@ export function AdminSettingsPanel() { a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); - toast.success("Database exported successfully"); + toast.success(t("admin.exportSuccess")); } else { const err = await response.json().catch(() => ({})); - toast.error(err.error || "Database export failed"); + toast.error(err.error || t("admin.exportFailed")); } } catch { - toast.error("Database export failed"); + toast.error(t("admin.exportFailed")); } finally { setExportLoading(false); } @@ -632,7 +644,7 @@ export function AdminSettingsPanel() { async function handleImportDatabase() { if (!importFile) { - toast.error("Please select a file first"); + toast.error(t("admin.importSelectFile")); return; } setImportLoading(true); @@ -669,21 +681,23 @@ export function AdminSettingsPanel() { (s.dismissedAlertsImported || 0) + (s.settingsImported || 0); toast.success( - `Import completed: ${total} items imported, ${s.skippedItems || 0} skipped`, + t("admin.importCompleted", { total, skipped: s.skippedItems || 0 }), ); setImportFile(null); setTimeout(() => window.location.reload(), 1500); } else { toast.error( - `Import failed: ${result.summary?.errors?.join(", ") || "Unknown error"}`, + t("admin.importFailed", { + error: result.summary?.errors?.join(", ") || "Unknown error", + }), ); } } else { const err = await response.json().catch(() => ({})); - toast.error(err.error || "Database import failed"); + toast.error(err.error || t("admin.importError")); } } catch { - toast.error("Database import failed"); + toast.error(t("admin.importError")); } finally { setImportLoading(false); } @@ -693,15 +707,15 @@ export function AdminSettingsPanel() {
{/* General */} } open={openSection === "general"} onToggle={() => toggle("general")} >
- Session Timeout + {t("admin.sessionTimeout")}
setSessionTimeout(e.target.value)} className="w-20 text-sm" /> - hours + + {t("admin.hours")} +
- Min 1h · Max 720h + {t("admin.sessionTimeoutRange")}
- Monitoring Defaults + {t("admin.monitoringDefaults")}
setStatusInterval(e.target.value)} className="w-20 text-sm" /> - sec + + {t("admin.sec")} +
setMetricsInterval(e.target.value)} className="w-20 text-sm" /> - sec + + {t("admin.sec")} +
@@ -808,15 +828,15 @@ export function AdminSettingsPanel() {
{guacEnabled && (
- Log Level + {t("admin.logLevel")}
{["debug", "info", "warn", "error"].map((l) => ( @@ -859,19 +879,21 @@ export function AdminSettingsPanel() { {/* OIDC */} } open={openSection === "oidc"} onToggle={() => toggle("oidc")} >
- Configure OpenID Connect for SSO. Fields marked{" "} - * are required. + {t("admin.oidcDescription").split("*")[0]} + * + {t("admin.oidcDescription").split("*")[1]}
- One email per line. Leave empty to allow all. + {t("admin.oidcAllowedUsersDesc")}