feat: finish the remote-desktop plugin (B14)

This commit is contained in:
LukeGus committed 2026-09-24 01:02:45 -05:00
1 parent 0af2c94983
commit 14eb558b85
215 files changed
+30029 -3921

No files matched your search

-35
View File
@@ -33,8 +33,6 @@ import {
updateGlobalMonitoringSettings,
getLogLevel,
updateLogLevel,
getGuacamoleSettings,
updateGuacamoleSettings,
getOidcAutoProvision,
updateOidcAutoProvision,
getSecondFactorAfterExternalLogin,
@@ -137,8 +135,6 @@ export function AdminSettingsPanel({
const [sessionTimeout, setSessionTimeout] = useState("24");
const [statusInterval, setStatusInterval] = useState("60");
const [metricsInterval, setMetricsInterval] = useState("30");
const [guacEnabled, setGuacEnabled] = useState(false);
const [guacUrl, setGuacUrl] = useState("guacd:4822");
const [logLevel, setLogLevel] = useState("info");
const [analyticsEnabled, setAnalyticsEnabled] = useState(true);
const [analyticsLocked, setAnalyticsLocked] = useState(false);
@@ -350,7 +346,6 @@ export function AdminSettingsPanel({
timeout,
monitoring,
level,
guac,
oidcProv,
secondFactorExternal,
oidcSilent,
@@ -366,7 +361,6 @@ export function AdminSettingsPanel({
getSessionTimeout(),
getGlobalMonitoringSettings(),
getLogLevel(),
getGuacamoleSettings(),
getOidcAutoProvision(),
getSecondFactorAfterExternalLogin(),
getOidcSilentLoginDefault(),
@@ -402,10 +396,6 @@ export function AdminSettingsPanel({
}
if (level.status === "fulfilled") setLogLevel(level.value.level);
if (guac.status === "fulfilled") {
setGuacEnabled(guac.value.enabled);
setGuacUrl(guac.value.url || "guacd:4822");
}
if (analytics.status === "fulfilled") {
setAnalyticsEnabled(analytics.value.enabled);
setAnalyticsLocked(analytics.value.locked ?? false);
@@ -656,26 +646,6 @@ export function AdminSettingsPanel({
}
}
async function handleSaveGuacamole() {
try {
await updateGuacamoleSettings({ enabled: guacEnabled, url: guacUrl });
toast.success(t("admin.remoteDesktopSaved"));
} catch {
toast.error(t("admin.remoteDesktopSaveFailed"));
}
}
async function handleToggleGuacamole() {
const newVal = !guacEnabled;
setGuacEnabled(newVal);
try {
await updateGuacamoleSettings({ enabled: newVal, url: guacUrl });
} catch {
setGuacEnabled(!newVal);
toast.error(t("admin.remoteDesktopUpdateFailed"));
}
}
async function handleSaveLogLevel(level: string) {
setLogLevel(level);
try {
@@ -1103,11 +1073,6 @@ export function AdminSettingsPanel({
metricsInterval={metricsInterval}
setMetricsInterval={setMetricsInterval}
handleSaveMonitoring={handleSaveMonitoring}
guacEnabled={guacEnabled}
handleToggleGuacamole={handleToggleGuacamole}
guacUrl={guacUrl}
setGuacUrl={setGuacUrl}
handleSaveGuacamole={handleSaveGuacamole}
logLevel={logLevel}
handleSaveLogLevel={handleSaveLogLevel}
/>
-54
View File
@@ -60,11 +60,6 @@ type GeneralSettingsSectionProps = {
metricsInterval: string;
setMetricsInterval: Dispatch<SetStateAction<string>>;
handleSaveMonitoring: () => void;
guacEnabled: boolean;
handleToggleGuacamole: () => void;
guacUrl: string;
setGuacUrl: Dispatch<SetStateAction<string>>;
handleSaveGuacamole: () => void;
logLevel: string;
handleSaveLogLevel: (level: string) => void;
};
@@ -106,11 +101,6 @@ export function AdminGeneralSettingsSection({
metricsInterval,
setMetricsInterval,
handleSaveMonitoring,
guacEnabled,
handleToggleGuacamole,
guacUrl,
setGuacUrl,
handleSaveGuacamole,
logLevel,
handleSaveLogLevel,
}: GeneralSettingsSectionProps) {
@@ -390,50 +380,6 @@ export function AdminGeneralSettingsSection({
</div>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3 mt-2">
<SettingRow
label={t("admin.enableRemoteDesktop")}
description={
<>
{t("admin.enableRemoteDesktopDesc")}{" "}
<a
href="https://docs.termix.site/setup/remote-desktop"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("admin.enableRemoteDesktopDocsLink")}
</a>
</>
}
>
<AdminToggle on={guacEnabled} onToggle={handleToggleGuacamole} />
</SettingRow>
{guacEnabled && (
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.guacdUrl")}
</label>
<div className="flex items-center gap-2">
<Input
value={guacUrl}
onChange={(e) => setGuacUrl(e.target.value)}
placeholder="guacd:4822"
className="text-sm"
/>
<Button
variant="outline"
size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7 shrink-0"
onClick={handleSaveGuacamole}
>
Save
</Button>
</div>
</div>
)}
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3 mt-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("admin.logLevel")}
+6 -18
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { hostProtocolFlags, type HostProtocols } from "./host-protocols";
import { getUserList } from "@/main-axios";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -141,12 +142,9 @@ export function AdminUserManagePanel({
}, [user.id]);
const [editor, setEditor] = useState<EditorState>(null);
const [editorTab, setEditorTab] = useState("general");
const [editorProtocols, setEditorProtocols] = useState({
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
});
const [editorProtocols, setEditorProtocols] = useState<HostProtocols>(() =>
hostProtocolFlags(null),
);
const [confirmDialog, setConfirmDialog] = useState<{
message: string;
onConfirm: () => void;
@@ -791,12 +789,7 @@ export function AdminUserManagePanel({
size="sm"
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => {
setEditorProtocols({
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
});
setEditorProtocols(hostProtocolFlags(null));
setEditorTab("general");
setEditor({ kind: "host", host: null });
}}
@@ -842,12 +835,7 @@ export function AdminUserManagePanel({
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={() => {
setEditorProtocols({
enableSsh: host.enableSsh,
enableRdp: host.enableRdp,
enableVnc: host.enableVnc,
enableTelnet: host.enableTelnet,
});
setEditorProtocols(hostProtocolFlags(host));
setEditorTab("general");
setEditor({ kind: "host", host });
}}
+150 -276
View File
@@ -20,7 +20,6 @@ import {
import {
fromTriState,
toTriState,
type RemoteDesktopDefaults,
type TerminalDefaults,
type TriState,
} from "@/lib/connection-defaults";
@@ -35,34 +34,6 @@ const inputClass =
const labelClass =
"text-[10px] font-bold uppercase tracking-widest text-muted-foreground";
const RDP_TOGGLES = [
["forceLossless", "hosts.guac.forceLossless", "hosts.guac.forceLosslessDesc"],
["enableWallpaper", "hosts.guac.wallpaper", "hosts.guac.wallpaperDesc"],
[
"enableFontSmoothing",
"hosts.guac.fontSmoothing",
"hosts.guac.fontSmoothingDesc",
],
[
"enableDesktopComposition",
"hosts.guac.desktopComposition",
"hosts.guac.desktopCompositionDesc",
],
["disableAudio", "hosts.guac.disableAudio", "hosts.guac.disableAudioDesc"],
[
"enablePrinting",
"hosts.guac.enablePrinting",
"hosts.guac.enablePrintingDesc",
],
[
"enableDrive",
"hosts.guac.enableDriveRedirection",
"hosts.guac.enableDriveRedirectionDesc",
],
["disableCopy", "hosts.guac.disableCopy", "hosts.guac.disableCopyDesc"],
["disablePaste", "hosts.guac.disablePaste", "hosts.guac.disablePasteDesc"],
] as const;
function Field({
label,
children,
@@ -105,17 +76,14 @@ export function ConnectionDefaultsSettings() {
const { t } = useTranslation();
const defaults = useConnectionDefaults();
const [open, setOpen] = useState(false);
const [tab, setTab] = useState<"shell" | "desktop">("shell");
const [terminal, setTerminal] = useState<TerminalDefaults>({});
const [rdp, setRdp] = useState<RemoteDesktopDefaults>({});
const [saving, setSaving] = useState(false);
const [savedThemes, setSavedThemes] = useState<SavedCustomTheme[]>([]);
useEffect(() => {
if (!defaults.ready) return;
setTerminal(defaults.terminal);
setRdp(defaults.rdp);
}, [defaults.ready, defaults.terminal, defaults.rdp]);
}, [defaults.ready, defaults.terminal]);
useEffect(() => {
if (!open) return;
@@ -127,17 +95,13 @@ export function ConnectionDefaultsSettings() {
const configuredCount = useMemo(() => {
const count = (source: Record<string, unknown>) =>
Object.values(source).filter((value) => value !== undefined).length;
return count(defaults.terminal) + count(defaults.rdp);
}, [defaults.terminal, defaults.rdp]);
return count(defaults.terminal);
}, [defaults.terminal]);
const updateTerminal = <K extends keyof TerminalDefaults>(
key: K,
value: TerminalDefaults[K],
) => setTerminal((current) => ({ ...current, [key]: value }));
const updateRdp = <K extends keyof RemoteDesktopDefaults>(
key: K,
value: RemoteDesktopDefaults[K],
) => setRdp((current) => ({ ...current, [key]: value }));
const applySavedTheme = (id: string) => {
if (!id) return;
@@ -153,10 +117,7 @@ export function ConnectionDefaultsSettings() {
async function save() {
setSaving(true);
try {
await Promise.all([
defaults.saveTerminalDefaults(terminal),
defaults.saveRdpDefaults(rdp),
]);
await defaults.saveTerminalDefaults(terminal);
toast.success(t("newUi.sidebar.connectionDefaults.saved"));
setOpen(false);
} catch {
@@ -204,241 +165,155 @@ export function ConnectionDefaultsSettings() {
</DialogDescription>
</DialogHeader>
<div className="flex border-b border-border">
{(
[
["shell", "newUi.sidebar.connectionDefaults.tabTerminal"],
[
"desktop",
"newUi.sidebar.connectionDefaults.tabRemoteDesktop",
],
] as const
).map(([id, labelKey]) => (
<button
key={id}
onClick={() => setTab(id)}
className={`px-3 py-2 text-xs font-medium border-b-2 -mb-px transition-colors ${
tab === id
? "border-accent-brand text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{t(labelKey)}
</button>
))}
</div>
<div className="max-h-[55vh] overflow-y-auto pr-1">
{tab === "shell" ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Field label={t("hosts.fontFamilyLabel")}>
<select
className={inputClass}
value={terminal.fontFamily ?? ""}
onChange={(e) =>
updateTerminal("fontFamily", e.target.value || undefined)
}
>
<option value="">{inheritLabel}</option>
{TERMINAL_FONTS.map((font) => (
<option key={font.value} value={font.value}>
{font.label}
</option>
))}
</select>
</Field>
<Field label={t("hosts.colorTheme")}>
<select
className={inputClass}
value={terminal.theme ?? ""}
onChange={(e) =>
updateTerminal("theme", e.target.value || undefined)
}
>
<option value="">{inheritLabel}</option>
{Object.entries(TERMINAL_THEMES)
.filter(
([key]) =>
key !== "termixDark" && key !== "termixLight",
)
.map(([key, theme]) => (
<option key={key} value={key}>
{theme.name}
</option>
))}
</select>
</Field>
{savedThemes.length > 0 && (
<Field
label={t(
"newUi.sidebar.connectionDefaults.savedThemeLabel",
)}
>
<select
className={inputClass}
value=""
onChange={(e) => applySavedTheme(e.target.value)}
>
<option value="">
{t(
"newUi.sidebar.connectionDefaults.savedThemePlaceholder",
)}
</option>
{savedThemes.map((theme) => (
<option key={theme.id} value={theme.id}>
{theme.name}
</option>
))}
</select>
</Field>
)}
<Field label={t("hosts.fontSizeLabel")}>
<input
className={inputClass}
type="number"
min={8}
max={32}
value={terminal.fontSize ?? ""}
placeholder={inheritLabel}
onChange={(e) =>
updateTerminal(
"fontSize",
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</Field>
<Field label={t("hosts.lineHeightLabel")}>
<input
className={inputClass}
type="number"
min={0.8}
max={2}
step={0.05}
value={terminal.lineHeight ?? ""}
placeholder={inheritLabel}
onChange={(e) =>
updateTerminal(
"lineHeight",
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</Field>
<Field label={t("hosts.cursorStyleLabel")}>
<select
className={inputClass}
value={terminal.cursorStyle ?? ""}
onChange={(e) =>
updateTerminal(
"cursorStyle",
(e.target.value || undefined) as
TerminalDefaults["cursorStyle"] | undefined,
)
}
>
<option value="">{inheritLabel}</option>
{CURSOR_STYLES.map((style) => (
<option key={style.value} value={style.value}>
{style.label}
</option>
))}
</select>
</Field>
<Field label={t("hosts.scrollbackBufferLabel")}>
<input
className={inputClass}
type="number"
min={1000}
max={100000}
step={1000}
value={terminal.scrollback ?? ""}
placeholder={inheritLabel}
onChange={(e) =>
updateTerminal(
"scrollback",
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</Field>
<div className="sm:col-span-2">
<SettingRow
label={t("hosts.cursorBlinking")}
description={t("hosts.cursorBlinkingDesc")}
>
<TriStateSelect
value={terminal.cursorBlink}
onChange={(value) => updateTerminal("cursorBlink", value)}
/>
</SettingRow>
</div>
</div>
) : (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Field label={t("hosts.guac.colorDepth")}>
<select
className={inputClass}
value={rdp.colorDepth ?? ""}
onChange={(e) =>
updateRdp(
"colorDepth",
e.target.value ? Number(e.target.value) : undefined,
)
}
>
<option value="">{inheritLabel}</option>
<option value="16">16-bit</option>
<option value="24">24-bit</option>
<option value="32">32-bit</option>
</select>
</Field>
<Field label={t("hosts.guac.resizeMethod")}>
<select
className={inputClass}
value={rdp.resizeMethod ?? ""}
onChange={(e) =>
updateRdp("resizeMethod", e.target.value || undefined)
}
>
<option value="">{inheritLabel}</option>
<option value="display-update">
{t(
"newUi.sidebar.connectionDefaults.resizeDisplayUpdate",
)}
</option>
<option value="reconnect">
{t("newUi.sidebar.connectionDefaults.resizeReconnect")}
</option>
</select>
</Field>
</div>
<div>
{RDP_TOGGLES.map(([key, labelKey, descriptionKey]) => (
<SettingRow
key={key}
label={t(labelKey)}
description={t(descriptionKey)}
>
<TriStateSelect
value={rdp[key] as boolean | undefined}
onChange={(value) => updateRdp(key, value)}
/>
</SettingRow>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Field label={t("hosts.fontFamilyLabel")}>
<select
className={inputClass}
value={terminal.fontFamily ?? ""}
onChange={(e) =>
updateTerminal("fontFamily", e.target.value || undefined)
}
>
<option value="">{inheritLabel}</option>
{TERMINAL_FONTS.map((font) => (
<option key={font.value} value={font.value}>
{font.label}
</option>
))}
</div>
</select>
</Field>
<Field label={t("hosts.colorTheme")}>
<select
className={inputClass}
value={terminal.theme ?? ""}
onChange={(e) =>
updateTerminal("theme", e.target.value || undefined)
}
>
<option value="">{inheritLabel}</option>
{Object.entries(TERMINAL_THEMES)
.filter(
([key]) => key !== "termixDark" && key !== "termixLight",
)
.map(([key, theme]) => (
<option key={key} value={key}>
{theme.name}
</option>
))}
</select>
</Field>
{savedThemes.length > 0 && (
<Field
label={t("newUi.sidebar.connectionDefaults.savedThemeLabel")}
>
<select
className={inputClass}
value=""
onChange={(e) => applySavedTheme(e.target.value)}
>
<option value="">
{t(
"newUi.sidebar.connectionDefaults.savedThemePlaceholder",
)}
</option>
{savedThemes.map((theme) => (
<option key={theme.id} value={theme.id}>
{theme.name}
</option>
))}
</select>
</Field>
)}
<Field label={t("hosts.fontSizeLabel")}>
<input
className={inputClass}
type="number"
min={8}
max={32}
value={terminal.fontSize ?? ""}
placeholder={inheritLabel}
onChange={(e) =>
updateTerminal(
"fontSize",
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</Field>
<Field label={t("hosts.lineHeightLabel")}>
<input
className={inputClass}
type="number"
min={0.8}
max={2}
step={0.05}
value={terminal.lineHeight ?? ""}
placeholder={inheritLabel}
onChange={(e) =>
updateTerminal(
"lineHeight",
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</Field>
<Field label={t("hosts.cursorStyleLabel")}>
<select
className={inputClass}
value={terminal.cursorStyle ?? ""}
onChange={(e) =>
updateTerminal(
"cursorStyle",
(e.target.value || undefined) as
TerminalDefaults["cursorStyle"] | undefined,
)
}
>
<option value="">{inheritLabel}</option>
{CURSOR_STYLES.map((style) => (
<option key={style.value} value={style.value}>
{style.label}
</option>
))}
</select>
</Field>
<Field label={t("hosts.scrollbackBufferLabel")}>
<input
className={inputClass}
type="number"
min={1000}
max={100000}
step={1000}
value={terminal.scrollback ?? ""}
placeholder={inheritLabel}
onChange={(e) =>
updateTerminal(
"scrollback",
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</Field>
<div className="sm:col-span-2">
<SettingRow
label={t("hosts.cursorBlinking")}
description={t("hosts.cursorBlinkingDesc")}
>
<TriStateSelect
value={terminal.cursorBlink}
onChange={(value) => updateTerminal("cursorBlink", value)}
/>
</SettingRow>
</div>
)}
</div>
</div>
<div className="flex items-center justify-between gap-2 pt-3 border-t border-border">
@@ -447,7 +322,6 @@ export function ConnectionDefaultsSettings() {
size="sm"
onClick={() => {
setTerminal({});
setRdp({});
}}
>
{t("newUi.sidebar.connectionDefaults.clearAll")}
+5 -1
View File
@@ -76,6 +76,7 @@ import {
} from "./HostEditorData";
import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext";
import { HostEditorGeneralTab } from "./HostEditorGeneralTab";
import { withProtocolSettings } from "./host-protocols";
import { canEditHost } from "./host-permissions";
import { VaultProfileManager } from "./VaultProfileManager";
import {
@@ -331,7 +332,10 @@ export function HostEditor({
}
// After the host: a new one has no id to scope settings to until it
// exists. A failure here must not claim the host itself failed to save.
await savePluginHostSettings(Number(saved.id), form.pluginSettings);
await savePluginHostSettings(
Number(saved.id),
withProtocolSettings(form.pluginSettings, protocols),
);
toast.success(host ? t("hosts.hostUpdated") : t("hosts.hostCreated"));
setPreviewTerminalTheme(null);
+21 -81
View File
@@ -2,19 +2,16 @@ import { TERMINAL_THEMES } from "@/lib/terminal-themes";
import type { Host } from "@/types/ui-types";
import type { SSHHostData } from "@/types";
import type { HostDefaults } from "@/api/settings-api";
import type {
RemoteDesktopDefaults,
TerminalDefaults,
} from "@/lib/connection-defaults";
import type { TerminalDefaults } from "@/lib/connection-defaults";
import {
listHostProtocols,
protocolPort,
type HostProtocols,
} from "./host-protocols";
type HostSocks5ProxyNode = NonNullable<Host["socks5ProxyChain"]>[number];
export type HostProtocols = {
enableSsh: boolean;
enableRdp: boolean;
enableVnc: boolean;
enableTelnet: boolean;
};
export type { HostProtocols };
export type HostAuthType = Host["authType"];
export type HostCursorStyle = NonNullable<
@@ -44,23 +41,8 @@ export const terminalAppearanceKeys = [
"customThemeColors",
] as const satisfies readonly (keyof TerminalDefaults)[];
export const remoteDesktopDefaultKeys = [
"colorDepth",
"resizeMethod",
"forceLossless",
"disableAudio",
"enableWallpaper",
"enableFontSmoothing",
"enableDesktopComposition",
"enablePrinting",
"enableDrive",
"disableCopy",
"disablePaste",
] as const satisfies readonly (keyof RemoteDesktopDefaults)[];
export interface UserConnectionDefaults {
terminal: TerminalDefaults;
rdp: RemoteDesktopDefaults;
}
function hasOwn(value: object | undefined, key: PropertyKey): boolean {
@@ -86,18 +68,13 @@ type SnippetResponse = SnippetListItem[] | { snippets?: SnippetListItem[] };
/**
* Whether the Connection Origin control is meaningful for a host.
*
* Every protocol Termix can dial from either backend belongs here. RDP, VNC
* and Telnet were added once they could originate from the desktop
* (Termix-SSH/Support#1240); before that the control was gated on SSH alone,
* so a host enabling only those protocols could never reach the setting.
* Every protocol Termix can dial from either backend belongs here, plugin
* protocols included (Termix-SSH/Support#1240); before that the control was
* gated on SSH alone, so a host enabling only remote desktop could never
* reach the setting.
*/
export function connectionOriginAppliesTo(protocols: HostProtocols): boolean {
return (
protocols.enableSsh ||
protocols.enableRdp ||
protocols.enableVnc ||
protocols.enableTelnet
);
return Object.values(protocols).some(Boolean);
}
export function mapSnippetResponse(
@@ -122,11 +99,6 @@ export function createHostEditorForm(
...(connectionDefaults?.terminal ?? {}),
...(host?.terminalConfig ?? {}),
};
const remoteDefaults = host?.enableRdp ? connectionDefaults?.rdp : undefined;
const guacamoleConfig = {
...(remoteDefaults ?? {}),
...(host?.guacamoleConfig ?? {}),
};
const proxmoxSettings = (host?.pluginSettings?.proxmox ?? {}) as Record<
string,
unknown
@@ -147,9 +119,6 @@ export function createHostEditorForm(
ip: host?.ip ?? "",
username: host?.username ?? (host ? "" : "root"),
sshPort: host?.sshPort ?? host?.port ?? 22,
rdpPort: host?.rdpPort ?? 3389,
vncPort: host?.vncPort ?? 5900,
telnetPort: host?.telnetPort ?? 23,
authType: host?.authType ?? "password",
useWarpgate: host?.useWarpgate ?? false,
shareSshAuth: host?.shareSshAuth ?? false,
@@ -223,11 +192,6 @@ export function createHostEditorForm(
inheritTerminalAppearance:
!host ||
terminalAppearanceKeys.every((key) => !hasOwn(host.terminalConfig, key)),
inheritRemoteDesktopDefaults:
!host ||
remoteDesktopDefaultKeys.every(
(key) => !hasOwn(host.guacamoleConfig, key),
),
localEcho: host?.terminalConfig?.localEcho ?? "default",
fontSize: terminalConfig.fontSize ?? d?.fontSize ?? 14,
fontFamily:
@@ -298,8 +262,6 @@ export function createHostEditorForm(
? "existing_rdp_password"
: (host?.rdpPassword ?? ""),
domain: host?.domain ?? "",
security: host?.security ?? "",
ignoreCert: host?.ignoreCert ?? false,
vncCredentialId: host?.vncCredentialId ?? "",
vncPassword: host?.hasVncPassword
? "existing_vnc_password"
@@ -320,7 +282,6 @@ export function createHostEditorForm(
telnetAuthType: (host?.telnetAuthType ??
(host?.telnetCredentialId ? "credential" : "direct")) as
"direct" | "credential",
guacamoleConfig,
statsConfig: host?.statsConfig ?? {
statusCheckEnabled: d?.statusCheckEnabled ?? true,
statusCheckInterval: 60,
@@ -399,9 +360,11 @@ export function buildHostEditorPayload(
const usesPassword = form.authType === "password";
const usesAgent = form.authType === "agent";
const usesVault = form.authType === "vault";
const guacamoleConfig = form.inheritRemoteDesktopDefaults
? stripKeys(form.guacamoleConfig, remoteDesktopDefaultKeys)
: form.guacamoleConfig;
// With SSH off, the host's primary protocol is the first plugin protocol
// switched on, and its port stands in for the host port.
const primaryProtocol = protocols.enableSsh
? undefined
: listHostProtocols().find((protocol) => protocols[protocol.settingKey]);
const terminalConfig = {
theme: form.theme,
cursorBlink: form.cursorBlink,
@@ -449,22 +412,12 @@ export function buildHostEditorPayload(
: terminalConfig;
return {
connectionType: protocols.enableSsh
? "ssh"
: protocols.enableRdp
? "rdp"
: protocols.enableVnc
? "vnc"
: "telnet",
connectionType: primaryProtocol?.id ?? "ssh",
name: form.name,
ip: form.ip,
port: protocols.enableSsh
? Number(form.sshPort)
: protocols.enableRdp
? Number(form.rdpPort)
: protocols.enableVnc
? Number(form.vncPort)
: Number(form.telnetPort),
port: primaryProtocol
? protocolPort(form.pluginSettings, primaryProtocol)
: Number(form.sshPort),
username: form.username,
folder: form.folder,
parentHostId: form.parentHostId ? Number(form.parentHostId) : null,
@@ -531,13 +484,7 @@ export function buildHostEditorPayload(
form.socks5ProxyMode === "chain" ? form.socks5ProxyChain : null,
connectionOrigin: form.connectionOrigin,
enableSsh: protocols.enableSsh,
enableRdp: protocols.enableRdp,
enableVnc: protocols.enableVnc,
enableTelnet: protocols.enableTelnet,
sshPort: Number(form.sshPort),
rdpPort: Number(form.rdpPort),
vncPort: Number(form.vncPort),
telnetPort: Number(form.telnetPort),
forceKeyboardInteractive: form.forceKeyboardInteractive,
rdpAuthType: protocols.enableRdp ? form.rdpAuthType : null,
rdpCredentialId:
@@ -557,8 +504,6 @@ export function buildHostEditorPayload(
? form.rdpPassword || null
: null,
rdpDomain: form.domain || null,
rdpSecurity: form.security || null,
rdpIgnoreCert: form.ignoreCert,
vncAuthType: protocols.enableVnc ? form.vncAuthType : null,
vncCredentialId:
protocols.enableVnc &&
@@ -602,11 +547,6 @@ export function buildHostEditorPayload(
snippetId: Number(a.snippetId),
})),
statsConfig: form.statsConfig,
guacamoleConfig:
(protocols.enableRdp || protocols.enableVnc || protocols.enableTelnet) &&
Object.keys(guacamoleConfig).length > 0
? guacamoleConfig
: null,
terminalConfig: protocols.enableSsh ? terminalOverrides : null,
};
}
+20 -42
View File
@@ -9,8 +9,6 @@ import type { Host } from "@/types/ui-types";
import {
Globe,
LayoutGrid,
Monitor,
MousePointerClick,
Plus,
Tag,
Terminal,
@@ -23,6 +21,7 @@ import { getSSHFolders, isElectron } from "@/main-axios";
import { connectionOriginAppliesTo } from "./HostEditorData";
import type { HostEditorForm, HostProtocols } from "./HostEditorData";
import { Select2 } from "@/components/select2";
import { useHostProtocols } from "./host-protocols";
type HostEditorSetField = <K extends keyof HostEditorForm>(
key: K,
@@ -48,6 +47,7 @@ export function HostEditorGeneralTab({
simpleMode?: boolean;
}) {
const { t } = useTranslation();
const pluginProtocols = useHostProtocols();
// Tracks which picker is shown, independent of whether a value is set yet
// -- switching to "parent host" mode with nothing picked shouldn't bounce
@@ -116,35 +116,22 @@ export function HostEditorGeneralTab({
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 py-3">
{[
{
proto: "enableSsh" as const,
proto: "enableSsh",
label: t("hosts.tabSsh"),
desc: t("hosts.secureShell"),
icon: <Terminal className="size-4" />,
portField: "sshPort" as const,
},
{
proto: "enableRdp" as const,
label: t("hosts.tabRdp"),
desc: t("hosts.remoteDesktop"),
icon: <Monitor className="size-4" />,
portField: "rdpPort" as const,
},
{
proto: "enableVnc" as const,
label: t("hosts.tabVnc"),
desc: t("hosts.virtualNetwork"),
icon: <MousePointerClick className="size-4" />,
portField: "vncPort" as const,
},
{
proto: "enableTelnet" as const,
label: t("hosts.tabTelnet"),
desc: t("hosts.unencryptedShell"),
icon: <Terminal className="size-4" />,
portField: "telnetPort" as const,
},
...pluginProtocols.map((protocol) => {
const Icon = protocol.icon;
return {
proto: protocol.settingKey,
label: t(protocol.titleKey),
desc: protocol.descriptionKey ? t(protocol.descriptionKey) : "",
icon: <Icon className="size-4" />,
};
}),
].map(({ proto, label, desc, icon }) => {
const enabled = protocols[proto];
const enabled = !!protocols[proto];
return (
<div
key={proto}
@@ -245,15 +232,12 @@ export function HostEditorGeneralTab({
</div>
</SectionCard>
{!protocols.enableSsh &&
!protocols.enableRdp &&
!protocols.enableVnc &&
!protocols.enableTelnet && (
<div className="flex items-center gap-3 p-3 border border-border bg-muted/20 text-xs text-muted-foreground">
<Globe className="size-4 shrink-0 text-muted-foreground/40" />
<span>{t("hosts.enableAtLeastOneProtocol")}</span>
</div>
)}
{!Object.values(protocols).some(Boolean) && (
<div className="flex items-center gap-3 p-3 border border-border bg-muted/20 text-xs text-muted-foreground">
<Globe className="size-4 shrink-0 text-muted-foreground/40" />
<span>{t("hosts.enableAtLeastOneProtocol")}</span>
</div>
)}
<SectionCard
title={t("hosts.folderAndAdvanced")}
@@ -769,13 +753,7 @@ export function HostEditorGeneralTab({
{isElectron() && connectionOriginAppliesTo(protocols) && (
<SettingRow
label={t("hosts.connectionOrigin")}
description={
protocols.enableRdp ||
protocols.enableVnc ||
protocols.enableTelnet
? `${t("hosts.connectionOriginDesc")} ${t("hosts.connectionOriginRemoteDesktopNote")}`
: t("hosts.connectionOriginDesc")
}
description={t("hosts.connectionOriginDesc")}
>
<select
className="flex h-7 border border-border bg-background px-2 py-0 text-xs outline-none focus:ring-1 focus:ring-ring"
+13 -33
View File
@@ -1,4 +1,9 @@
/* eslint-disable react-hooks/exhaustive-deps */
import {
enabledHostProtocols,
hostProtocolFlags,
type HostProtocols,
} from "./host-protocols";
import React, {
useState,
useEffect,
@@ -102,12 +107,9 @@ export function HostManager({
} | null>(null);
const [hostEditorDirty, setHostEditorDirty] = useState(false);
const [showUnsavedHostDialog, setShowUnsavedHostDialog] = useState(false);
const [editingProtocols, setEditingProtocols] = useState({
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
});
const [editingProtocols, setEditingProtocols] = useState<HostProtocols>(() =>
hostProtocolFlags(null),
);
const simpleEditor = useUiPreference("hostEditor", "mode") === "simple";
// Expanding advanced is a per-session choice; fixing one host's SSH options
// shouldn't quietly move the whole app off the Simple preset.
@@ -142,12 +144,7 @@ export function HostManager({
setEditingHost(host);
setEditingCredential(null);
setActiveHostTab("general");
setEditingProtocols({
enableSsh: host.enableSsh,
enableRdp: host.enableRdp,
enableVnc: host.enableVnc,
enableTelnet: host.enableTelnet,
});
setEditingProtocols(hostProtocolFlags(host));
return true;
}
}
@@ -204,12 +201,7 @@ export function HostManager({
if (action === "add-host") {
setEditingHost("new");
setEditingCredential(null);
setEditingProtocols({
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
});
setEditingProtocols(hostProtocolFlags(null));
setActiveHostTab("general");
} else if (action === "add-credential") {
setEditingCredential("new");
@@ -224,12 +216,7 @@ export function HostManager({
const handleAddHost = () => {
setEditingHost("new");
setEditingCredential(null);
setEditingProtocols({
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
});
setEditingProtocols(hostProtocolFlags(null));
setActiveHostTab("general");
};
const handleAddCredential = () => {
@@ -244,12 +231,7 @@ export function HostManager({
setEditingHost(host);
setEditingCredential(null);
setActiveHostTab("general");
setEditingProtocols({
enableSsh: host.enableSsh,
enableRdp: host.enableRdp,
enableVnc: host.enableVnc,
enableTelnet: host.enableTelnet,
});
setEditingProtocols(hostProtocolFlags(host));
}
};
window.addEventListener("host-manager:add-host", handleAddHost);
@@ -848,9 +830,7 @@ export function HostManager({
<option value="">{t("credentials.selectHostOption")}</option>
{allHosts
.filter(
(h) =>
h.enableSsh ||
(!h.enableRdp && !h.enableVnc && !h.enableTelnet),
(h) => h.enableSsh || enabledHostProtocols(h).length === 0,
)
.map((h) => (
<option key={h.id} value={h.id}>
-10
View File
@@ -109,16 +109,9 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
proxmoxStatsConfig:
(proxmoxSettings.proxmoxStatsConfig as Host["proxmoxStatsConfig"]) ??
null,
enableRdp: h.enableRdp != null ? h.enableRdp : h.connectionType === "rdp",
enableVnc: h.enableVnc != null ? h.enableVnc : h.connectionType === "vnc",
enableTelnet:
h.enableTelnet != null ? h.enableTelnet : h.connectionType === "telnet",
sshPort:
h.sshPort ??
(h.connectionType === "ssh" || !h.connectionType ? h.port : 22),
rdpPort: h.rdpPort ?? (h.connectionType === "rdp" ? h.port : 3389),
vncPort: h.vncPort ?? (h.connectionType === "vnc" ? h.port : 5900),
telnetPort: h.telnetPort ?? (h.connectionType === "telnet" ? h.port : 23),
rdpAuthType:
(h.rdpAuthType as "direct" | "credential") ??
(h.rdpCredentialId ? "credential" : "direct"),
@@ -128,8 +121,6 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
rdpPassword: h.rdpPassword ?? "",
hasRdpPassword: !!host.hasRdpPassword || !!h.rdpPassword,
domain: h.rdpDomain,
security: h.rdpSecurity,
ignoreCert: h.rdpIgnoreCert ?? false,
vncAuthType:
(h.vncAuthType as "direct" | "credential") ??
(h.vncCredentialId ? "credential" : "direct"),
@@ -160,7 +151,6 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
hasSudoPassword:
!!host.hasSudoPassword || !!parsedTerminalConfig?.sudoPassword,
statsConfig: parseJson(h.statsConfig) as Host["statsConfig"],
guacamoleConfig: parseJson(h.guacamoleConfig),
forceKeyboardInteractive: h.forceKeyboardInteractive ?? false,
useSocks5: h.useSocks5,
socks5Host: h.socks5Host,
+19 -30
View File
@@ -1,4 +1,5 @@
import { getErrorMessage } from "../lib/error-message.js";
import { enabledHostProtocols, useHostProtocols } from "./host-protocols";
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
import { useEffect, useRef, useState } from "react";
import type { HostData } from "@/types/index";
@@ -87,9 +88,9 @@ function hostGroupNames(host: Host, key: GroupKey): string[] {
case "protocol": {
const protos: string[] = [];
if (host.enableSsh) protos.push("ssh");
if (host.enableRdp) protos.push("rdp");
if (host.enableVnc) protos.push("vnc");
if (host.enableTelnet) protos.push("telnet");
for (const protocol of enabledHostProtocols(host)) {
protos.push(protocol.id);
}
return protos.length > 0 ? protos : ["__none__"];
}
case "auth":
@@ -144,9 +145,9 @@ function hostPassesFilters(host: Host, filters: FilterState): boolean {
if (filters.protocol.length > 0) {
const ok =
(filters.protocol.includes("ssh") && host.enableSsh) ||
(filters.protocol.includes("rdp") && host.enableRdp) ||
(filters.protocol.includes("vnc") && host.enableVnc) ||
(filters.protocol.includes("telnet") && host.enableTelnet);
enabledHostProtocols(host).some((protocol) =>
filters.protocol.includes(protocol.id),
);
if (!ok) return false;
}
if (filters.features.length > 0) {
@@ -203,6 +204,7 @@ export function HostsPanel({
active?: boolean;
}) {
const { t } = useTranslation();
const hostProtocols = useHostProtocols();
const sshAuthProviders = useSshAuthProviders();
const [hostSearch, setHostSearch] = useState("");
const [managerEditing, setManagerEditing] = useState(false);
@@ -349,9 +351,6 @@ export function HostsPanel({
pin: true,
notes: "Main production web server running Nginx",
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
sshPort: 22,
enableTerminal: true,
enableTunnel: false,
@@ -370,9 +369,6 @@ export function HostsPanel({
folder: "Production",
tags: ["database", "production", "postgresql"],
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
sshPort: 22,
enableTerminal: true,
enableTunnel: true,
@@ -449,10 +445,6 @@ export function HostsPanel({
...h,
port: h.port ?? h.sshPort ?? 22,
enableSsh: h.enableSsh ?? h.connectionType === "ssh",
enableRdp: h.enableRdp ?? h.connectionType === "rdp",
enableVnc: h.enableVnc ?? h.connectionType === "vnc",
enableTelnet:
h.enableTelnet ?? h.connectionType === "telnet",
}),
);
const result = await bulkImportSSHHosts(
@@ -768,23 +760,20 @@ export function HostsPanel({
<DropdownMenuLabel>
{t("hosts.filterProtocolGroup")}
</DropdownMenuLabel>
{(
[
["ssh", "Ssh"],
["rdp", "Rdp"],
["vnc", "Vnc"],
["telnet", "Telnet"],
] as const
).map(([val, key]) => (
{[
{ id: "ssh", label: t("hosts.filterProtocolSsh") },
...hostProtocols.map((protocol) => ({
id: protocol.id,
label: t(protocol.titleKey),
})),
].map(({ id, label }) => (
<DropdownMenuCheckboxItem
key={val}
checked={filterState.protocol.includes(val)}
onCheckedChange={() =>
handleFilterToggle("protocol", val)
}
key={id}
checked={filterState.protocol.includes(id)}
onCheckedChange={() => handleFilterToggle("protocol", id)}
onSelect={(e) => e.preventDefault()}
>
{t(`hosts.filterProtocol${key}`)}
{label}
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
+38 -50
View File
@@ -1,27 +1,12 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Eye,
EyeOff,
FolderSearch,
Monitor,
MousePointerClick,
Terminal,
} from "lucide-react";
import { Eye, EyeOff, FolderSearch, Terminal } from "lucide-react";
import { Input } from "@/components/input";
import type { Host } from "@/types/ui-types";
import { getCredentials } from "@/api/credentials-api";
import { mapCredentials } from "./HostManagerData";
import {
createQuickConnectHost,
type QuickConnectProtocol,
} from "./quick-connect-host";
const DEFAULT_PORTS: Record<QuickConnectProtocol, string> = {
ssh: "22",
rdp: "3389",
vnc: "5900",
};
import { createQuickConnectHost } from "./quick-connect-host";
import { useHostProtocols } from "./host-protocols";
import { Select2 } from "@/components/select2";
import { resolveHostTabType } from "@/lib/host-connection-tabs";
@@ -32,7 +17,8 @@ interface QuickConnectPanelProps {
export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
const { t } = useTranslation();
const [host, setHost] = useState("");
const [protocol, setProtocol] = useState<QuickConnectProtocol>("ssh");
// "ssh", or the id of a plugin protocol offered in Quick Connect.
const [protocol, setProtocol] = useState("ssh");
const [port, setPort] = useState("22");
const [domain, setDomain] = useState("");
const [username, setUsername] = useState("root");
@@ -53,11 +39,17 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
.catch(() => {});
}, []);
const isDesktop = protocol !== "ssh";
const pluginProtocols = useHostProtocols().filter(
(entry) => entry.quickConnect,
);
const selected = pluginProtocols.find((entry) => entry.id === protocol);
const isDesktop = !!selected;
const defaultPort = (id: string) =>
String(pluginProtocols.find((entry) => entry.id === id)?.defaultPort ?? 22);
const switchProtocol = (next: QuickConnectProtocol) => {
const switchProtocol = (next: string) => {
// Keep a port the user typed; only swap the protocol default.
if (port === DEFAULT_PORTS[protocol]) setPort(DEFAULT_PORTS[next]);
if (port === defaultPort(protocol)) setPort(defaultPort(next));
setProtocol(next);
};
@@ -67,13 +59,13 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
if (!isDesktop && !username) return;
const hostConfig = createQuickConnectHost({
ip: host,
port: parseInt(port) || parseInt(DEFAULT_PORTS[protocol]),
port: parseInt(port) || parseInt(defaultPort(protocol)),
username,
authType: isDesktop ? "password" : authType,
password,
key: privateKey,
credentialId,
protocol,
protocol: selected,
domain: domain || undefined,
});
const target = type ?? resolveHostTabType(hostConfig);
@@ -90,19 +82,21 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
{t("newUi.sidebar.quickConnect.protocolLabel")}
</label>
<div className="flex gap-1">
{(["ssh", "rdp", "vnc"] as const).map((type) => (
<button
key={type}
onClick={() => switchProtocol(type)}
className={`flex-1 py-1 text-[10px] font-semibold border transition-colors uppercase ${
protocol === type
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
: "border-border text-muted-foreground hover:text-foreground"
}`}
>
{type}
</button>
))}
{["ssh", ...pluginProtocols.map((entry) => entry.id)].map(
(type) => (
<button
key={type}
onClick={() => switchProtocol(type)}
className={`flex-1 py-1 text-[10px] font-semibold border transition-colors uppercase ${
protocol === type
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
: "border-border text-muted-foreground hover:text-foreground"
}`}
>
{type}
</button>
),
)}
</div>
</div>
<div className="flex flex-col gap-1">
@@ -206,7 +200,7 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
</div>
</div>
)}
{isDesktop && protocol === "rdp" && (
{selected?.quickConnect?.showDomain && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.quickConnect.domainLabel")}
@@ -264,21 +258,15 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
</div>
)}
<div className="flex flex-col gap-1.5 pt-1">
{isDesktop ? (
{selected ? (
<button
onClick={() => connect(protocol)}
onClick={() => connect()}
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors"
>
{protocol === "rdp" ? (
<Monitor className="size-3.5" />
) : (
<MousePointerClick className="size-3.5" />
)}
{t(
protocol === "rdp"
? "newUi.sidebar.quickConnect.connectToRdp"
: "newUi.sidebar.quickConnect.connectToVnc",
)}
<selected.icon className="size-3.5" />
{t("newUi.sidebar.quickConnect.connectWith", {
protocol: t(selected.titleKey),
})}
</button>
) : (
<>
+8 -7
View File
@@ -1,9 +1,9 @@
import type { Host, SharePermissionLevel } from "@/types/ui-types";
import {
AUTH_PROTOCOL_METADATA,
isSupportedAuthOverrideProtocol,
type AuthOverrideProtocol,
} from "@/types/auth-protocols";
import { listHostProtocols, protocolEnabled } from "./host-protocols";
const LEVEL_RANK: Record<SharePermissionLevel, number> = {
connect: 1,
@@ -36,10 +36,11 @@ export function canOverrideHostAuth(
host: Host,
protocol: AuthOverrideProtocol,
): boolean {
const enableField = AUTH_PROTOCOL_METADATA[protocol].enableField;
return (
!!host.isShared &&
isSupportedAuthOverrideProtocol(protocol) &&
!!host[enableField]
);
if (!host.isShared || !isSupportedAuthOverrideProtocol(protocol)) {
return false;
}
if (protocol === "ssh") return !!host.enableSsh;
// Other protocols are switched on in their plugin's host settings.
const plugin = listHostProtocols().find((entry) => entry.id === protocol);
return !!plugin && protocolEnabled(host.pluginSettings, plugin);
}
+102
View File
@@ -0,0 +1,102 @@
import type { ComponentType } from "react";
import { byOrderThenId, createRegistry } from "@/lib/registry";
type Icon = ComponentType<{ className?: string; size?: number | string }>;
/**
* A connection protocol a plugin adds next to SSH (RDP, VNC). Its on/off
* switch and port are the plugin's own host settings, so core reads them
* from host.pluginSettings[pluginId] and never knows the protocol by name.
*/
export interface HostProtocolDef {
id: string;
pluginId: string;
/** The plugin's boolean host setting that switches the protocol on. */
settingKey: string;
/** The plugin's number host setting holding the port, if it has one. */
portKey?: string;
defaultPort: number;
titleKey: string;
descriptionKey?: string;
icon: Icon;
order?: number;
/** Offer it in Quick Connect, optionally with a domain field. */
quickConnect?: { showDomain?: boolean };
}
const protocols = createRegistry<HostProtocolDef>(byOrderThenId);
export const registerHostProtocol = protocols.register;
export const useHostProtocols = protocols.useList;
export const listHostProtocols = protocols.list;
/** Which protocols are on, keyed by setting key, with SSH as enableSsh. */
export type HostProtocols = { enableSsh: boolean } & Record<string, boolean>;
type PluginSettingsBag = Record<string, Record<string, unknown>> | undefined;
function settingsOf(
pluginSettings: PluginSettingsBag,
protocol: HostProtocolDef,
): Record<string, unknown> {
return pluginSettings?.[protocol.pluginId] ?? {};
}
export function protocolEnabled(
pluginSettings: PluginSettingsBag,
protocol: HostProtocolDef,
): boolean {
return settingsOf(pluginSettings, protocol)[protocol.settingKey] === true;
}
export function protocolPort(
pluginSettings: PluginSettingsBag,
protocol: HostProtocolDef,
): number {
const raw = protocol.portKey
? Number(settingsOf(pluginSettings, protocol)[protocol.portKey])
: NaN;
return Number.isInteger(raw) && raw > 0 ? raw : protocol.defaultPort;
}
export function hostProtocolFlags(
host: { enableSsh?: boolean; pluginSettings?: PluginSettingsBag } | null,
list: HostProtocolDef[] = listHostProtocols(),
): HostProtocols {
const flags: HostProtocols = {
enableSsh: host ? host.enableSsh !== false : true,
};
for (const protocol of list) {
flags[protocol.settingKey] = host
? protocolEnabled(host.pluginSettings, protocol)
: false;
}
return flags;
}
/** The protocols a host has switched on, in registration order. */
export function enabledHostProtocols(
host: { pluginSettings?: PluginSettingsBag },
list: HostProtocolDef[] = listHostProtocols(),
): HostProtocolDef[] {
return list.filter((protocol) =>
protocolEnabled(host.pluginSettings, protocol),
);
}
/** Writes the editor's protocol switches into each owner's host settings. */
export function withProtocolSettings(
pluginSettings: Record<string, Record<string, unknown>>,
flags: HostProtocols,
list: HostProtocolDef[] = listHostProtocols(),
): Record<string, Record<string, unknown>> {
const next = { ...pluginSettings };
for (const protocol of list) {
if (!(protocol.settingKey in flags)) continue;
next[protocol.pluginId] = {
...(next[protocol.pluginId] ?? {}),
[protocol.settingKey]: flags[protocol.settingKey] === true,
};
}
return next;
}
+23 -28
View File
@@ -1,12 +1,15 @@
import type { SSHHostData } from "@/types";
import type { Host } from "@/types/ui-types";
export type QuickConnectProtocol = "ssh" | "rdp" | "vnc";
import type { HostProtocolDef } from "./host-protocols";
type QuickConnectInput = Pick<
Host,
"ip" | "port" | "username" | "authType" | "password" | "key" | "credentialId"
> & { protocol?: QuickConnectProtocol; domain?: string };
> & {
/** A plugin protocol; SSH when omitted. */
protocol?: HostProtocolDef;
domain?: string;
};
export const QUICK_CONNECT_ID_PREFIX = "quick-connect-";
@@ -15,10 +18,17 @@ export function isQuickConnectHost(host: Pick<Host, "id">): boolean {
}
export function createQuickConnectHost(input: QuickConnectInput): Host {
const protocol = input.protocol ?? "ssh";
if (protocol !== "ssh") {
const protocol = input.protocol;
if (protocol) {
// Protocol logins are core host fields named after the protocol.
const login = {
[`${protocol.id}AuthType`]: "direct",
[`${protocol.id}User`]: input.username,
[`${protocol.id}Password`]: input.password,
};
return {
...createQuickConnectHost({ ...input, protocol: "ssh", port: 22 }),
...createQuickConnectHost({ ...input, protocol: undefined, port: 22 }),
...login,
port: input.port,
enableTerminal: false,
enableCommandHistory: false,
@@ -28,16 +38,13 @@ export function createQuickConnectHost(input: QuickConnectInput): Host {
enableTerminalToolbar: false,
enableAiAssistant: false,
enableSsh: false,
enableRdp: protocol === "rdp",
enableVnc: protocol === "vnc",
rdpPort: protocol === "rdp" ? input.port : 3389,
vncPort: protocol === "vnc" ? input.port : 5900,
rdpAuthType: "direct",
rdpUser: protocol === "rdp" ? input.username : undefined,
rdpPassword: protocol === "rdp" ? input.password : undefined,
domain: protocol === "rdp" ? input.domain : undefined,
vncUser: protocol === "vnc" ? input.username : undefined,
vncPassword: protocol === "vnc" ? input.password : undefined,
domain: input.domain,
pluginSettings: {
[protocol.pluginId]: {
[protocol.settingKey]: true,
...(protocol.portKey ? { [protocol.portKey]: input.port } : {}),
},
},
};
}
return {
@@ -71,13 +78,7 @@ export function createQuickConnectHost(input: QuickConnectInput): Host {
enableTerminalToolbar: true,
enableAiAssistant: false,
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
sshPort: input.port,
rdpPort: 3389,
vncPort: 5900,
telnetPort: 23,
};
}
@@ -113,12 +114,6 @@ export function quickConnectHostToPayload(host: Host): SSHHostData {
showServerStatsInSidebar: true,
connectionType: "ssh",
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
sshPort: host.sshPort,
rdpPort: host.rdpPort,
vncPort: host.vncPort,
telnetPort: host.telnetPort,
};
}
+13 -10
View File
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest";
import { registerHostProtocol } from "@/sidebar/host-protocols";
import type { Host } from "@/types/ui-types";
import {
buildStatusTooltip,
@@ -69,25 +70,27 @@ describe("buildStatusTooltip", () => {
});
it("includes protocol names in the tooltip when protocols are enabled", () => {
const dispose = registerHostProtocol({
id: "demo-desktop",
pluginId: "demo",
settingKey: "enableDemo",
defaultPort: 3389,
titleKey: "Demo Desktop",
icon: () => null,
});
const host = makeHost({
enableSsh: true,
enableRdp: true,
enableVnc: false,
enableTelnet: false,
pluginSettings: { demo: { enableDemo: true } },
});
const tooltip = buildStatusTooltip(host, "online", t);
dispose();
expect(tooltip).toContain("SSH");
expect(tooltip).toContain("RDP");
expect(tooltip).toContain("Demo Desktop");
expect(tooltip).toContain("Available");
});
it("returns just the status label when no protocols are enabled", () => {
const host = makeHost({
enableSsh: false,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
});
const host = makeHost({ enableSsh: false });
const tooltip = buildStatusTooltip(host, "online", t);
expect(tooltip).toBe("Available");
});
+5 -8
View File
@@ -1,4 +1,5 @@
/* eslint-disable react-refresh/only-export-components */
import { enabledHostProtocols } from "@/sidebar/host-protocols";
import { useEffect, useRef, useState, type MouseEvent } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
@@ -105,9 +106,9 @@ export function buildStatusTooltip(
if (!statusCheckEnabled(host)) return t("hosts.status.monitoringDisabled");
const protocols: string[] = [];
if (host.enableSsh) protocols.push("SSH");
if (host.enableRdp) protocols.push("RDP");
if (host.enableVnc) protocols.push("VNC");
if (host.enableTelnet) protocols.push("Telnet");
for (const protocol of enabledHostProtocols(host)) {
protocols.push(t(protocol.titleKey));
}
if (protocols.length === 0) return statusLabel;
return `${protocols.join(", ")}: ${statusLabel}`;
}
@@ -958,11 +959,7 @@ export function HostItem({
// reachable. If the host only exposes a single action, just launch it.
if (isTouchOnly) {
e.stopPropagation();
const otherProtocols = [
host.enableRdp,
host.enableVnc,
host.enableTelnet,
].filter(Boolean).length;
const otherProtocols = enabledHostProtocols(host).length;
if (otherProtocols <= 1) {
openHostTab(defaultAction);
} else {
+14 -10
View File
@@ -6,6 +6,7 @@ import {
useLayoutEffect,
useState,
} from "react";
import { updatePluginHostSettings } from "@/api/plugins-api";
import { useTranslation } from "react-i18next";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useAreaPreferences } from "@/contexts/UiPreferencesContext";
@@ -608,22 +609,14 @@ export function SidebarTree({
credentialId: host.credentialId ? Number(host.credentialId) : null,
overrideCredentialUsername: host.overrideCredentialUsername ?? false,
enableSsh: host.enableSsh,
enableRdp: host.enableRdp,
enableVnc: host.enableVnc,
enableTelnet: host.enableTelnet,
enableTerminal: host.enableTerminal,
enableTunnel: host.enableTunnel,
enableFileManager: host.enableFileManager,
enableDocker: host.enableDocker,
sshPort: host.sshPort,
rdpPort: host.rdpPort,
vncPort: host.vncPort,
telnetPort: host.telnetPort,
rdpUser: host.rdpUser ?? null,
rdpPassword: host.rdpPassword ?? null,
rdpDomain: host.domain ?? null,
rdpSecurity: host.security ?? null,
rdpIgnoreCert: host.ignoreCert ?? false,
vncAuthType: host.vncAuthType ?? null,
vncCredentialId: host.vncCredentialId
? Number(host.vncCredentialId)
@@ -650,10 +643,21 @@ export function SidebarTree({
snippetId: Number(a.snippetId),
})),
statsConfig: host.statsConfig,
guacamoleConfig: host.guacamoleConfig ?? null,
terminalConfig: host.terminalConfig ?? null,
};
await createSSHHost(duplicateHost);
const created = await createSSHHost(duplicateHost);
// Plugin host settings live outside the host row. Secrets come back
// redacted, and sending the marker back is a no-op, so they stay unset.
for (const [pluginId, fields] of Object.entries(
host.pluginSettings ?? {},
)) {
if (!fields || Object.keys(fields).length === 0) continue;
await updatePluginHostSettings(
pluginId,
Number(created.id),
fields,
).catch(() => {});
}
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
toast.success(t("hosts.duplicatedHost", { name: host.name }));
} catch {