feat: guacd improvements, created tool bar

This commit is contained in:
LukeGus
2026-05-21 22:35:58 -05:00
parent 23a0160ef0
commit 6bcbadcc25
9 changed files with 1131 additions and 267 deletions
+4 -1
View File
@@ -1617,9 +1617,12 @@ async function deploySSHKeyToHost(
const escapedKey = actualPublicKey const escapedKey = actualPublicKey
.replace(/\\/g, "\\\\") .replace(/\\/g, "\\\\")
.replace(/'/g, "'\\''"); .replace(/'/g, "'\\''");
const escapedName = credData.name
.replace(/\\/g, "\\\\")
.replace(/'/g, "'\\''");
conn.exec( 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) => { (err, stream) => {
if (err) { if (err) {
clearTimeout(addTimeout); clearTimeout(addTimeout);
+35 -11
View File
@@ -43,6 +43,8 @@ const router = express.Router();
const upload = multer({ storage: multer.memoryStorage() }); const upload = multer({ storage: multer.memoryStorage() });
const STATS_SERVER_URL = "http://localhost:30005";
function notifyStatsHostUpdated( function notifyStatsHostUpdated(
hostId: number, hostId: number,
headers: Pick<Request["headers"], "authorization" | "cookie">, headers: Pick<Request["headers"], "authorization" | "cookie">,
@@ -50,7 +52,7 @@ function notifyStatsHostUpdated(
): void { ): void {
axios axios
.post( .post(
"http://localhost:30005/host-updated", `${STATS_SERVER_URL}/host-updated`,
{ hostId }, { hostId },
{ {
headers: { headers: {
@@ -1926,9 +1928,21 @@ router.get(
const exportData = isRemoteDesktop const exportData = isRemoteDesktop
? { ? {
...baseExportData, ...baseExportData,
domain: resolvedHost.domain || null, enableRdp: !!resolvedHost.enableRdp,
security: resolvedHost.security || null, enableVnc: !!resolvedHost.enableVnc,
ignoreCert: !!resolvedHost.ignoreCert, 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 guacamoleConfig: resolvedHost.guacamoleConfig
? JSON.parse(resolvedHost.guacamoleConfig as string) ? JSON.parse(resolvedHost.guacamoleConfig as string)
: null, : null,
@@ -2252,9 +2266,8 @@ router.delete(
try { try {
const axios = (await import("axios")).default; const axios = (await import("axios")).default;
const statsPort = 30005;
await axios.post( await axios.post(
`http://localhost:${statsPort}/host-deleted`, `${STATS_SERVER_URL}/host-deleted`,
{ hostId: numericHostId }, { hostId: numericHostId },
{ {
headers: { headers: {
@@ -3429,11 +3442,10 @@ router.delete(
try { try {
const axios = (await import("axios")).default; const axios = (await import("axios")).default;
const statsPort = 30005;
for (const host of hostsToDelete) { for (const host of hostsToDelete) {
try { try {
await axios.post( await axios.post(
`http://localhost:${statsPort}/host-deleted`, `${STATS_SERVER_URL}/host-deleted`,
{ hostId: host.id }, { hostId: host.id },
{ {
headers: { headers: {
@@ -3853,9 +3865,21 @@ router.post(
sshDataObj.key = null; sshDataObj.key = null;
sshDataObj.keyPassword = null; sshDataObj.keyPassword = null;
sshDataObj.keyType = null; sshDataObj.keyType = null;
sshDataObj.domain = hostData.domain || null; sshDataObj.rdpUser = hostData.rdpUser || null;
sshDataObj.security = hostData.security || null; sshDataObj.rdpPassword = hostData.rdpPassword || null;
sshDataObj.ignoreCert = hostData.ignoreCert ? 1 : 0; 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 sshDataObj.guacamoleConfig = hostData.guacamoleConfig
? JSON.stringify(hostData.guacamoleConfig) ? JSON.stringify(hostData.guacamoleConfig)
: null; : null;
+87 -20
View File
@@ -18,18 +18,52 @@ const authManager = AuthManager.getInstance();
router.use(authManager.createAuthMiddleware()); router.use(authManager.createAuthMiddleware());
/** /**
* POST /guacamole/token * @openapi
* Generate an encrypted connection token for guacamole-lite * /guacamole/token:
* * post:
* Body: { * summary: Generate an encrypted Guacamole connection token
* type: "rdp" | "vnc" | "telnet", * description: Creates an AES-256-CBC encrypted token for guacamole-lite with the given connection parameters
* hostname: string, * tags:
* port?: number, * - Guacamole
* username?: string, * security:
* password?: string, * - bearerAuth: []
* domain?: string, * requestBody:
* // Additional protocol-specific options * 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) => { router.post("/token", async (req, res) => {
try { try {
@@ -203,9 +237,35 @@ router.post(
let token: string; let token: string;
let hostname = host.ip as string; let hostname = host.ip as string;
let port = host.port as number; let port = host.port as number;
const username = (host.username as string) || ""; let username: string;
const password = (host.password as string) || ""; let password: string;
const domain = (host.domain as 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 // Establish SSH tunnel if jump hosts are configured
let jumpHosts: Array<{ hostId: number }> = []; let jumpHosts: Array<{ hostId: number }> = [];
@@ -299,11 +359,18 @@ router.post(
guacConfig["create-drive-path"] = true; guacConfig["create-drive-path"] = true;
} }
token = tokenService.createRdpToken(hostname, username, password, { token = tokenService.createRdpToken(hostname, username, password, {
port: port || 3389, port,
domain, domain,
security: (host.security as string) || undefined, security:
(host.rdpSecurity as string) ||
(host.security as string) ||
undefined,
"ignore-cert": "ignore-cert":
host.ignoreCert !== undefined ? !!host.ignoreCert : true, host.rdpIgnoreCert !== undefined
? !!host.rdpIgnoreCert
: host.ignoreCert !== undefined
? !!host.ignoreCert
: true,
...guacConfig, ...guacConfig,
}); });
break; break;
@@ -313,7 +380,7 @@ router.post(
username || undefined, username || undefined,
password, password,
{ {
port: port || 5900, port,
security: "any", security: "any",
...guacConfig, ...guacConfig,
}, },
@@ -321,7 +388,7 @@ router.post(
break; break;
case "telnet": case "telnet":
token = tokenService.createTelnetToken(hostname, username, password, { token = tokenService.createTelnetToken(hostname, username, password, {
port: port || 23, port,
...guacConfig, ...guacConfig,
}); });
break; break;
+1 -1
View File
@@ -693,7 +693,7 @@ export function AppShell({
return ( return (
<div <div
key={tab.id} key={tab.id}
className="flex flex-col overflow-hidden" className="flex flex-col overflow-hidden bg-background"
style={ style={
visible visible
? { ? {
+30 -27
View File
@@ -6,7 +6,8 @@ import {
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx"; import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
import { getGuacamoleTokenFromHost, getGuacdStatus } from "@/main-axios.ts"; import { getGuacamoleTokenFromHost, getGuacdStatus } from "@/main-axios.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AlertCircle, RefreshCw, Keyboard } from "lucide-react"; import { AlertCircle, RefreshCw } from "lucide-react";
import { GuacamoleToolbar } from "@/features/guacamole/GuacamoleToolbar.tsx";
import { Button } from "@/components/button.tsx"; import { Button } from "@/components/button.tsx";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx"; import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import type { SSHHost } from "@/types"; import type { SSHHost } from "@/types";
@@ -49,9 +50,29 @@ const GuacamoleApp: React.FC<GuacamoleAppProps> = ({ hostId }) => {
); );
} }
if (!hostId) {
return (
<div
className="flex flex-col items-center justify-center h-full gap-4"
style={{ backgroundColor: "var(--bg-base)" }}
>
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
<span
className="text-sm font-semibold"
style={{ color: "var(--foreground)" }}
>
{t("guacamole.hostNotFound")}
</span>
</div>
);
}
return ( return (
<GuacamoleAppInner <GuacamoleAppInner
hostId={parseInt(hostId!, 10)} hostId={parseInt(hostId, 10)}
hostConfig={hostConfig} hostConfig={hostConfig}
/> />
); );
@@ -82,9 +103,7 @@ const GuacamoleAppInner: React.FC<GuacamoleAppInnerProps> = ({
getGuacdStatus() getGuacdStatus()
.then((status) => { .then((status) => {
if (status.guacd.status !== "connected") { if (status.guacd.status !== "connected") {
setError( setError(t("guacamole.guacdUnavailable"));
"Remote desktop service (guacd) is not available. Please ensure guacd is running and accessible and configured properly in admin settings.",
);
return; return;
} }
return getGuacamoleTokenFromHost(hostId); return getGuacamoleTokenFromHost(hostId);
@@ -102,18 +121,6 @@ const GuacamoleAppInner: React.FC<GuacamoleAppInnerProps> = ({
setRetryCount((c) => c + 1); 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) { if (error) {
return ( return (
<div <div
@@ -189,21 +196,17 @@ const GuacamoleAppInner: React.FC<GuacamoleAppInnerProps> = ({
</div> </div>
)} )}
<GuacamoleDisplay <GuacamoleDisplay
key={token}
ref={displayRef} ref={displayRef}
connectionConfig={{ token, protocol, type: protocol }} connectionConfig={{ token, protocol, type: protocol }}
isVisible={true} isVisible={true}
onError={(err) => setConnectionError(err)} onError={(err) => setConnectionError(err)}
/> />
{(protocol === "rdp" || protocol === "vnc") && ( <GuacamoleToolbar
<button displayRef={displayRef}
onClick={sendCtrlAltDelete} protocol={protocol}
title="Ctrl+Alt+Delete" onReconnect={handleReconnect}
className="absolute top-3 right-3 z-10 flex items-center gap-1.5 px-2.5 py-1.5 text-[10px] font-semibold uppercase tracking-wider bg-background/80 backdrop-blur border border-border rounded hover:bg-muted transition-colors opacity-60 hover:opacity-100" />
>
<Keyboard className="size-3" />
Ctrl+Alt+Del
</button>
)}
</div> </div>
); );
}; };
@@ -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<GuacamoleDisplayHandle>;
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>
<TooltipTrigger asChild>
<button
type="button"
onClick={onClick}
className={cn(BTN_BASE, className)}
>
{children}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{tooltip}
</TooltipContent>
</Tooltip>
);
}
function TipIconBtn({
tooltip,
onClick,
className,
children,
}: {
tooltip: string;
onClick: () => void;
className?: string;
children: React.ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onClick}
className={cn(BTN_ICON, className)}
>
{children}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{tooltip}
</TooltipContent>
</Tooltip>
);
}
export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
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<Record<number, boolean>>({
[MODIFIER_KEYSYMS.ctrl]: false,
[MODIFIER_KEYSYMS.alt]: false,
[MODIFIER_KEYSYMS.shift]: false,
[MODIFIER_KEYSYMS.win]: false,
});
const toolbarRef = useRef<HTMLDivElement>(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 (
<div
ref={toolbarRef}
style={containerStyle}
onMouseDown={(e) => e.preventDefault()}
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center bg-background/85 backdrop-blur-sm border border-border shadow-lg rounded-sm overflow-hidden">
<button
type="button"
onMouseDown={startDrag}
className="flex items-center justify-center size-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors cursor-grab active:cursor-grabbing"
>
<GripVertical className="size-3" />
</button>
<div className="w-px h-4 bg-border" />
<button
type="button"
onClick={() => setCollapsed(false)}
className="flex items-center justify-center size-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title={t("guacamole.toolbar.expand")}
>
<Monitor className="size-3.5" />
</button>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{t("guacamole.toolbar.expand")}
</TooltipContent>
</Tooltip>
</div>
);
}
return (
<TooltipProvider delayDuration={500}>
<div
ref={toolbarRef}
style={containerStyle}
onMouseDown={(e) => 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 */}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onMouseDown={startDrag}
className="flex items-center justify-center h-7 px-1 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors rounded-sm cursor-grab active:cursor-grabbing"
>
<GripVertical className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{t("guacamole.toolbar.dragHandle")}
</TooltipContent>
</Tooltip>
{/* System combos — RDP/VNC only */}
{isRdpVnc && (
<>
<div className={SEP} />
<TipBtn
tooltip={t("guacamole.toolbar.ctrlAltDel")}
onClick={() => sendCombo(0xffe3, 0xffe9, 0xffff)}
>
CAD
</TipBtn>
<TipBtn
tooltip={t("guacamole.toolbar.winL")}
onClick={() => sendCombo(0xff67, 0x006c)}
>
Win+L
</TipBtn>
<TipBtn
tooltip={t("guacamole.toolbar.winKey")}
onClick={() => sendCombo(0xff67)}
>
Win
</TipBtn>
</>
)}
{/* Sticky modifiers — RDP/VNC only */}
{isRdpVnc && (
<>
<div className={SEP} />
{(
[
["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]) => (
<Tooltip key={key}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => toggleStickyKey(ks)}
className={cn(
BTN_BASE,
stickyKeys[ks] &&
"bg-primary/15 text-primary border border-primary/30",
)}
>
{label}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{stickyKeys[ks]
? t("guacamole.toolbar.stickyActive", { key: label })
: t("guacamole.toolbar.stickyInactive", { key: label })}
</TooltipContent>
</Tooltip>
))}
</>
)}
{/* Function key toggle */}
<div className={SEP} />
<TipBtn
tooltip={t("guacamole.toolbar.fnToggle")}
onClick={() => setShowFKeys((v) => !v)}
className={cn(
showFKeys && "bg-primary/15 text-primary border border-primary/30",
)}
>
Fn
</TipBtn>
{/* F1-F12 row */}
{showFKeys &&
FKEY_KEYSYMS.map((ks, i) => (
<TipBtn
key={ks}
tooltip={`F${i + 1}`}
onClick={() => sendCombo(ks)}
>
F{i + 1}
</TipBtn>
))}
{/* Navigation */}
<div className={SEP} />
<TipBtn
tooltip={t("guacamole.toolbar.esc")}
onClick={() => sendCombo(0xff1b)}
>
Esc
</TipBtn>
<TipBtn
tooltip={t("guacamole.toolbar.tab")}
onClick={() => sendCombo(0xff09)}
>
Tab
</TipBtn>
<TipBtn
tooltip={t("guacamole.toolbar.home")}
onClick={() => sendCombo(0xff50)}
>
Home
</TipBtn>
<TipBtn
tooltip={t("guacamole.toolbar.end")}
onClick={() => sendCombo(0xff57)}
>
End
</TipBtn>
<TipBtn
tooltip={t("guacamole.toolbar.pageUp")}
onClick={() => sendCombo(0xff55)}
>
PgUp
</TipBtn>
<TipBtn
tooltip={t("guacamole.toolbar.pageDown")}
onClick={() => sendCombo(0xff56)}
>
PgDn
</TipBtn>
{/* Arrow cluster */}
<div className="flex flex-col ml-0.5">
<div className="flex justify-center">
<TipIconBtn
tooltip={t("guacamole.toolbar.arrowUp")}
onClick={() => sendCombo(0xff52)}
>
<ChevronUp className="size-3" />
</TipIconBtn>
</div>
<div className="flex">
<TipIconBtn
tooltip={t("guacamole.toolbar.arrowLeft")}
onClick={() => sendCombo(0xff51)}
>
<ChevronLeft className="size-3" />
</TipIconBtn>
<TipIconBtn
tooltip={t("guacamole.toolbar.arrowDown")}
onClick={() => sendCombo(0xff54)}
>
<ChevronDown className="size-3" />
</TipIconBtn>
<TipIconBtn
tooltip={t("guacamole.toolbar.arrowRight")}
onClick={() => sendCombo(0xff53)}
>
<ChevronRight className="size-3" />
</TipIconBtn>
</div>
</div>
{/* Session */}
<div className={SEP} />
<TipIconBtn
tooltip={t("guacamole.toolbar.reconnect")}
onClick={onReconnect}
>
<RefreshCw className="size-3.5" />
</TipIconBtn>
{/* Collapse */}
<div className="w-px h-5 bg-border mx-0.5 shrink-0" />
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setCollapsed(true)}
className={cn(BTN_ICON)}
>
<ChevronsLeftRight className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{t("guacamole.toolbar.collapse")}
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
);
};
+202 -1
View File
@@ -182,6 +182,9 @@
"keyType": "Key Type", "keyType": "Key Type",
"uploadFile": "Upload File", "uploadFile": "Upload File",
"tabGeneral": "General", "tabGeneral": "General",
"tabSsh": "SSH",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTunnels": "Tunnels", "tabTunnels": "Tunnels",
"tabDocker": "Docker", "tabDocker": "Docker",
"tabFiles": "Files", "tabFiles": "Files",
@@ -721,7 +724,35 @@
"hostNotFound": "Host not found", "hostNotFound": "Host not found",
"noHostSelected": "No host selected", "noHostSelected": "No host selected",
"reconnect": "Reconnect", "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": { "terminal": {
"connect": "Connect to Host", "connect": "Connect to Host",
@@ -1516,6 +1547,176 @@
"consoleTab": "Console", "consoleTab": "Console",
"startContainerToAccess": "Start the container to access the 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": { "newUi": {
"sidebar": { "sidebar": {
"quickConnect": { "quickConnect": {
File diff suppressed because it is too large Load Diff
+22 -10
View File
@@ -223,7 +223,11 @@ function makeHostTabs(t: (key: string) => string): HostTab[] {
label: t("hosts.tabGeneral"), label: t("hosts.tabGeneral"),
icon: <Settings className="size-3" />, icon: <Settings className="size-3" />,
}, },
{ id: "ssh", label: "SSH", icon: <Terminal className="size-3" /> }, {
id: "ssh",
label: t("hosts.tabSsh"),
icon: <Terminal className="size-3" />,
},
{ {
id: "tunnels", id: "tunnels",
label: t("hosts.tabTunnels"), label: t("hosts.tabTunnels"),
@@ -244,8 +248,16 @@ function makeHostTabs(t: (key: string) => string): HostTab[] {
label: t("hosts.tabStats"), label: t("hosts.tabStats"),
icon: <Activity className="size-3" />, icon: <Activity className="size-3" />,
}, },
{ id: "rdp", label: "RDP", icon: <Monitor className="size-3" /> }, {
{ id: "vnc", label: "VNC", icon: <Monitor className="size-3" /> }, id: "rdp",
label: t("hosts.tabRdp"),
icon: <Monitor className="size-3" />,
},
{
id: "vnc",
label: t("hosts.tabVnc"),
icon: <Monitor className="size-3" />,
},
{ {
id: "telnet", id: "telnet",
label: t("hosts.tabTelnet"), label: t("hosts.tabTelnet"),
@@ -1205,28 +1217,28 @@ function HostEditor({
{[ {[
{ {
proto: "enableSsh" as const, proto: "enableSsh" as const,
label: "SSH", label: t("hosts.tabSsh"),
desc: t("hosts.secureShell"), desc: t("hosts.secureShell"),
icon: <Terminal className="size-4" />, icon: <Terminal className="size-4" />,
portField: "sshPort" as const, portField: "sshPort" as const,
}, },
{ {
proto: "enableRdp" as const, proto: "enableRdp" as const,
label: "RDP", label: t("hosts.tabRdp"),
desc: "Remote Desktop", desc: t("hosts.remoteDesktop"),
icon: <Monitor className="size-4" />, icon: <Monitor className="size-4" />,
portField: "rdpPort" as const, portField: "rdpPort" as const,
}, },
{ {
proto: "enableVnc" as const, proto: "enableVnc" as const,
label: "VNC", label: t("hosts.tabVnc"),
desc: t("hosts.virtualNetwork"), desc: t("hosts.virtualNetwork"),
icon: <Monitor className="size-4" />, icon: <Monitor className="size-4" />,
portField: "vncPort" as const, portField: "vncPort" as const,
}, },
{ {
proto: "enableTelnet" as const, proto: "enableTelnet" as const,
label: "Telnet", label: t("hosts.tabTelnet"),
desc: t("hosts.unencryptedShell"), desc: t("hosts.unencryptedShell"),
icon: <Terminal className="size-4" />, icon: <Terminal className="size-4" />,
portField: "telnetPort" as const, portField: "telnetPort" as const,
@@ -3300,8 +3312,8 @@ function HostEditor({
</label> </label>
<select <select
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring" className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
value={form.guacamoleConfig["security"] ?? "any"} value={form.security ?? "any"}
onChange={(e) => setGuacField("security", e.target.value)} onChange={(e) => setField("security", e.target.value)}
> >
<option value="any">Any</option> <option value="any">Any</option>
<option value="nla">NLA</option> <option value="nla">NLA</option>