mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 21:03:47 +00:00
feat: guacd improvements, created tool bar
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user