mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-11 08:42:45 +00:00
* feat(ssh): add per-host TOTP with QR scan and MFA auto-fill Store an optional otpauth TOTP secret on the host, encrypt and sync it like a password, and auto-submit it once when a keyboard-interactive prompt matches JumpServer or PAM TOTP text. Closes #3323 Refs #3415 Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(ssh): address Codex TOTP review findings Reset authenticator editor state when the host changes, match custom prompts with RE2, offer keyboard-interactive during connection tests when TOTP is configured, and drop the secret when a host becomes a plugin. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(ssh): fill TOTP from the active prompt and ship re2js Attach the connection's otpauth URI to the keyboard-interactive dialog so Fill current code uses that attempt, including unsaved hosts. Move re2js into production dependencies so packaged builds can match custom prompts. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(ssh): ship the shared TOTP runtime in packaged builds electron-builder allowlists domain CommonJS modules individually, and sshAuthHelper requires domain/totp.shared.cjs at load. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(ssh): save a pasted TOTP secret and ignore stale QR imports Host save waits for the authenticator draft to validate before writing the host. QR import completions from the previous host no longer change busy state or errors on the newly selected host. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * test(host): ignore TOTP copy in the proxy command summary check The assertion was matching the word secret in the authenticator UI. It now checks that the ProxyCommand token itself is not rendered. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * feat(ssh): simplify host TOTP setup and type codes into the terminal Move authenticator setup into a dialog so the host form keeps a single setting row, show keyboard-interactive prompts as field labels, and add a terminal context-menu action that types the current code without Enter. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(ssh): keep TOTP fill inside the auth dialog Remove the terminal context-menu action. Offer the verification-code button on each non-password prompt, highlight OTP prompts, and never write the code into the password field. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(ssh): keep MFA instructions and scan every screen Show keyboard-interactive name and instructions as plain text when they are not already the field label, and scan each unmatched desktop source once instead of repeating the first display. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(ssh): keep multiline MFA prompt directions Show lines before the last keyboard-interactive prompt as plain-text guidance, and keep that last line as the field label. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(ssh): scan TOTP QR codes at each display's full size Request a thumbnail as wide as the widest display and as tall as the tallest, so a portrait screen is not scaled into a landscape frame. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(ssh): keep TOTP fill off menu prompts Judge the fill action from the last prompt line and the shared menu, SMS, and confirmation exclusion list. Use a secondary button on the label row, and simplify the host authenticator row. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
1421 lines
50 KiB
TypeScript
1421 lines
50 KiB
TypeScript
import {
|
|
Check,
|
|
Eye,
|
|
EyeOff,
|
|
FileText,
|
|
FolderPlus,
|
|
PlugZap,
|
|
Plus,
|
|
Settings2,
|
|
Tag,
|
|
X,
|
|
} from "lucide-react";
|
|
import React, { useEffect, useMemo, useState, useCallback, useRef } from "react";
|
|
import { useI18n } from "../application/i18n/I18nProvider";
|
|
import { useApplicationBackend, type SshAgentStatus } from "../application/state/useApplicationBackend";
|
|
import { applyGroupDefaults, resolveGroupDefaults, resolveGroupTerminalThemeId } from "../domain/groupConfig";
|
|
import {
|
|
getEffectiveHostDistro,
|
|
normalizePrimaryTelnetState,
|
|
} from "../domain/host";
|
|
import {
|
|
formatProxyConfigEndpoint,
|
|
formatProxyConfigType,
|
|
updateProxyConfigField,
|
|
} from "../domain/proxyProfiles";
|
|
import { hasRequiredHostAuthCredential, resolveHostAuth, resolveHostAuthMethodForPersistence } from "../domain/sshAuth";
|
|
import { canServeAsSshJumpHost } from "../domain/terminalProtocol";
|
|
import { customThemeStore } from "../application/state/customThemeStore";
|
|
import {
|
|
hasHostFontSizeOverride,
|
|
hasHostThemeOverride,
|
|
resolveHostTerminalFontSize,
|
|
resolveHostTerminalThemeId,
|
|
} from "../domain/terminalAppearance";
|
|
import { EnvVar, GroupConfig, Host, Identity, KnownHost, ManagedSource, ProxyConfig, ProxyProfile, Snippet, SSHKey, TerminalSettings } from "../types";
|
|
import { DISTRO_COLORS, DISTRO_LOGOS } from "./DistroAvatar";
|
|
import ThemeSelectPanel from "./ThemeSelectPanel";
|
|
import {
|
|
AsidePanel,
|
|
AsidePanelContent,
|
|
AsidePanelFooter,
|
|
type AsidePanelLayout,
|
|
type AsidePanelResizeProps,
|
|
} from "./ui/aside-panel";
|
|
import { HostDetailsAdvancedSections } from "./HostDetailsAdvancedSections";
|
|
import { detachEffectiveHostIdentity, HostDetailsConnectionSections } from "./HostDetailsConnectionSections";
|
|
import type { HostTotpFlushResult } from "./HostTotpFields";
|
|
import {
|
|
LINUX_DISTRO_OPTION_IDS,
|
|
parseOptionalPortInput,
|
|
resolveDetailsTelnetPassword,
|
|
resolveDetailsTelnetPort,
|
|
resolveDetailsTelnetUsername,
|
|
resolvePrimaryProtocolSavePort,
|
|
resolvePrimaryProtocolSwitchPort,
|
|
prepareTelnetCredentialsForSave,
|
|
prepareProxyConfigForSave,
|
|
} from "./HostDetailsPanel.helpers";
|
|
export { parseOptionalPortInput } from "./HostDetailsPanel.helpers";
|
|
import { TerminalEncodingSelect } from "./TerminalEncodingSelect";
|
|
import { Button } from "./ui/button";
|
|
import { Combobox, ComboboxOption, MultiCombobox } from "./ui/combobox";
|
|
import { Input } from "./ui/input";
|
|
import { Switch } from "./ui/switch";
|
|
import { toast } from "./ui/toast";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
|
|
|
import {
|
|
ChainPanel,
|
|
CreateGroupPanel,
|
|
HostDetailsSection,
|
|
EnvVarsPanel,
|
|
ProxyPanel,
|
|
} from "./host-details";
|
|
import { HostNotesEditor } from "./host/HostNotesEditor";
|
|
import { HostDetailsScriptsSection } from "./host/HostDetailsScriptsSection";
|
|
import { ensureHostConnectScriptIds, getEditableHostConnectScriptIds, syncSnippetsForHostConnectQueueSave } from "@/domain/hostConnectScripts.ts";
|
|
import {
|
|
isPluginHostProtocol,
|
|
sanitizePluginConnection,
|
|
stripBuiltInConnectionFieldsForPluginHost,
|
|
} from "../domain/pluginConnection";
|
|
import { PluginConnectionSection } from "./PluginConnectionSection";
|
|
import { useHostConnectionTest } from "../application/state/useHostConnectionTest";
|
|
import { HostConnectionTestDialog } from "./host-details/HostConnectionTestDialog";
|
|
|
|
type CredentialType = "sshid" | "key" | "certificate" | "localKeyFile" | null;
|
|
type SubPanel =
|
|
| "none"
|
|
| "create-group"
|
|
| "proxy"
|
|
| "chain"
|
|
| "env-vars"
|
|
| "theme-select"
|
|
| "telnet-theme-select";
|
|
|
|
interface HostDetailsPanelProps {
|
|
initialData?: Host | null;
|
|
availableKeys: SSHKey[];
|
|
identities: Identity[];
|
|
proxyProfiles?: ProxyProfile[];
|
|
groups: string[];
|
|
managedSources?: ManagedSource[];
|
|
allTags?: string[]; // All available tags for autocomplete
|
|
allHosts?: Host[]; // All hosts for chain selection
|
|
defaultGroup?: string | null; // Default group for new hosts (from current navigation)
|
|
terminalThemeId: string;
|
|
terminalFontSize: number;
|
|
onSave: (host: Host) => void;
|
|
onCancel: () => void;
|
|
onCreateGroup?: (groupPath: string) => void; // Callback to create a new group
|
|
onCreateTag?: (tag: string) => void; // Callback to create a new tag
|
|
groupDefaults?: Partial<import('../domain/models').GroupConfig>;
|
|
groupConfigs?: GroupConfig[];
|
|
layout?: AsidePanelLayout;
|
|
onImportKey?: (draft: Partial<SSHKey>) => SSHKey;
|
|
snippets?: Snippet[];
|
|
onSnippetsChange?: (snippets: Snippet[]) => void;
|
|
onHostsChange?: (hosts: Host[] | ((prev: Host[]) => Host[])) => void;
|
|
knownHosts?: KnownHost[];
|
|
onAddKnownHost?: (knownHost: KnownHost) => void;
|
|
terminalSettings?: TerminalSettings;
|
|
className?: string;
|
|
}
|
|
|
|
type HostDetailsPanelPropsWithResize = HostDetailsPanelProps & AsidePanelResizeProps;
|
|
|
|
const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
|
initialData,
|
|
availableKeys,
|
|
identities,
|
|
proxyProfiles = [],
|
|
groups,
|
|
managedSources = [],
|
|
allTags = [],
|
|
allHosts = [],
|
|
defaultGroup,
|
|
terminalThemeId,
|
|
terminalFontSize,
|
|
onSave,
|
|
onCancel,
|
|
onCreateGroup,
|
|
onCreateTag,
|
|
groupDefaults,
|
|
groupConfigs = [],
|
|
layout = "overlay",
|
|
onImportKey,
|
|
snippets = [],
|
|
onSnippetsChange,
|
|
onHostsChange,
|
|
knownHosts,
|
|
onAddKnownHost,
|
|
terminalSettings,
|
|
className,
|
|
resizable,
|
|
persistWidthStorageKey,
|
|
resizeAriaLabel,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
const asideResizeProps = {
|
|
resizable,
|
|
persistWidthStorageKey,
|
|
resizeAriaLabel,
|
|
};
|
|
const { checkSshAgent } = useApplicationBackend();
|
|
const baselineSnippetsRef = useRef(snippets);
|
|
const totpFlushRef = useRef<(() => Promise<HostTotpFlushResult>) | null>(null);
|
|
const savingHostRef = useRef(false);
|
|
const baselineHydratedRef = useRef(snippets.length > 0);
|
|
const openingQueueIdsRef = useRef<string[]>([]);
|
|
const [form, setForm] = useState<Host>(
|
|
() =>
|
|
(initialData ? normalizePrimaryTelnetState(initialData) : null) ||
|
|
({
|
|
id: crypto.randomUUID(),
|
|
label: "",
|
|
hostname: "",
|
|
port: groupDefaults?.port ? undefined : 22,
|
|
username: groupDefaults?.username ? "" : "root",
|
|
protocol: "ssh",
|
|
tags: [],
|
|
os: "linux",
|
|
authMethod: undefined,
|
|
authPolicyVersion: 1,
|
|
charset: groupDefaults?.charset ? undefined : "UTF-8",
|
|
distroMode: "auto",
|
|
createdAt: Date.now(),
|
|
group: defaultGroup || undefined, // Pre-fill with current navigation group
|
|
} as Host),
|
|
);
|
|
|
|
const [activeSubPanel, setActiveSubPanel] = useState<SubPanel>("none");
|
|
|
|
const [credentialPopoverOpen, setCredentialPopoverOpen] = useState(false);
|
|
const [selectedCredentialType, setSelectedCredentialType] =
|
|
useState<CredentialType>(null);
|
|
|
|
const [identitySuggestionsOpen, setIdentitySuggestionsOpen] = useState(false);
|
|
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
const [showTelnetPassword, setShowTelnetPassword] = useState(false);
|
|
const [showAlgorithmOverrides, setShowAlgorithmOverrides] = useState(false);
|
|
const [showNotesEditor, setShowNotesEditor] = useState(() => Boolean(initialData?.notes?.trim()));
|
|
const [pluginConnectionValid, setPluginConnectionValid] = useState(true);
|
|
|
|
const [newKeyFilePath, setNewKeyFilePath] = useState("");
|
|
const [pendingReferenceKeyPath, setPendingReferenceKeyPath] = useState<string | null>(null);
|
|
|
|
const [newGroupName, setNewGroupName] = useState("");
|
|
const [newGroupParent, setNewGroupParent] = useState("");
|
|
|
|
const [sshAgentStatus, setSshAgentStatus] = useState<SshAgentStatus | null>(null);
|
|
const [sshForwardingAgentStatus, setSshForwardingAgentStatus] = useState<SshAgentStatus | null>(null);
|
|
|
|
const [testOpen, setTestOpen] = useState(false);
|
|
const hostConnectionTest = useHostConnectionTest({
|
|
host: form,
|
|
hosts: allHosts,
|
|
keys: availableKeys,
|
|
identities,
|
|
knownHosts,
|
|
groupConfigs,
|
|
proxyProfiles,
|
|
terminalSettings,
|
|
onAddKnownHost,
|
|
});
|
|
|
|
const handleStartTest = () => {
|
|
setTestOpen(true);
|
|
void hostConnectionTest.startTest();
|
|
};
|
|
|
|
const handleCloseTest = () => {
|
|
setTestOpen(false);
|
|
};
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
if (form.agentForwarding || form.useSshAgent === true) {
|
|
void checkSshAgent({
|
|
identityAgent: form.useSshAgent === true ? form.identityAgent : undefined,
|
|
hostname: form.hostname,
|
|
port: form.port,
|
|
username: form.username,
|
|
}).then((status) => {
|
|
if (!cancelled) setSshAgentStatus(status);
|
|
});
|
|
} else {
|
|
setSshAgentStatus(null);
|
|
}
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [form.agentForwarding, form.useSshAgent, form.identityAgent, form.hostname, form.port, form.username, checkSshAgent]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
if (form.agentForwarding) {
|
|
void checkSshAgent({
|
|
identityAgent: form.identityAgent,
|
|
agentForwarding: true,
|
|
hostname: form.hostname,
|
|
port: form.port,
|
|
username: form.username,
|
|
}).then((status) => {
|
|
if (!cancelled) setSshForwardingAgentStatus(status);
|
|
});
|
|
} else {
|
|
setSshForwardingAgentStatus(null);
|
|
}
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [form.agentForwarding, form.identityAgent, form.hostname, form.port, form.username, checkSshAgent]);
|
|
|
|
const [groupInputValue, setGroupInputValue] = useState(form.group || "");
|
|
|
|
const initialHostId = initialData?.id;
|
|
|
|
useEffect(() => {
|
|
baselineSnippetsRef.current = snippets;
|
|
baselineHydratedRef.current = snippets.length > 0;
|
|
if (!initialData) {
|
|
openingQueueIdsRef.current = [];
|
|
} else {
|
|
const normalized = normalizePrimaryTelnetState(initialData);
|
|
const ensured = snippets.length > 0
|
|
? ensureHostConnectScriptIds(normalized, snippets)
|
|
: normalized;
|
|
openingQueueIdsRef.current = getEditableHostConnectScriptIds(ensured, snippets);
|
|
}
|
|
// Snapshot only when opening/reopening a host editor, not on live snippet edits.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [initialHostId]);
|
|
|
|
useEffect(() => {
|
|
// If the editor opened before snippets hydrated, capture the first non-empty
|
|
// catalog as baseline so concurrent-edit guards still have a reference.
|
|
if (baselineHydratedRef.current) return;
|
|
if (snippets.length === 0) return;
|
|
baselineSnippetsRef.current = snippets;
|
|
baselineHydratedRef.current = true;
|
|
if (initialData) {
|
|
const normalized = normalizePrimaryTelnetState(initialData);
|
|
const ensured = ensureHostConnectScriptIds(normalized, snippets);
|
|
openingQueueIdsRef.current = getEditableHostConnectScriptIds(ensured, snippets);
|
|
}
|
|
}, [initialData, snippets]);
|
|
|
|
useEffect(() => {
|
|
if (!initialData) return;
|
|
const normalized = normalizePrimaryTelnetState(initialData);
|
|
setForm(
|
|
snippets.length > 0
|
|
? ensureHostConnectScriptIds(normalized, snippets)
|
|
: normalized,
|
|
);
|
|
setGroupInputValue(initialData.group || "");
|
|
setPendingReferenceKeyPath(null);
|
|
setShowPassword(false);
|
|
setShowTelnetPassword(false);
|
|
setShowNotesEditor(Boolean(normalized.notes?.trim()));
|
|
// Reset only when opening a different host — not when snippets list updates.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [initialHostId]);
|
|
|
|
useEffect(() => {
|
|
if (!initialData || snippets.length === 0) return;
|
|
setForm((prev) => {
|
|
if (prev.id !== initialData.id) return prev;
|
|
const synced = ensureHostConnectScriptIds(prev, snippets);
|
|
if (
|
|
synced.connectScriptIds === prev.connectScriptIds
|
|
&& synced.loginScriptId === prev.loginScriptId
|
|
) {
|
|
return prev;
|
|
}
|
|
return synced;
|
|
});
|
|
}, [initialData, snippets]);
|
|
|
|
const update = <K extends keyof Host>(key: K, value: Host[K]) => {
|
|
setForm((prev) => ({ ...prev, [key]: value }));
|
|
};
|
|
|
|
const addLocalKeyFilePath = useCallback((path: string) => {
|
|
const trimmed = path.trim();
|
|
if (!trimmed) return;
|
|
setForm((prev) => ({
|
|
...prev,
|
|
identityFilePaths: onImportKey ? [trimmed] : [...(prev.identityFilePaths || []), trimmed],
|
|
identityFileId: undefined,
|
|
authMethod: "key",
|
|
}));
|
|
setPendingReferenceKeyPath(onImportKey ? trimmed : null);
|
|
setNewKeyFilePath("");
|
|
setSelectedCredentialType(null);
|
|
}, [onImportKey]);
|
|
|
|
const effectiveGroupDefaults = useMemo(() => {
|
|
const currentGroupPath = form.group || defaultGroup;
|
|
if (currentGroupPath && groupConfigs.length > 0) {
|
|
return resolveGroupDefaults(currentGroupPath, groupConfigs);
|
|
}
|
|
return groupDefaults;
|
|
}, [defaultGroup, form.group, groupConfigs, groupDefaults]);
|
|
|
|
const effectiveAuthHost = useMemo(
|
|
() => effectiveGroupDefaults ? applyGroupDefaults(form, effectiveGroupDefaults) : form,
|
|
[effectiveGroupDefaults, form],
|
|
);
|
|
|
|
const selectedIdentity = useMemo(() => {
|
|
if (!effectiveAuthHost.identityId) return undefined;
|
|
return identities.find((i) => i.id === effectiveAuthHost.identityId);
|
|
}, [effectiveAuthHost.identityId, identities]);
|
|
|
|
const effectiveAuth = useMemo(() => resolveHostAuth({
|
|
host: effectiveAuthHost,
|
|
keys: availableKeys,
|
|
identities,
|
|
}), [availableKeys, effectiveAuthHost, identities]);
|
|
const effectiveAuthMethod = effectiveAuth.authMethod;
|
|
|
|
const effectiveThemeId = useMemo(
|
|
() => resolveHostTerminalThemeId(form, resolveGroupTerminalThemeId(effectiveGroupDefaults, terminalThemeId)),
|
|
[effectiveGroupDefaults, form, terminalThemeId],
|
|
);
|
|
const effectiveFontSize = useMemo(
|
|
() => resolveHostTerminalFontSize(form, terminalFontSize),
|
|
[form, terminalFontSize],
|
|
);
|
|
const hasEffectiveThemeOverride = useMemo(
|
|
() => hasHostThemeOverride(form),
|
|
[form],
|
|
);
|
|
const hasEffectiveFontSizeOverride = useMemo(
|
|
() => hasHostFontSizeOverride(form),
|
|
[form],
|
|
);
|
|
const effectiveTelnetThemeId =
|
|
form.protocols?.find((p) => p.protocol === "telnet")?.theme || effectiveThemeId;
|
|
const effectiveTelnetPort = resolveDetailsTelnetPort(form, effectiveGroupDefaults);
|
|
const effectiveTelnetUsername = resolveDetailsTelnetUsername(form, effectiveGroupDefaults);
|
|
const effectiveTelnetPassword = resolveDetailsTelnetPassword(form, effectiveGroupDefaults);
|
|
const distroOptions = useMemo(
|
|
() =>
|
|
LINUX_DISTRO_OPTION_IDS.map((value) => ({
|
|
value,
|
|
label: t(`hostDetails.distro.option.${value}`),
|
|
icon: DISTRO_LOGOS[value],
|
|
bgClass: DISTRO_COLORS[value] || DISTRO_COLORS.default,
|
|
})),
|
|
[t],
|
|
);
|
|
|
|
const getDistroOptionLabel = useCallback(
|
|
(value?: string) =>
|
|
distroOptions.find((option) => option.value === value)?.label ||
|
|
value ||
|
|
t("hostDetails.distro.pending"),
|
|
[distroOptions, t],
|
|
);
|
|
|
|
const effectiveFormDistro = getEffectiveHostDistro(form);
|
|
const selectedProxyProfile = useMemo(
|
|
() => proxyProfiles.find((profile) => profile.id === form.proxyProfileId),
|
|
[form.proxyProfileId, proxyProfiles],
|
|
);
|
|
const hasMissingProxyProfile = Boolean(form.proxyProfileId && !selectedProxyProfile);
|
|
const proxySummaryType = hasMissingProxyProfile
|
|
? t("hostDetails.proxyPanel.missing")
|
|
: formatProxyConfigType(selectedProxyProfile?.config || form.proxyConfig) || "HTTP";
|
|
const proxySummaryLabel = hasMissingProxyProfile
|
|
? t("hostDetails.proxyPanel.missingSaved")
|
|
: selectedProxyProfile
|
|
? selectedProxyProfile.label
|
|
: formatProxyConfigEndpoint(form.proxyConfig);
|
|
const proxySummaryTooltip = hasMissingProxyProfile
|
|
? t("hostDetails.proxyPanel.missingSaved")
|
|
: selectedProxyProfile
|
|
? `${selectedProxyProfile.label} - ${formatProxyConfigEndpoint(selectedProxyProfile.config)}`
|
|
: `${formatProxyConfigType(form.proxyConfig)} ${formatProxyConfigEndpoint(form.proxyConfig)}`;
|
|
|
|
const handleDistroModeChange = useCallback((mode: "auto" | "manual") => {
|
|
setForm((prev) => ({
|
|
...prev,
|
|
distroMode: mode,
|
|
manualDistro:
|
|
mode === "manual"
|
|
? prev.manualDistro || getEffectiveHostDistro(prev) || "linux"
|
|
: prev.manualDistro,
|
|
}));
|
|
}, []);
|
|
|
|
const updateProxyConfig = useCallback(
|
|
(field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => {
|
|
setForm((prev) => {
|
|
const { proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
|
|
return {
|
|
...rest,
|
|
proxyConfig: updateProxyConfigField(prev.proxyConfig, field, value),
|
|
} as Host;
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
const clearProxyConfig = useCallback(() => {
|
|
setForm((prev) => {
|
|
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
|
|
return rest as Host;
|
|
});
|
|
}, []);
|
|
|
|
const selectProxyProfile = useCallback((profileId: string | undefined) => {
|
|
setForm((prev) => {
|
|
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
|
|
if (!profileId) return rest as Host;
|
|
return { ...rest, proxyProfileId: profileId } as Host;
|
|
});
|
|
}, []);
|
|
|
|
const selectDirectProxy = useCallback(() => {
|
|
setForm((prev) => {
|
|
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
|
|
return { ...rest, directProxy: true } as Host;
|
|
});
|
|
}, []);
|
|
|
|
const addHostToChain = (hostId: string) => {
|
|
setForm((prev) => ({
|
|
...prev,
|
|
hostChain: {
|
|
hostIds: [...(prev.hostChain?.hostIds || []), hostId],
|
|
},
|
|
}));
|
|
};
|
|
|
|
const removeHostFromChain = (index: number) => {
|
|
setForm((prev) => {
|
|
const ids = (prev.hostChain?.hostIds || []).filter((_, i) => i !== index);
|
|
return { ...prev, hostChain: ids.length > 0 ? { hostIds: ids } : undefined };
|
|
});
|
|
};
|
|
|
|
const clearHostChain = useCallback(() => {
|
|
setForm((prev) => {
|
|
const { hostChain: _hostChain, ...rest } = prev;
|
|
return rest as Host;
|
|
});
|
|
}, []);
|
|
|
|
const [newEnvName, setNewEnvName] = useState("");
|
|
const [newEnvValue, setNewEnvValue] = useState("");
|
|
|
|
const addEnvVar = () => {
|
|
if (!newEnvName.trim()) return;
|
|
const newVar: EnvVar = { name: newEnvName.trim(), value: newEnvValue };
|
|
setForm((prev) => ({
|
|
...prev,
|
|
environmentVariables: [...(prev.environmentVariables || []), newVar],
|
|
}));
|
|
setNewEnvName("");
|
|
setNewEnvValue("");
|
|
};
|
|
|
|
const removeEnvVar = (index: number) => {
|
|
setForm((prev) => {
|
|
const filtered = (prev.environmentVariables || []).filter((_, i) => i !== index);
|
|
return { ...prev, environmentVariables: filtered.length > 0 ? filtered : undefined };
|
|
});
|
|
};
|
|
const pluginProtocolActive = isPluginHostProtocol(form.protocol);
|
|
const hasHostname = form.hostname.trim().length > 0;
|
|
const canSaveHost = pluginProtocolActive
|
|
? pluginConnectionValid && Boolean(sanitizePluginConnection(form.pluginConnection, form.protocol))
|
|
: hasHostname;
|
|
|
|
const handleSubmit = async () => {
|
|
if (savingHostRef.current) return;
|
|
savingHostRef.current = true;
|
|
try {
|
|
const flushed = await totpFlushRef.current?.();
|
|
if (flushed && !flushed.ok) return;
|
|
const hostname = form.hostname.trim()
|
|
|| (pluginProtocolActive ? form.label.trim() || form.pluginConnection?.providerId || "plugin" : "");
|
|
if (!hostname || !canSaveHost) return;
|
|
if (!pluginProtocolActive && !hasRequiredHostAuthCredential({ host: effectiveAuthHost, keys: availableKeys, identities })) {
|
|
toast.error(t("hostDetails.auth.credentialRequired"));
|
|
return;
|
|
}
|
|
const proxySave = pluginProtocolActive
|
|
? { normalizedProxyConfig: undefined, error: undefined }
|
|
: prepareProxyConfigForSave({
|
|
proxyConfig: form.proxyConfig,
|
|
proxyProfileId: form.proxyProfileId,
|
|
proxyProfiles,
|
|
identities,
|
|
});
|
|
if (proxySave.error) {
|
|
const messageKey = proxySave.error === "port"
|
|
? "proxyProfiles.error.port"
|
|
: proxySave.error === "required"
|
|
? "hostDetails.proxyPanel.error.required"
|
|
: proxySave.error === "missingSaved"
|
|
? "hostDetails.proxyPanel.missingSaved"
|
|
: proxySave.error === "missingIdentity"
|
|
? "hostDetails.proxyPanel.missingIdentity"
|
|
: proxySave.error === "incompleteIdentity"
|
|
? "hostDetails.proxyPanel.incompleteIdentity"
|
|
: "hostDetails.proxyPanel.unreadableIdentity";
|
|
toast.error(t(messageKey));
|
|
setActiveSubPanel("proxy");
|
|
return;
|
|
}
|
|
const normalizedProxyConfig = proxySave.normalizedProxyConfig;
|
|
let finalLabel = form.label?.trim() || hostname;
|
|
const finalGroup = groupInputValue.trim() || form.group || "";
|
|
|
|
const targetManagedSource = managedSources
|
|
.filter(s => finalGroup === s.groupName || finalGroup.startsWith(s.groupName + "/"))
|
|
.sort((a, b) => b.groupName.length - a.groupName.length)[0];
|
|
|
|
const canBeManaged = !form.protocol || form.protocol === "ssh";
|
|
|
|
if (targetManagedSource && canBeManaged) {
|
|
finalLabel = finalLabel.replace(/\s/g, '');
|
|
}
|
|
|
|
let finalManagedSourceId: string | undefined;
|
|
if (targetManagedSource && canBeManaged) {
|
|
finalManagedSourceId = targetManagedSource.id;
|
|
} else if (managedSources.length === 0 && form.managedSourceId && canBeManaged) {
|
|
finalManagedSourceId = form.managedSourceId;
|
|
} else {
|
|
finalManagedSourceId = undefined;
|
|
}
|
|
|
|
const { proxyConfig: _draftProxyConfig, ...formWithoutProxyDraft } = form;
|
|
const finalPort = resolvePrimaryProtocolSavePort(
|
|
form.protocol,
|
|
form.port,
|
|
Boolean(groupDefaults?.port),
|
|
Boolean(groupDefaults?.telnetPort),
|
|
);
|
|
let cleaned: Host = {
|
|
...formWithoutProxyDraft,
|
|
...(normalizedProxyConfig && { proxyConfig: normalizedProxyConfig }),
|
|
hostname,
|
|
label: finalLabel,
|
|
group: finalGroup,
|
|
tags: form.tags || [],
|
|
notes: form.notes?.trim() || undefined,
|
|
port: finalPort,
|
|
password: form.savePassword === false ? undefined : form.password,
|
|
authMethod: resolveHostAuthMethodForPersistence({
|
|
host: form,
|
|
keys: availableKeys,
|
|
identities,
|
|
groupDefaults: effectiveGroupDefaults,
|
|
}),
|
|
authPolicyVersion: 1,
|
|
managedSourceId: finalManagedSourceId,
|
|
};
|
|
if (flushed?.ok) {
|
|
if (flushed.totp) cleaned.totp = flushed.totp;
|
|
else delete cleaned.totp;
|
|
}
|
|
if (pluginProtocolActive) {
|
|
const pluginConnection = sanitizePluginConnection(cleaned.pluginConnection, cleaned.protocol);
|
|
if (!pluginConnection) return;
|
|
cleaned = stripBuiltInConnectionFieldsForPluginHost({ ...cleaned, pluginConnection });
|
|
}
|
|
cleaned = prepareTelnetCredentialsForSave(normalizePrimaryTelnetState(cleaned));
|
|
if (
|
|
onImportKey &&
|
|
pendingReferenceKeyPath &&
|
|
cleaned.identityFilePaths?.includes(pendingReferenceKeyPath)
|
|
) {
|
|
const fileName = pendingReferenceKeyPath.split('/').pop() || pendingReferenceKeyPath;
|
|
const key = onImportKey({
|
|
source: 'reference',
|
|
filePath: pendingReferenceKeyPath,
|
|
label: fileName,
|
|
privateKey: '',
|
|
category: 'key',
|
|
});
|
|
cleaned = {
|
|
...cleaned,
|
|
identityFileId: key.id,
|
|
identityFilePaths: [pendingReferenceKeyPath],
|
|
authMethod: "key",
|
|
};
|
|
}
|
|
const preserveLegacyTheme = initialData?.theme != null && cleaned.themeOverride !== false;
|
|
const preserveLegacyFontFamily = initialData?.fontFamily != null && cleaned.fontFamilyOverride !== false;
|
|
const preserveLegacyFontSize = initialData?.fontSize != null && cleaned.fontSizeOverride !== false;
|
|
|
|
if (cleaned.themeOverride === false) {
|
|
delete cleaned.theme;
|
|
} else if (preserveLegacyTheme && cleaned.theme == null) {
|
|
cleaned.theme = initialData?.theme;
|
|
}
|
|
|
|
if (cleaned.fontFamilyOverride === false) {
|
|
delete cleaned.fontFamily;
|
|
} else if (preserveLegacyFontFamily && cleaned.fontFamily == null) {
|
|
cleaned.fontFamily = initialData?.fontFamily;
|
|
}
|
|
|
|
if (cleaned.fontSizeOverride === false) {
|
|
delete cleaned.fontSize;
|
|
} else if (preserveLegacyFontSize && cleaned.fontSize == null) {
|
|
cleaned.fontSize = initialData?.fontSize;
|
|
}
|
|
|
|
if ((cleaned.protocol && cleaned.protocol !== "ssh") || cleaned.moshEnabled || cleaned.etEnabled) {
|
|
delete cleaned.x11Forwarding;
|
|
}
|
|
if (onSnippetsChange || onHostsChange) {
|
|
const hostId = cleaned.id;
|
|
if (snippets.length === 0 && !baselineHydratedRef.current) {
|
|
// Vault may publish hosts before snippets hydrate. Do not wipe unresolved
|
|
// connectScriptIds while the script catalog is still incomplete.
|
|
} else {
|
|
const savedQueueIds = openingQueueIdsRef.current;
|
|
const finalQueueIds = getEditableHostConnectScriptIds(cleaned, snippets);
|
|
const synced = syncSnippetsForHostConnectQueueSave(
|
|
snippets,
|
|
hostId,
|
|
savedQueueIds,
|
|
finalQueueIds,
|
|
{
|
|
baselineSnippets: baselineSnippetsRef.current,
|
|
hosts: allHosts,
|
|
},
|
|
);
|
|
cleaned = {
|
|
...cleaned,
|
|
connectScriptIds: synced.connectScriptIds.length > 0
|
|
? synced.connectScriptIds
|
|
: undefined,
|
|
// sanitizeHost reconstitutes connectScriptIds from loginScriptId when
|
|
// the explicit queue is absent; clear the legacy field when emptied.
|
|
...(synced.connectScriptIds.length === 0
|
|
? { loginScriptId: undefined }
|
|
: {}),
|
|
};
|
|
if (synced.changed && onSnippetsChange) {
|
|
onSnippetsChange(synced.snippets);
|
|
}
|
|
if (onHostsChange && synced.hosts.length > 0) {
|
|
const peerChanged = synced.hosts.some((host) => {
|
|
if (host.id === cleaned.id) return false;
|
|
const original = allHosts.find((entry) => entry.id === host.id);
|
|
return original !== undefined && original !== host;
|
|
});
|
|
if (peerChanged) {
|
|
// Append only the script IDs this save newly queued onto each peer.
|
|
// Leave the edited host alone so onSave can three-way merge it.
|
|
onHostsChange((prevHosts) => {
|
|
const peerAdds = new Map<string, string[]>();
|
|
for (const syncedHost of synced.hosts) {
|
|
if (syncedHost.id === cleaned.id) continue;
|
|
const snapshot = allHosts.find((entry) => entry.id === syncedHost.id);
|
|
if (!snapshot || snapshot === syncedHost) continue;
|
|
const snapshotIds = snapshot.connectScriptIds ?? [];
|
|
const addedIds = (syncedHost.connectScriptIds ?? []).filter(
|
|
(id) => !snapshotIds.includes(id),
|
|
);
|
|
if (addedIds.length > 0) peerAdds.set(syncedHost.id, addedIds);
|
|
}
|
|
return prevHosts.map((host) => {
|
|
if (host.id === cleaned.id) return host;
|
|
const addedIds = peerAdds.get(host.id);
|
|
if (!addedIds || addedIds.length === 0) return host;
|
|
const currentIds = host.connectScriptIds ?? [];
|
|
const mergedIds = [...currentIds];
|
|
for (const id of addedIds) {
|
|
if (!mergedIds.includes(id)) mergedIds.push(id);
|
|
}
|
|
if (
|
|
mergedIds.length === currentIds.length
|
|
&& mergedIds.every((id, index) => id === currentIds[index])
|
|
) {
|
|
return host;
|
|
}
|
|
return { ...host, connectScriptIds: mergedIds };
|
|
});
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
onSave(stripBuiltInConnectionFieldsForPluginHost(cleaned));
|
|
} finally {
|
|
savingHostRef.current = false;
|
|
}
|
|
};
|
|
|
|
const handleCreateGroup = () => {
|
|
if (!newGroupName.trim()) return;
|
|
const fullPath = newGroupParent
|
|
? `${newGroupParent}/${newGroupName.trim()}`
|
|
: newGroupName.trim();
|
|
onCreateGroup?.(fullPath);
|
|
setGroupInputValue(fullPath);
|
|
update("group", fullPath);
|
|
setNewGroupName("");
|
|
setNewGroupParent("");
|
|
setActiveSubPanel("none");
|
|
};
|
|
|
|
const availableHostsForChain = useMemo(() => {
|
|
const chainedIds = new Set(form.hostChain?.hostIds || []);
|
|
// Chain hops are SSH ProxyJump hops; never offer local/serial/telnet/plugin
|
|
// entries (e.g. saved local shells) as jump hosts.
|
|
return allHosts.filter((h) =>
|
|
h.id !== form.id
|
|
&& !chainedIds.has(h.id)
|
|
&& canServeAsSshJumpHost(h),
|
|
);
|
|
}, [allHosts, form.id, form.hostChain?.hostIds]);
|
|
|
|
const chainedHosts = useMemo(() => {
|
|
const ids = form.hostChain?.hostIds || [];
|
|
return ids
|
|
.map((id) => allHosts.find((h) => h.id === id))
|
|
.filter(Boolean) as Host[];
|
|
}, [allHosts, form.hostChain?.hostIds]);
|
|
|
|
const groupOptions: ComboboxOption[] = useMemo(() => {
|
|
return groups.map((g) => ({
|
|
value: g,
|
|
label: g.includes("/") ? g.split("/").pop()! : g,
|
|
sublabel: g.includes("/") ? g : undefined,
|
|
}));
|
|
}, [groups]);
|
|
|
|
const tagOptions: ComboboxOption[] = useMemo(() => {
|
|
const allTagSet = new Set([...allTags, ...(form.tags || [])]);
|
|
return Array.from(allTagSet).map((t) => ({ value: t, label: t }));
|
|
}, [allTags, form.tags]);
|
|
|
|
const keysByCategory = useMemo(() => {
|
|
return {
|
|
key: availableKeys.filter((k) => k.category === "key"),
|
|
certificate: availableKeys.filter((k) => k.category === "certificate"),
|
|
identity: availableKeys.filter((k) => k.category === "identity"),
|
|
};
|
|
}, [availableKeys]);
|
|
|
|
const selectedTelnetIdentity = useMemo(() => {
|
|
if (!form.telnetIdentityId) return undefined;
|
|
return identities.find((i) => i.id === form.telnetIdentityId);
|
|
}, [form.telnetIdentityId, identities]);
|
|
|
|
const telnetIdentityOptions: ComboboxOption[] = useMemo(
|
|
() =>
|
|
identities.map((identity) => ({
|
|
value: identity.id,
|
|
label: identity.label,
|
|
sublabel: identity.username,
|
|
})),
|
|
[identities],
|
|
);
|
|
|
|
const filteredIdentitySuggestions = useMemo(() => {
|
|
if (selectedIdentity) return [];
|
|
const q = (form.username || "").toLowerCase().trim();
|
|
const base = identities;
|
|
const filtered = q
|
|
? base.filter(
|
|
(i) =>
|
|
i.label.toLowerCase().includes(q) ||
|
|
i.username.toLowerCase().includes(q),
|
|
)
|
|
: base;
|
|
return filtered.slice(0, 6);
|
|
}, [form.username, identities, selectedIdentity]);
|
|
|
|
useEffect(() => {
|
|
if (!identitySuggestionsOpen) return;
|
|
if (filteredIdentitySuggestions.length === 0) {
|
|
setIdentitySuggestionsOpen(false);
|
|
}
|
|
}, [filteredIdentitySuggestions.length, identitySuggestionsOpen]);
|
|
|
|
const applyIdentity = useCallback(
|
|
(identity: Identity) => {
|
|
setForm((prev) => ({
|
|
...prev,
|
|
identityId: identity.id,
|
|
username: identity.username,
|
|
authMethod: identity.authMethod,
|
|
password: undefined,
|
|
identityFileId: undefined,
|
|
identityFilePaths: undefined,
|
|
}));
|
|
setPendingReferenceKeyPath(null);
|
|
setSelectedCredentialType(null);
|
|
setCredentialPopoverOpen(false);
|
|
setIdentitySuggestionsOpen(false);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const clearIdentity = useCallback(() => {
|
|
setForm((prev) => detachEffectiveHostIdentity(prev, effectiveAuth.username));
|
|
setIdentitySuggestionsOpen(false);
|
|
}, [effectiveAuth.username]);
|
|
|
|
const updateTelnetIdentity = useCallback((identityId: string) => {
|
|
setForm((prev) => ({
|
|
...prev,
|
|
telnetIdentityId: identityId || undefined,
|
|
...(identityId
|
|
? {
|
|
telnetUsername: undefined,
|
|
telnetPassword: undefined,
|
|
}
|
|
: {}),
|
|
}));
|
|
}, []);
|
|
|
|
if (activeSubPanel === "create-group") {
|
|
return (
|
|
<CreateGroupPanel
|
|
newGroupName={newGroupName}
|
|
setNewGroupName={setNewGroupName}
|
|
newGroupParent={newGroupParent}
|
|
setNewGroupParent={setNewGroupParent}
|
|
groups={groups}
|
|
onSave={handleCreateGroup}
|
|
onBack={() => setActiveSubPanel("none")}
|
|
onCancel={onCancel}
|
|
layout={layout}
|
|
{...asideResizeProps}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (activeSubPanel === "proxy") {
|
|
return (
|
|
<ProxyPanel
|
|
proxyConfig={form.proxyConfig}
|
|
proxyProfiles={proxyProfiles}
|
|
identities={identities}
|
|
selectedProxyProfileId={form.proxyProfileId}
|
|
directProxy={form.directProxy}
|
|
onUpdateProxy={updateProxyConfig}
|
|
onSelectProxyProfile={selectProxyProfile}
|
|
onSelectDirectProxy={selectDirectProxy}
|
|
onClearProxy={clearProxyConfig}
|
|
onBack={() => setActiveSubPanel("none")}
|
|
onCancel={onCancel}
|
|
layout={layout}
|
|
{...asideResizeProps}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (activeSubPanel === "chain") {
|
|
return (
|
|
<ChainPanel
|
|
formLabel={form.label}
|
|
formHostname={form.hostname}
|
|
form={form}
|
|
chainedHosts={chainedHosts}
|
|
availableHostsForChain={availableHostsForChain}
|
|
onAddHost={addHostToChain}
|
|
onRemoveHost={removeHostFromChain}
|
|
onClearChain={clearHostChain}
|
|
onBack={() => setActiveSubPanel("none")}
|
|
onCancel={onCancel}
|
|
layout={layout}
|
|
{...asideResizeProps}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (activeSubPanel === "env-vars") {
|
|
return (
|
|
<EnvVarsPanel
|
|
hostLabel={form.label}
|
|
hostHostname={form.hostname}
|
|
environmentVariables={form.environmentVariables || []}
|
|
newEnvName={newEnvName}
|
|
newEnvValue={newEnvValue}
|
|
setNewEnvName={setNewEnvName}
|
|
setNewEnvValue={setNewEnvValue}
|
|
onAddEnvVar={addEnvVar}
|
|
onRemoveEnvVar={removeEnvVar}
|
|
onUpdateEnvVar={(index, field, value) => {
|
|
const newVars = [...(form.environmentVariables || [])];
|
|
newVars[index] = { ...newVars[index], [field]: value };
|
|
setForm((prev) => ({ ...prev, environmentVariables: newVars }));
|
|
}}
|
|
onSave={() => {
|
|
if (newEnvName.trim()) addEnvVar();
|
|
setActiveSubPanel("none");
|
|
}}
|
|
onBack={() => setActiveSubPanel("none")}
|
|
onCancel={onCancel}
|
|
layout={layout}
|
|
{...asideResizeProps}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (activeSubPanel === "theme-select") {
|
|
return (
|
|
<ThemeSelectPanel
|
|
open={true}
|
|
selectedThemeId={effectiveThemeId}
|
|
onSelect={(themeId) => {
|
|
if (themeId === effectiveThemeId && !hasEffectiveThemeOverride) {
|
|
setActiveSubPanel("none");
|
|
return;
|
|
}
|
|
setForm((prev) => ({ ...prev, theme: themeId, themeOverride: true }));
|
|
setActiveSubPanel("none");
|
|
}}
|
|
onClose={onCancel}
|
|
onBack={() => setActiveSubPanel("none")}
|
|
showBackButton={true}
|
|
layout={layout}
|
|
{...asideResizeProps}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (activeSubPanel === "telnet-theme-select") {
|
|
return (
|
|
<ThemeSelectPanel
|
|
open={true}
|
|
selectedThemeId={effectiveTelnetThemeId}
|
|
onSelect={(themeId) => {
|
|
const telnetConfig = form.protocols?.find(
|
|
(p) => p.protocol === "telnet",
|
|
);
|
|
if (telnetConfig) {
|
|
const newProtocols = form.protocols?.map((p) =>
|
|
p.protocol === "telnet" ? { ...p, theme: themeId } : p,
|
|
);
|
|
setForm((prev) => ({ ...prev, protocols: newProtocols }));
|
|
} else {
|
|
const newProtocols = [
|
|
...(form.protocols || []),
|
|
{
|
|
protocol: "telnet" as const,
|
|
port: effectiveTelnetPort,
|
|
enabled: true,
|
|
theme: themeId,
|
|
},
|
|
];
|
|
setForm((prev) => ({ ...prev, protocols: newProtocols }));
|
|
}
|
|
setActiveSubPanel("none");
|
|
}}
|
|
onClose={onCancel}
|
|
onBack={() => setActiveSubPanel("none")}
|
|
showBackButton={true}
|
|
layout={layout}
|
|
{...asideResizeProps}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<AsidePanel
|
|
open={true}
|
|
onClose={onCancel}
|
|
width="w-[420px]"
|
|
layout={layout}
|
|
className={className}
|
|
dataSection="host-details-panel"
|
|
{...asideResizeProps}
|
|
title={
|
|
initialData ? t("hostDetails.title.details") : t("hostDetails.title.new")
|
|
}
|
|
actions={
|
|
<>
|
|
{!pluginProtocolActive && form.protocol !== "telnet" && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="inline-flex">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={handleStartTest}
|
|
disabled={!canSaveHost}
|
|
aria-label={t("hostConnectionTest.title")}
|
|
>
|
|
<PlugZap size={16} />
|
|
</Button>
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">
|
|
{t("hostConnectionTest.title")}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={handleSubmit}
|
|
disabled={!canSaveHost}
|
|
aria-label={t("hostDetails.saveAria")}
|
|
>
|
|
<Check size={16} />
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<AsidePanelContent>
|
|
<HostDetailsSection
|
|
icon={<Settings2 size={14} className="text-muted-foreground" />}
|
|
title={t("hostDetails.section.general")}
|
|
>
|
|
<Input
|
|
placeholder={t("hostDetails.label.placeholder")}
|
|
value={form.label}
|
|
onChange={(e) => {
|
|
let value = e.target.value;
|
|
const targetGroup = groupInputValue.trim() || form.group || "";
|
|
const willBeManaged = managedSources.some(s =>
|
|
targetGroup === s.groupName || targetGroup.startsWith(s.groupName + "/")
|
|
);
|
|
const canBeManaged = !form.protocol || form.protocol === "ssh";
|
|
if (willBeManaged && canBeManaged) {
|
|
value = value.replace(/\s/g, '');
|
|
}
|
|
update("label", value);
|
|
}}
|
|
className="h-10"
|
|
/>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<div className="h-10 w-10 rounded-lg bg-secondary/80 flex items-center justify-center shrink-0">
|
|
<FolderPlus size={16} className="text-muted-foreground" />
|
|
</div>
|
|
<Combobox
|
|
options={groupOptions}
|
|
value={form.group || ""}
|
|
onValueChange={(val) => {
|
|
update("group", val);
|
|
setGroupInputValue(val);
|
|
}}
|
|
placeholder={t("hostDetails.group.placeholder")}
|
|
allowCreate={true}
|
|
onCreateNew={(val) => {
|
|
onCreateGroup?.(val);
|
|
update("group", val);
|
|
setGroupInputValue(val);
|
|
}}
|
|
createText="Create Group"
|
|
triggerClassName="flex-1 h-10"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<div className="h-10 w-10 rounded-lg bg-secondary/80 flex items-center justify-center shrink-0">
|
|
<Tag size={16} className="text-muted-foreground" />
|
|
</div>
|
|
<MultiCombobox
|
|
options={tagOptions}
|
|
values={form.tags || []}
|
|
onValuesChange={(vals) => update("tags", vals)}
|
|
placeholder="Add tags..."
|
|
allowCreate={true}
|
|
onCreateNew={(val) => onCreateTag?.(val)}
|
|
createText="Create Tag"
|
|
triggerClassName="flex-1 min-h-10"
|
|
/>
|
|
</div>
|
|
</HostDetailsSection>
|
|
|
|
<PluginConnectionSection
|
|
form={form}
|
|
setForm={setForm}
|
|
t={t}
|
|
onValidityChange={setPluginConnectionValid}
|
|
identities={identities}
|
|
keys={availableKeys}
|
|
/>
|
|
|
|
{!pluginProtocolActive ? <HostDetailsConnectionSections
|
|
t={t}
|
|
form={form}
|
|
setForm={setForm}
|
|
totpFlushRef={totpFlushRef}
|
|
update={update}
|
|
groupDefaults={effectiveGroupDefaults}
|
|
effectiveAuthMethod={effectiveAuthMethod}
|
|
effectiveUsername={effectiveAuth.username}
|
|
effectiveIdentityId={effectiveAuthHost.identityId}
|
|
selectedIdentity={selectedIdentity}
|
|
clearIdentity={clearIdentity}
|
|
identities={identities}
|
|
identitySuggestionsOpen={identitySuggestionsOpen}
|
|
filteredIdentitySuggestions={filteredIdentitySuggestions}
|
|
setIdentitySuggestionsOpen={setIdentitySuggestionsOpen}
|
|
availableKeys={availableKeys}
|
|
applyIdentity={applyIdentity}
|
|
showPassword={showPassword}
|
|
setShowPassword={setShowPassword}
|
|
pendingReferenceKeyPath={pendingReferenceKeyPath}
|
|
setPendingReferenceKeyPath={setPendingReferenceKeyPath}
|
|
selectedCredentialType={selectedCredentialType}
|
|
setSelectedCredentialType={setSelectedCredentialType}
|
|
credentialPopoverOpen={credentialPopoverOpen}
|
|
setCredentialPopoverOpen={setCredentialPopoverOpen}
|
|
keysByCategory={keysByCategory}
|
|
newKeyFilePath={newKeyFilePath}
|
|
setNewKeyFilePath={setNewKeyFilePath}
|
|
addLocalKeyFilePath={addLocalKeyFilePath}
|
|
handleDistroModeChange={handleDistroModeChange}
|
|
distroOptions={distroOptions}
|
|
effectiveFormDistro={effectiveFormDistro}
|
|
getDistroOptionLabel={getDistroOptionLabel}
|
|
/> : null}
|
|
|
|
{onSnippetsChange ? (
|
|
<HostDetailsScriptsSection
|
|
host={form}
|
|
onHostChange={setForm}
|
|
snippets={snippets}
|
|
t={t}
|
|
/>
|
|
) : null}
|
|
|
|
<HostDetailsSection
|
|
icon={<FileText size={14} className="text-muted-foreground shrink-0" />}
|
|
title={t("hostDetails.notes.label")}
|
|
hint={t("hostDetails.notes.help")}
|
|
action={
|
|
<Switch
|
|
checked={showNotesEditor}
|
|
onCheckedChange={setShowNotesEditor}
|
|
aria-label={
|
|
showNotesEditor
|
|
? t("hostDetails.notes.toggle.hide")
|
|
: t("hostDetails.notes.toggle.show")
|
|
}
|
|
/>
|
|
}
|
|
>
|
|
{showNotesEditor ? (
|
|
<HostNotesEditor
|
|
panelKey={form.id}
|
|
value={form.notes ?? ""}
|
|
onChange={(notes) => update("notes", notes)}
|
|
showHeader={false}
|
|
defaultTab="edit"
|
|
/>
|
|
) : null}
|
|
</HostDetailsSection>
|
|
|
|
<HostDetailsAdvancedSections
|
|
t={t}
|
|
form={form}
|
|
setForm={setForm}
|
|
update={update}
|
|
effectiveThemeId={effectiveThemeId}
|
|
hasEffectiveThemeOverride={hasEffectiveThemeOverride}
|
|
effectiveFontSize={effectiveFontSize}
|
|
hasEffectiveFontSizeOverride={hasEffectiveFontSizeOverride}
|
|
sshAgentStatus={sshAgentStatus}
|
|
sshForwardingAgentStatus={sshForwardingAgentStatus}
|
|
effectiveGroupDefaults={effectiveGroupDefaults}
|
|
effectiveAuthMethod={effectiveAuthMethod}
|
|
showAlgorithmOverrides={showAlgorithmOverrides}
|
|
setShowAlgorithmOverrides={setShowAlgorithmOverrides}
|
|
chainedHosts={chainedHosts}
|
|
setActiveSubPanel={setActiveSubPanel}
|
|
clearHostChain={clearHostChain}
|
|
proxySummaryType={proxySummaryType}
|
|
proxySummaryLabel={proxySummaryLabel}
|
|
proxySummaryTooltip={proxySummaryTooltip}
|
|
clearProxyConfig={clearProxyConfig}
|
|
/>
|
|
|
|
<div className="flex items-center gap-3 py-2">
|
|
<div className="flex-1 h-px bg-border/60" />
|
|
<span className="text-xs text-muted-foreground">{t("hostDetails.otherProtocols")}</span>
|
|
<div className="flex-1 h-px bg-border/60" />
|
|
</div>
|
|
|
|
{form.telnetEnabled || form.protocol === "telnet" ? (
|
|
<HostDetailsSection
|
|
icon={<Plus size={14} className="text-muted-foreground" />}
|
|
title="Telnet"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex-1 min-w-0 h-10 flex items-center gap-2 bg-secondary/70 border border-border/70 rounded-md px-3">
|
|
<span className="text-xs text-muted-foreground">{t("hostDetails.telnetOn")}</span>
|
|
<div className="ml-auto w-1/2 min-w-0 flex items-center gap-2 justify-end">
|
|
<Input
|
|
type="number"
|
|
value={effectiveTelnetPort}
|
|
onChange={(e) => update("telnetPort", parseOptionalPortInput(e.target.value))}
|
|
className="h-8 flex-1 min-w-0 text-center"
|
|
/>
|
|
<span className="text-xs text-muted-foreground">{t("hostDetails.port")}</span>
|
|
</div>
|
|
</div>
|
|
{form.protocol !== "telnet" && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
|
onClick={() => update("telnetEnabled", false)}
|
|
>
|
|
<X size={14} />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between gap-3">
|
|
<span className="text-xs text-muted-foreground">{t("hostDetails.telnet.setDefault")}</span>
|
|
<Switch
|
|
checked={form.protocol === "telnet"}
|
|
onCheckedChange={(checked) => {
|
|
const nextProtocol = checked ? "telnet" : "ssh";
|
|
setForm((prev) => ({
|
|
...prev,
|
|
protocol: nextProtocol,
|
|
port: resolvePrimaryProtocolSwitchPort(
|
|
prev.port,
|
|
nextProtocol,
|
|
Boolean(groupDefaults?.telnetPort),
|
|
Boolean(groupDefaults?.port),
|
|
),
|
|
}));
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<p className="text-xs font-semibold">{t("hostDetails.telnet.credentials")}</p>
|
|
{identities.length > 0 && (
|
|
<Combobox
|
|
options={telnetIdentityOptions}
|
|
value={form.telnetIdentityId || ""}
|
|
onValueChange={updateTelnetIdentity}
|
|
placeholder={t("hostDetails.telnet.identity.placeholder")}
|
|
emptyText={t("common.noResultsFound")}
|
|
className="w-full"
|
|
/>
|
|
)}
|
|
{form.telnetIdentityId ? (
|
|
<div className="text-xs text-muted-foreground">
|
|
{selectedTelnetIdentity
|
|
? `${selectedTelnetIdentity.username} - ${selectedTelnetIdentity.label}`
|
|
: t("hostDetails.identity.missing")}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<Input
|
|
placeholder={t("hostDetails.telnet.username")}
|
|
value={effectiveTelnetUsername}
|
|
onChange={(e) =>
|
|
update("telnetUsername" as keyof Host, e.target.value)
|
|
}
|
|
className="h-10"
|
|
/>
|
|
<div className="relative">
|
|
<Input
|
|
placeholder={t("hostDetails.telnet.password")}
|
|
type={showTelnetPassword ? "text" : "password"}
|
|
value={effectiveTelnetPassword}
|
|
onChange={(e) =>
|
|
update("telnetPassword" as keyof Host, e.target.value)
|
|
}
|
|
className="h-10 pr-10"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowTelnetPassword(!showTelnetPassword)}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
{showTelnetPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<TerminalEncodingSelect
|
|
value={form.charset}
|
|
inheritedValue={effectiveGroupDefaults?.charset}
|
|
onValueChange={(value) => update("charset", value)}
|
|
/>
|
|
|
|
<button
|
|
type="button"
|
|
className="w-full flex items-center gap-3 p-2 rounded-lg bg-secondary/50 hover:bg-secondary transition-colors text-left"
|
|
onClick={() => setActiveSubPanel("telnet-theme-select")}
|
|
>
|
|
<div
|
|
className="w-12 h-8 rounded-md border border-border/60 flex items-center justify-center text-[6px] font-mono overflow-hidden"
|
|
style={{
|
|
backgroundColor:
|
|
customThemeStore.getThemeById(effectiveTelnetThemeId)?.colors.background || "#100F0F",
|
|
color:
|
|
customThemeStore.getThemeById(effectiveTelnetThemeId)?.colors.foreground || "#CECDC3",
|
|
}}
|
|
>
|
|
<div className="p-0.5">
|
|
<div
|
|
style={{
|
|
color: customThemeStore.getThemeById(effectiveTelnetThemeId)?.colors.green,
|
|
}}
|
|
>
|
|
$
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<span className="text-sm flex-1">
|
|
{customThemeStore.getThemeById(effectiveTelnetThemeId)?.name || "Flexoki Dark"}
|
|
</span>
|
|
</button>
|
|
</HostDetailsSection>
|
|
) : (
|
|
<Button
|
|
variant="ghost"
|
|
className="w-full h-10 justify-start gap-2 border border-dashed border-border/60"
|
|
onClick={() => {
|
|
update("telnetEnabled", true);
|
|
}}
|
|
>
|
|
<Plus size={14} />
|
|
{t("hostDetails.telnet.add")}
|
|
</Button>
|
|
)}
|
|
</AsidePanelContent>
|
|
<AsidePanelFooter>
|
|
<Button
|
|
className="w-full h-10"
|
|
onClick={handleSubmit}
|
|
disabled={!canSaveHost}
|
|
>
|
|
{t("common.save")}
|
|
</Button>
|
|
</AsidePanelFooter>
|
|
<HostConnectionTestDialog
|
|
open={testOpen}
|
|
onClose={handleCloseTest}
|
|
host={form}
|
|
keys={availableKeys}
|
|
test={hostConnectionTest}
|
|
/>
|
|
</AsidePanel>
|
|
);
|
|
};
|
|
|
|
|
|
export default HostDetailsPanel;
|