mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
* feat: 1Password Connect secret sources for SSH credentials Hosts and credentials can hold op://vault/item/field references instead of secrets; they are resolved at connect time from the user's secret source (1Password Connect) at the single point where every subsystem receives plaintext credentials, so terminal, SFTP, Docker, metrics and tunnels all work without per-subsystem changes. Sources are per user, optionally shared, with the access token encrypted under the owner's data key; resolved values are cached briefly in memory. * style: format secret source changes
2755 lines
123 KiB
TypeScript
2755 lines
123 KiB
TypeScript
import React, { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
TERMINAL_THEMES,
|
|
TERMINAL_FONTS,
|
|
BELL_STYLES,
|
|
FAST_SCROLL_MODIFIERS,
|
|
CURSOR_STYLES,
|
|
} from "@/lib/terminal-themes";
|
|
import { Button } from "@/components/button";
|
|
import { Input } from "@/components/input";
|
|
import { PasswordInput } from "@/components/password-input";
|
|
import { Slider } from "@/components/slider";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "@/components/tooltip";
|
|
import {
|
|
TERMINAL_FONT_ZOOM_MIN,
|
|
TERMINAL_FONT_ZOOM_MAX,
|
|
} from "@/features/terminal/terminal-font-zoom";
|
|
import {
|
|
Globe,
|
|
Info,
|
|
Layers, // --- tmux-monitor ---
|
|
LayoutGrid,
|
|
Network,
|
|
Palette,
|
|
Pencil,
|
|
Plus,
|
|
Settings,
|
|
Shield,
|
|
Upload,
|
|
X,
|
|
Zap,
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
|
|
import { TerminalPreview } from "@/features/terminal/TerminalPreview";
|
|
import {
|
|
createSSHHost,
|
|
updateSSHHost,
|
|
getSnippets,
|
|
subscribeTunnelStatuses,
|
|
connectTunnel,
|
|
disconnectTunnel,
|
|
getUserInfo,
|
|
getVaultProfiles,
|
|
getHostPassword,
|
|
adminCreateUserHost,
|
|
adminUpdateUserHost,
|
|
adminGetHostPassword,
|
|
adminGetUserSnippets,
|
|
createCredential,
|
|
adminCreateUserCredential,
|
|
} from "@/main-axios";
|
|
import { getTailscaleDevices, getHostDefaults } from "@/api/settings-api";
|
|
import {
|
|
getUserPreferences,
|
|
saveUserPreferences,
|
|
parseCustomThemes,
|
|
type SavedCustomTheme,
|
|
} from "@/api/open-tabs-api";
|
|
import type { Host, VaultProfile } from "@/types/ui-types";
|
|
import type { SSHHost, TunnelStatus } from "@/types";
|
|
import { useTabsSafe } from "@/shell/TabContext";
|
|
import {
|
|
buildHostEditorPayload,
|
|
createHostEditorForm,
|
|
mapSnippetResponse,
|
|
omitOwnerSshAuthFromSharedEdit,
|
|
terminalAppearanceKeys,
|
|
type HostAuthType,
|
|
type HostBellStyle,
|
|
type HostBackspaceMode,
|
|
type HostCursorStyle,
|
|
type HostFastScrollModifier,
|
|
type HostProtocols,
|
|
} from "./HostEditorData";
|
|
import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext";
|
|
import {
|
|
HostDockerTab,
|
|
HostProxmoxTab,
|
|
HostFilesTab,
|
|
} from "./HostEditorFeatureTabs";
|
|
import { HostEditorGeneralTab } from "./HostEditorGeneralTab";
|
|
import { canEditHost } from "./host-permissions";
|
|
import {
|
|
HostEditorRdpTab,
|
|
HostEditorTelnetTab,
|
|
HostEditorVncTab,
|
|
} from "./HostEditorGuacamoleTabs";
|
|
import { HostStatsTab } from "./HostEditorStatsTab";
|
|
import { VaultProfileManager } from "./VaultProfileManager";
|
|
import {
|
|
SecretReferenceHint,
|
|
SecretSourceManager,
|
|
} from "./SecretSourceManager";
|
|
import { findHostByTunnelEndpoint } from "@/features/tunnel/tunnel-endpoints";
|
|
import {
|
|
toCredentialOption,
|
|
type CredentialOption,
|
|
} from "./quick-created-credential";
|
|
|
|
const CUSTOM_FONT_OPTION = "__custom__";
|
|
|
|
export function HostEditor({
|
|
host,
|
|
activeTab,
|
|
onBack,
|
|
onSave,
|
|
protocols,
|
|
onProtocolChange,
|
|
onDirtyChange,
|
|
onTabChange,
|
|
hosts,
|
|
credentials,
|
|
adminTargetUserId,
|
|
simpleMode = false,
|
|
onEditCredential,
|
|
}: {
|
|
host: Host | null;
|
|
activeTab: string;
|
|
/** Collapses the General tab to the fields needed to reach a host. */
|
|
simpleMode?: boolean;
|
|
onBack: () => void;
|
|
onSave: (saved: SSHHost) => void;
|
|
protocols: HostProtocols;
|
|
onProtocolChange: (p: Partial<typeof protocols>) => void;
|
|
onDirtyChange?: (dirty: boolean) => void;
|
|
onTabChange: (tab: string) => void;
|
|
hosts: Host[];
|
|
credentials: { id: string; name: string; username: string }[];
|
|
// When set, the editor works on another user's host through the admin
|
|
// impersonation endpoints instead of the signed-in user's own data.
|
|
adminTargetUserId?: string;
|
|
// Opens the currently-selected credential for editing (from within the
|
|
// host editor), so the user can tweak/rotate it without leaving the flow.
|
|
onEditCredential?: (credentialId: string) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const { setPreviewTerminalTheme } = useTabsSafe();
|
|
const connectionDefaults = useConnectionDefaults();
|
|
const [form, setForm] = useState(() =>
|
|
createHostEditorForm(host, undefined, connectionDefaults),
|
|
);
|
|
const [isCustomFont, setIsCustomFont] = useState(
|
|
() => !TERMINAL_FONTS.some((f) => f.value === form.fontFamily),
|
|
);
|
|
|
|
const setField = <K extends keyof typeof form>(k: K, v: (typeof form)[K]) => {
|
|
onDirtyChange?.(true);
|
|
setForm((p) => ({
|
|
...p,
|
|
[k]: v,
|
|
...(terminalAppearanceKeys.includes(
|
|
k as (typeof terminalAppearanceKeys)[number],
|
|
)
|
|
? { inheritTerminalAppearance: false }
|
|
: {}),
|
|
}));
|
|
};
|
|
|
|
const setGuacField = (key: string, value: unknown) => {
|
|
onDirtyChange?.(true);
|
|
setForm((current) => ({
|
|
...current,
|
|
inheritRemoteDesktopDefaults: false,
|
|
guacamoleConfig: { ...current.guacamoleConfig, [key]: value },
|
|
}));
|
|
};
|
|
|
|
const [saving, setSaving] = useState(false);
|
|
const [snippets, setSnippets] = useState<{ id: number; name: string }[]>([]);
|
|
const [tunnelStatuses, setTunnelStatuses] = useState<
|
|
Record<string, TunnelStatus>
|
|
>({});
|
|
const [tailscaleDevices, setTailscaleDevices] = useState<
|
|
Array<{
|
|
id: string;
|
|
name: string;
|
|
hostname: string;
|
|
addresses: string[];
|
|
os: string;
|
|
lastSeen: string;
|
|
}>
|
|
>([]);
|
|
const [tailscaleHasApiKey, setTailscaleHasApiKey] = useState(false);
|
|
const [tailscaleLoading, setTailscaleLoading] = useState(false);
|
|
const [tailscaleDeviceError, setTailscaleDeviceError] = useState(false);
|
|
const [connectingTunnel, setConnectingTunnel] = useState<number | null>(null);
|
|
const [isOidcUser, setIsOidcUser] = useState(false);
|
|
const [vaultProfiles, setVaultProfiles] = useState<VaultProfile[]>([]);
|
|
const [showVaultManager, setShowVaultManager] = useState(false);
|
|
const [showSecretSources, setShowSecretSources] = useState(false);
|
|
const [quickCredentialName, setQuickCredentialName] = useState("");
|
|
const [creatingQuickCredential, setCreatingQuickCredential] = useState(false);
|
|
const [showQuickCredentialDialog, setShowQuickCredentialDialog] =
|
|
useState(false);
|
|
const [quickCreatedCredential, setQuickCreatedCredential] =
|
|
useState<CredentialOption | null>(null);
|
|
const [savedThemes, setSavedThemes] = useState<SavedCustomTheme[]>([]);
|
|
const [savingTheme, setSavingTheme] = useState(false);
|
|
|
|
const reloadSavedThemes = () => {
|
|
getUserPreferences()
|
|
.then((prefs) => setSavedThemes(parseCustomThemes(prefs.customThemes)))
|
|
.catch(() => {});
|
|
};
|
|
|
|
useEffect(() => {
|
|
reloadSavedThemes();
|
|
}, []);
|
|
|
|
const handleSaveAsGlobalTheme = async () => {
|
|
const colors = form.customThemeColors;
|
|
if (!colors) return;
|
|
const name = window.prompt(t("hosts.saveGlobalThemeNamePrompt"));
|
|
if (!name || !name.trim()) return;
|
|
setSavingTheme(true);
|
|
try {
|
|
const newTheme: SavedCustomTheme = {
|
|
id: `theme-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
name: name.trim(),
|
|
colors,
|
|
};
|
|
const updated = [...savedThemes, newTheme];
|
|
await saveUserPreferences({
|
|
customThemes: JSON.stringify(updated),
|
|
});
|
|
setSavedThemes(updated);
|
|
toast.success(t("hosts.saveGlobalThemeSuccess"));
|
|
} catch {
|
|
toast.error(t("hosts.saveGlobalThemeError"));
|
|
} finally {
|
|
setSavingTheme(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteGlobalTheme = async (id: string) => {
|
|
const updated = savedThemes.filter((theme) => theme.id !== id);
|
|
try {
|
|
await saveUserPreferences({
|
|
customThemes: JSON.stringify(updated),
|
|
});
|
|
setSavedThemes(updated);
|
|
} catch {
|
|
toast.error(t("hosts.saveGlobalThemeError"));
|
|
}
|
|
};
|
|
|
|
const handleApplyGlobalTheme = (id: string) => {
|
|
const theme = savedThemes.find((entry) => entry.id === id);
|
|
if (!theme) return;
|
|
setField("customThemeColors", { ...theme.colors });
|
|
};
|
|
|
|
const reloadVaultProfiles = () => {
|
|
getVaultProfiles()
|
|
.then((res) => setVaultProfiles(res as unknown as VaultProfile[]))
|
|
.catch(() => {});
|
|
};
|
|
|
|
useEffect(() => {
|
|
getUserInfo()
|
|
.then((info) => setIsOidcUser(info.is_oidc))
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const loadSnippets = adminTargetUserId
|
|
? adminGetUserSnippets(adminTargetUserId)
|
|
: getSnippets();
|
|
loadSnippets
|
|
.then((res) => setSnippets(mapSnippetResponse(res)))
|
|
.catch(() => {});
|
|
reloadVaultProfiles();
|
|
}, [adminTargetUserId]);
|
|
|
|
useEffect(() => {
|
|
if (!connectionDefaults.ready) return;
|
|
if (host) {
|
|
setForm(createHostEditorForm(host, undefined, connectionDefaults));
|
|
return;
|
|
}
|
|
getHostDefaults()
|
|
.then((d) => setForm(createHostEditorForm(null, d, connectionDefaults)))
|
|
.catch(() => {});
|
|
}, [host, connectionDefaults]);
|
|
|
|
useEffect(() => {
|
|
if (!host?.id || form.vncAuthType !== "direct" || form.vncPassword) return;
|
|
|
|
let cancelled = false;
|
|
const loadVncPassword = adminTargetUserId
|
|
? adminGetHostPassword(adminTargetUserId, Number(host.id), "vncPassword")
|
|
: getHostPassword(Number(host.id), "vncPassword");
|
|
loadVncPassword.then((password) => {
|
|
if (cancelled || !password) return;
|
|
setForm((prev) =>
|
|
prev.vncPassword ? prev : { ...prev, vncPassword: password },
|
|
);
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [form.vncAuthType, form.vncPassword, host?.id, adminTargetUserId]);
|
|
|
|
useEffect(() => {
|
|
if (activeTab !== "tunnels") return;
|
|
const unsub = subscribeTunnelStatuses((s) => setTunnelStatuses(s));
|
|
return unsub;
|
|
}, [activeTab]);
|
|
|
|
useEffect(() => {
|
|
if (form.authType !== "tailscale") return;
|
|
setTailscaleLoading(true);
|
|
setTailscaleDeviceError(false);
|
|
getTailscaleDevices()
|
|
.then((res) => {
|
|
setTailscaleDevices(res?.devices ?? []);
|
|
setTailscaleHasApiKey(res?.hasApiKey ?? false);
|
|
setTailscaleDeviceError(!!res?.error);
|
|
})
|
|
.catch(() => setTailscaleDeviceError(true))
|
|
.finally(() => setTailscaleLoading(false));
|
|
}, [form.authType]);
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true);
|
|
try {
|
|
const fullData = buildHostEditorPayload(form, protocols);
|
|
const data = lockAuthReferences
|
|
? omitOwnerSshAuthFromSharedEdit(fullData)
|
|
: fullData;
|
|
let saved: SSHHost;
|
|
if (adminTargetUserId) {
|
|
saved = host
|
|
? await adminUpdateUserHost(adminTargetUserId, Number(host.id), data)
|
|
: await adminCreateUserHost(adminTargetUserId, data);
|
|
} else {
|
|
saved = host
|
|
? await updateSSHHost(Number(host.id), data)
|
|
: await createSSHHost(data);
|
|
}
|
|
toast.success(host ? t("hosts.hostUpdated") : t("hosts.hostCreated"));
|
|
setPreviewTerminalTheme(null);
|
|
onSave(saved);
|
|
} catch {
|
|
toast.error(t("hosts.failedToSave"));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const authMethod = form.authType;
|
|
const availableCredentials =
|
|
quickCreatedCredential &&
|
|
!credentials.some(
|
|
(credential) => credential.id === quickCreatedCredential.id,
|
|
)
|
|
? [...credentials, quickCreatedCredential]
|
|
: credentials;
|
|
const selectedCredential = availableCredentials.find(
|
|
(c) => String(c.id) === String(form.credentialId),
|
|
);
|
|
|
|
const canQuickCreateCredential =
|
|
(authMethod === "password" || authMethod === "key") &&
|
|
(authMethod === "password"
|
|
? !!form.password || !!host?.hasPassword
|
|
: (!!form.key && form.key !== "existing_key") || !!host?.hasKey);
|
|
|
|
const openQuickCredentialDialog = () => {
|
|
setQuickCredentialName(form.name || form.username || "");
|
|
setShowQuickCredentialDialog(true);
|
|
};
|
|
|
|
const handleQuickCreateCredential = async () => {
|
|
if (!quickCredentialName.trim()) {
|
|
toast.error(t("hosts.credentialNameRequired"));
|
|
return;
|
|
}
|
|
setCreatingQuickCredential(true);
|
|
try {
|
|
const fetchField = (field: "password" | "key" | "keyPassword") =>
|
|
adminTargetUserId
|
|
? adminGetHostPassword(adminTargetUserId, Number(host?.id), field)
|
|
: getHostPassword(Number(host?.id), field);
|
|
|
|
const data: Record<string, unknown> = {
|
|
name: quickCredentialName,
|
|
username: form.username || null,
|
|
folder: form.folder || null,
|
|
};
|
|
|
|
if (authMethod === "password") {
|
|
data.authType = "password";
|
|
data.password =
|
|
form.password && form.password !== "existing_password"
|
|
? form.password
|
|
: host?.hasPassword
|
|
? await fetchField("password")
|
|
: null;
|
|
} else {
|
|
const key =
|
|
form.key && form.key !== "existing_key"
|
|
? form.key
|
|
: host?.hasKey
|
|
? await fetchField("key")
|
|
: null;
|
|
const keyPassword =
|
|
form.keyPassword && form.keyPassword !== "existing_key_password"
|
|
? form.keyPassword
|
|
: host?.hasKeyPassword
|
|
? await fetchField("keyPassword")
|
|
: null;
|
|
data.authType = "key";
|
|
data.key = key;
|
|
data.keyPassword = keyPassword;
|
|
data.password =
|
|
form.password && form.password !== "existing_password"
|
|
? form.password
|
|
: null;
|
|
}
|
|
|
|
const created = adminTargetUserId
|
|
? await adminCreateUserCredential(adminTargetUserId, data)
|
|
: await createCredential(data);
|
|
const credential = toCredentialOption(created);
|
|
if (!credential) throw new Error(t("hosts.failedToSaveCredential"));
|
|
|
|
setQuickCreatedCredential(credential);
|
|
setForm((current) => ({
|
|
...current,
|
|
authType: "credential",
|
|
credentialId: credential.id,
|
|
}));
|
|
toast.success(t("hosts.credentialCreated"));
|
|
if (!adminTargetUserId) {
|
|
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
|
}
|
|
setShowQuickCredentialDialog(false);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : null;
|
|
toast.error(msg || t("hosts.failedToSaveCredential"));
|
|
} finally {
|
|
setCreatingQuickCredential(false);
|
|
}
|
|
};
|
|
|
|
// Shared hosts: view-level recipients see a read-only editor; edit-level
|
|
// recipients may change the host but never its credential/vault references
|
|
// or auth type (owner-only, enforced server-side too).
|
|
const isSharedHost = !!host?.isShared;
|
|
const readOnly = isSharedHost && host !== null && !canEditHost(host);
|
|
const lockAuthReferences = isSharedHost && !readOnly;
|
|
|
|
const handleProtocolToggle = (
|
|
proto: keyof typeof protocols,
|
|
value: boolean,
|
|
) => {
|
|
onDirtyChange?.(true);
|
|
onProtocolChange({ [proto]: value });
|
|
const tabForProto: Record<string, string> = {
|
|
enableSsh: "ssh",
|
|
enableRdp: "rdp",
|
|
enableVnc: "vnc",
|
|
enableTelnet: "telnet",
|
|
};
|
|
const sshGroupTabs = [
|
|
"ssh",
|
|
"terminal",
|
|
"tunnels",
|
|
"docker",
|
|
"files",
|
|
"host-metrics",
|
|
];
|
|
if (!value) {
|
|
if (proto === "enableSsh" && sshGroupTabs.includes(activeTab)) {
|
|
onTabChange("general");
|
|
} else if (activeTab === tabForProto[proto]) {
|
|
onTabChange("general");
|
|
}
|
|
}
|
|
if (value && tabForProto[proto]) onTabChange(tabForProto[proto]);
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
{isSharedHost && (
|
|
<div className="flex items-start gap-2.5 p-3 border border-accent-brand/30 bg-accent-brand/5 text-xs text-muted-foreground">
|
|
<Shield className="size-3.5 shrink-0 mt-0.5 text-accent-brand" />
|
|
<div>
|
|
{readOnly
|
|
? t("hosts.sharing.viewOnlyBanner", {
|
|
owner: host?.ownerUsername || "?",
|
|
})
|
|
: t("hosts.sharing.sharedEditBanner", {
|
|
owner: host?.ownerUsername || "?",
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<fieldset
|
|
disabled={readOnly}
|
|
className={`flex flex-col gap-3 min-w-0 ${readOnly ? "opacity-80" : ""}`}
|
|
>
|
|
<div className="flex flex-col gap-3">
|
|
{activeTab === "general" && (
|
|
<HostEditorGeneralTab
|
|
form={form}
|
|
setField={setField}
|
|
protocols={protocols}
|
|
handleProtocolToggle={handleProtocolToggle}
|
|
hosts={hosts}
|
|
host={host}
|
|
simpleMode={simpleMode}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === "ssh" && (
|
|
<>
|
|
<SectionCard
|
|
title={t("hosts.connectionLabel")}
|
|
icon={<Globe className="size-3.5" />}
|
|
>
|
|
<div className="flex flex-col gap-4 py-3">
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.sshPort")}
|
|
</label>
|
|
<Input
|
|
type="number"
|
|
placeholder="22"
|
|
value={form.sshPort}
|
|
onChange={(e) =>
|
|
setField("sshPort", Number(e.target.value))
|
|
}
|
|
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</SectionCard>
|
|
<SectionCard
|
|
title={t("hosts.authenticationLabel")}
|
|
icon={<Shield className="size-3.5" />}
|
|
action={
|
|
!isSharedHost &&
|
|
canQuickCreateCredential && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
|
|
onClick={openQuickCredentialDialog}
|
|
>
|
|
<Plus className="size-3 mr-1" />
|
|
{t("hosts.createCredentialFromHostBtn")}
|
|
</Button>
|
|
)
|
|
}
|
|
>
|
|
{isSharedHost && (
|
|
<div className="py-3 text-xs text-muted-foreground">
|
|
{t(
|
|
host?.shareSshAuth
|
|
? "hosts.sharing.ownerAuthShared"
|
|
: "hosts.sharing.ownerAuthPrivate",
|
|
)}
|
|
</div>
|
|
)}
|
|
<div
|
|
className={`flex flex-col gap-4 py-3 ${isSharedHost ? "hidden" : ""}`}
|
|
>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.authenticationMethod")}
|
|
</label>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{t("hosts.authenticationMethodDesc")}
|
|
</p>
|
|
<div
|
|
className="flex flex-wrap gap-2"
|
|
role="radiogroup"
|
|
aria-label={t("hosts.authenticationMethod")}
|
|
>
|
|
{[
|
|
"password",
|
|
"key",
|
|
"credential",
|
|
"vault",
|
|
"none",
|
|
"opkssh",
|
|
"stepca",
|
|
"tailscale",
|
|
"agent",
|
|
].map((m) => (
|
|
<button
|
|
key={m}
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={authMethod === m}
|
|
disabled={lockAuthReferences}
|
|
title={
|
|
lockAuthReferences
|
|
? t("hosts.sharing.ownerOnlyControl")
|
|
: undefined
|
|
}
|
|
onClick={() => {
|
|
setField("authType", m as HostAuthType);
|
|
}}
|
|
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${authMethod === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
|
>
|
|
{m}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{lockAuthReferences && (
|
|
<p className="text-[10px] text-muted-foreground/60">
|
|
{t("hosts.sharing.ownerOnlyControl")}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-border pt-4 mt-1">
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.username")}
|
|
</label>
|
|
<Input
|
|
placeholder="root"
|
|
value={form.username}
|
|
disabled={
|
|
authMethod === "credential" &&
|
|
!!selectedCredential?.username &&
|
|
!form.overrideCredentialUsername
|
|
}
|
|
onFocus={() => {
|
|
if (form.username === "root")
|
|
setField("username", "");
|
|
}}
|
|
onBlur={() => {
|
|
if (form.username === "")
|
|
setField("username", "root");
|
|
}}
|
|
onChange={(e) => setField("username", e.target.value)}
|
|
/>
|
|
{isOidcUser && (
|
|
<p className="text-[10px] text-muted-foreground/60">
|
|
{t("hosts.oidcUsernameHint")}
|
|
</p>
|
|
)}
|
|
{authMethod === "stepca" && (
|
|
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.stepcaLabel")}
|
|
</span>
|
|
<a
|
|
href="https://smallstep.com/docs/step-ca/provisioners/#oauthoidc-single-sign-on"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-[10px] text-accent-brand hover:underline"
|
|
>
|
|
{t("hosts.docsLink")}
|
|
</a>
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{t("hosts.stepcaDesc")}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{authMethod === "tailscale" && (
|
|
<p className="text-[10px] text-muted-foreground/60">
|
|
{t("hosts.tailscaleUsernameHint")}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{authMethod === "password" && (
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.password")}
|
|
</label>
|
|
<PasswordInput
|
|
className="h-8 text-xs pr-8"
|
|
placeholder={
|
|
form.password === "existing_password"
|
|
? t("hosts.passwordSaved")
|
|
: "••••••••"
|
|
}
|
|
value={
|
|
form.password === "existing_password"
|
|
? ""
|
|
: form.password
|
|
}
|
|
onFocus={() => {
|
|
if (form.password === "existing_password")
|
|
setField("password", "");
|
|
}}
|
|
onChange={(e) => setField("password", e.target.value)}
|
|
/>
|
|
<SecretReferenceHint
|
|
onManage={() => setShowSecretSources((v) => !v)}
|
|
/>
|
|
</div>
|
|
)}
|
|
{(authMethod === "password" || authMethod === "key") &&
|
|
showSecretSources && (
|
|
<SecretSourceManager
|
|
onClose={() => setShowSecretSources(false)}
|
|
/>
|
|
)}
|
|
{authMethod === "key" && (
|
|
<>
|
|
<div className="flex flex-col gap-1.5 col-span-2">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.sshPrivateKey")}
|
|
</label>
|
|
<div className="flex gap-1">
|
|
{(["paste", "upload"] as const).map((tab) => (
|
|
<button
|
|
key={tab}
|
|
type="button"
|
|
onClick={() => setField("keySubTab", tab)}
|
|
className={`px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest border transition-colors ${form.keySubTab === tab ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
|
>
|
|
{tab === "paste"
|
|
? t("hosts.keyPasteTab")
|
|
: t("hosts.keyUploadTab")}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{form.keySubTab === "paste" ? (
|
|
<div className="flex flex-col gap-1.5">
|
|
{form.key === "existing_key" && (
|
|
<div className="px-3 py-2 text-[10px] border border-accent-brand/30 bg-accent-brand/5 text-accent-brand">
|
|
{t("hosts.keySaved")} —{" "}
|
|
{t("hosts.keyReplaceNotice")}
|
|
</div>
|
|
)}
|
|
<textarea
|
|
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
|
rows={5}
|
|
value={
|
|
form.key === "existing_key" ? "" : form.key
|
|
}
|
|
onChange={(e) =>
|
|
setField("key", e.target.value)
|
|
}
|
|
className="w-full px-3 py-2 text-[10px] bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-2">
|
|
<label
|
|
className={`flex items-center justify-center gap-2 h-16 border-2 border-dashed cursor-pointer transition-colors ${form.key ? "border-accent-brand/40 bg-accent-brand/5 text-accent-brand" : "border-border text-muted-foreground hover:border-accent-brand/30 hover:text-foreground"}`}
|
|
>
|
|
<Upload className="size-4" />
|
|
<span className="text-xs">
|
|
{form.key === "existing_key"
|
|
? t("hosts.keySaved")
|
|
: form.key
|
|
? t("hosts.keyFileLoaded")
|
|
: t("hosts.keyUploadClick")}
|
|
</span>
|
|
<input
|
|
type="file"
|
|
accept=".pem,.key,.ppk,.txt"
|
|
className="hidden"
|
|
onChange={async (e) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
const text = await file.text();
|
|
setField("key", text);
|
|
e.target.value = "";
|
|
}}
|
|
/>
|
|
</label>
|
|
{form.key && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setField("key", "")}
|
|
className="text-[10px] text-destructive self-start"
|
|
>
|
|
{form.key === "existing_key"
|
|
? t("hosts.replaceKey")
|
|
: t("hosts.clearKey")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.keyPassphrase")}
|
|
</label>
|
|
<PasswordInput
|
|
className="h-8 text-xs pr-8"
|
|
placeholder={
|
|
form.keyPassword === "existing_key_password"
|
|
? t("hosts.keyPassphraseSaved")
|
|
: t("hosts.optional")
|
|
}
|
|
value={
|
|
form.keyPassword === "existing_key_password"
|
|
? ""
|
|
: form.keyPassword
|
|
}
|
|
onFocus={() => {
|
|
if (form.keyPassword === "existing_key_password")
|
|
setField("keyPassword", "");
|
|
}}
|
|
onChange={(e) =>
|
|
setField("keyPassword", e.target.value)
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.password")} ({t("common.optional")})
|
|
</label>
|
|
<PasswordInput
|
|
className="h-8 text-xs pr-8"
|
|
placeholder={
|
|
form.password === "existing_password"
|
|
? t("hosts.passwordSaved")
|
|
: "••••••••"
|
|
}
|
|
value={
|
|
form.password === "existing_password"
|
|
? ""
|
|
: form.password
|
|
}
|
|
onFocus={() => {
|
|
if (form.password === "existing_password")
|
|
setField("password", "");
|
|
}}
|
|
onChange={(e) =>
|
|
setField("password", e.target.value)
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.keyTypeLabel")}
|
|
</label>
|
|
<select
|
|
value={form.keyType}
|
|
onChange={(e) =>
|
|
setField("keyType", e.target.value)
|
|
}
|
|
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
|
>
|
|
<option value="auto">
|
|
{t("hosts.keyTypeAuto")}
|
|
</option>
|
|
<option value="ssh-rsa">RSA</option>
|
|
<option value="ssh-ed25519">Ed25519</option>
|
|
<option value="ecdsa-sha2-nistp256">
|
|
ECDSA P-256
|
|
</option>
|
|
<option value="ecdsa-sha2-nistp384">
|
|
ECDSA P-384
|
|
</option>
|
|
<option value="ecdsa-sha2-nistp521">
|
|
ECDSA P-521
|
|
</option>
|
|
<option value="ssh-dss">DSA</option>
|
|
<option value="ssh-rsa-sha2-256">
|
|
RSA SHA2-256
|
|
</option>
|
|
<option value="ssh-rsa-sha2-512">
|
|
RSA SHA2-512
|
|
</option>
|
|
</select>
|
|
</div>
|
|
</>
|
|
)}
|
|
{authMethod === "vault" && (
|
|
<div className="flex flex-col gap-1.5 col-span-2">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.vaultProfile")}
|
|
</label>
|
|
<select
|
|
value={form.vaultProfileId}
|
|
onChange={(e) =>
|
|
setField("vaultProfileId", e.target.value)
|
|
}
|
|
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
|
>
|
|
<option value="">
|
|
{t("hosts.selectAVaultProfile")}
|
|
</option>
|
|
{vaultProfiles.map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
{p.shared ? `${p.name} (shared)` : p.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{t("hosts.vaultProfileHint")}
|
|
</p>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<a
|
|
href="https://docs.termix.site/features/authentication/vault"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-[10px] text-accent-brand hover:underline"
|
|
>
|
|
{t("hosts.docsLink")}
|
|
</a>
|
|
<button
|
|
type="button"
|
|
className="text-[10px] text-accent-brand hover:text-accent-brand/80"
|
|
onClick={() => setShowVaultManager((v) => !v)}
|
|
>
|
|
{t("hosts.vaultManageProfiles")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{authMethod === "vault" && showVaultManager && (
|
|
<VaultProfileManager
|
|
profiles={vaultProfiles}
|
|
onChanged={reloadVaultProfiles}
|
|
onClose={() => setShowVaultManager(false)}
|
|
/>
|
|
)}
|
|
{authMethod === "credential" && (
|
|
<>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.storedCredential")}
|
|
</label>
|
|
<div className="flex items-center gap-2">
|
|
<select
|
|
value={form.credentialId}
|
|
disabled={lockAuthReferences}
|
|
title={
|
|
lockAuthReferences
|
|
? t("hosts.sharing.ownerOnlyControl")
|
|
: undefined
|
|
}
|
|
onChange={(e) => {
|
|
const newId = e.target.value;
|
|
setField("credentialId", newId);
|
|
if (!form.overrideCredentialUsername) {
|
|
const cred = availableCredentials.find(
|
|
(c) => c.id === newId,
|
|
);
|
|
if (cred?.username)
|
|
setField("username", cred.username);
|
|
}
|
|
}}
|
|
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<option value="">
|
|
{t("hosts.selectACredential")}
|
|
</option>
|
|
{availableCredentials.map((c) => (
|
|
<option key={c.id} value={c.id}>
|
|
{c.username
|
|
? `${c.name} (${c.username})`
|
|
: c.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{onEditCredential && form.credentialId && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-9 px-2.5 shrink-0"
|
|
title={t("hosts.editCredentialAction")}
|
|
onClick={() =>
|
|
onEditCredential(form.credentialId)
|
|
}
|
|
>
|
|
<Pencil className="size-3.5" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{selectedCredential?.username && (
|
|
<div className="flex items-center justify-between col-span-2 pt-1">
|
|
<div className="flex flex-col gap-0.5">
|
|
<span className="text-xs font-medium">
|
|
{t("hosts.overrideCredentialUsername")}
|
|
</span>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{t("hosts.overrideCredentialUsernameDesc")}
|
|
</span>
|
|
</div>
|
|
<FakeSwitch
|
|
checked={form.overrideCredentialUsername}
|
|
onChange={(v) => {
|
|
setField("overrideCredentialUsername", v);
|
|
if (!v && selectedCredential?.username) {
|
|
setField(
|
|
"username",
|
|
selectedCredential.username,
|
|
);
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.password")} ({t("common.optional")})
|
|
</label>
|
|
<PasswordInput
|
|
className="h-8 text-xs pr-8"
|
|
placeholder={
|
|
form.password === "existing_password"
|
|
? t("hosts.passwordSaved")
|
|
: "••••••••"
|
|
}
|
|
value={
|
|
form.password === "existing_password"
|
|
? ""
|
|
: form.password
|
|
}
|
|
onFocus={() => {
|
|
if (form.password === "existing_password")
|
|
setField("password", "");
|
|
}}
|
|
onChange={(e) =>
|
|
setField("password", e.target.value)
|
|
}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
{authMethod === "opkssh" && (
|
|
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.opksshLabel")}
|
|
</span>
|
|
<a
|
|
href="https://docs.termix.site/features/authentication/opkssh"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-[10px] text-accent-brand hover:underline"
|
|
>
|
|
{t("hosts.docsLink")}
|
|
</a>
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{t("hosts.opksshDesc")}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{authMethod === "tailscale" && (
|
|
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.tailscaleDeviceSelect")}
|
|
</label>
|
|
<a
|
|
href="https://docs.termix.site/features/networking/tailscale"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-[10px] text-accent-brand hover:underline"
|
|
>
|
|
{t("hosts.tailscaleDocsLink")}
|
|
</a>
|
|
</div>
|
|
{tailscaleLoading ? (
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{t("hosts.tailscaleLoadingDevices")}
|
|
</p>
|
|
) : tailscaleDeviceError ? (
|
|
<p className="text-[10px] text-destructive">
|
|
{t("hosts.tailscaleDeviceLoadFailed")}
|
|
</p>
|
|
) : !tailscaleHasApiKey ? (
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{t("hosts.tailscaleNoApiKey")}
|
|
</p>
|
|
) : tailscaleDevices.length === 0 ? (
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{t("hosts.tailscaleNoDevices")}
|
|
</p>
|
|
) : (
|
|
<>
|
|
<select
|
|
className="w-full border border-border bg-background text-foreground text-xs px-2 py-1.5 focus:outline-none focus:border-accent-brand/50"
|
|
value={
|
|
tailscaleDevices.find((d) =>
|
|
d.addresses.includes(form.ip),
|
|
)?.id ?? ""
|
|
}
|
|
onChange={(e) => {
|
|
const device = tailscaleDevices.find(
|
|
(d) => d.id === e.target.value,
|
|
);
|
|
if (device) {
|
|
const tailscaleIp =
|
|
device.addresses.find((a) =>
|
|
a.startsWith("100."),
|
|
) ??
|
|
device.addresses[0] ??
|
|
"";
|
|
setField("ip", tailscaleIp);
|
|
}
|
|
}}
|
|
>
|
|
<option value="" disabled>
|
|
{t("hosts.tailscaleDeviceSelectPlaceholder")}
|
|
</option>
|
|
{tailscaleDevices.map((d) => (
|
|
<option key={d.id} value={d.id}>
|
|
{d.hostname} (
|
|
{d.addresses.find((a) =>
|
|
a.startsWith("100."),
|
|
) ??
|
|
d.addresses[0] ??
|
|
""}
|
|
)
|
|
</option>
|
|
))}
|
|
</select>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{t("hosts.tailscaleDeviceAutoFill")}
|
|
</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
{authMethod === "agent" && (
|
|
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.agentLabel")}
|
|
</span>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{t("hosts.agentAuthenticationDesc")}
|
|
</p>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.agentSocketPathLabel")}
|
|
</label>
|
|
<Input
|
|
placeholder={t("hosts.agentSocketPathPlaceholder")}
|
|
value={form.agentSocketPath}
|
|
onChange={(e) =>
|
|
setField("agentSocketPath", e.target.value)
|
|
}
|
|
/>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{t("hosts.agentSocketPathHint")}
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.agentIdentityLabel")}
|
|
</label>
|
|
<Input
|
|
placeholder={t("hosts.agentIdentityPlaceholder")}
|
|
value={form.agentIdentity}
|
|
onChange={(e) =>
|
|
setField("agentIdentity", e.target.value)
|
|
}
|
|
/>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{t("hosts.agentIdentityHint")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<SettingRow
|
|
label={t("hosts.shareSshAuthLabel")}
|
|
description={t("hosts.shareSshAuthDesc")}
|
|
>
|
|
<FakeSwitch
|
|
checked={form.shareSshAuth}
|
|
onChange={(v) => setField("shareSshAuth", v)}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
label={t("hosts.forceKeyboardInteractiveLabel")}
|
|
description={t("hosts.forceKeyboardInteractiveShortDesc")}
|
|
>
|
|
<FakeSwitch
|
|
checked={form.forceKeyboardInteractive}
|
|
onChange={(v) => setField("forceKeyboardInteractive", v)}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
label={t("hosts.allowLegacyAlgorithmsLabel")}
|
|
badge={
|
|
form.allowLegacyAlgorithms
|
|
? t("hosts.insecure")
|
|
: undefined
|
|
}
|
|
description={t("hosts.allowLegacyAlgorithmsDesc")}
|
|
>
|
|
<FakeSwitch
|
|
checked={form.allowLegacyAlgorithms}
|
|
onChange={(v) => setField("allowLegacyAlgorithms", v)}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
label={t("hosts.sudoPasswordAutoFillLabel")}
|
|
description={t("hosts.sudoPasswordAutoFillDesc")}
|
|
>
|
|
<FakeSwitch
|
|
checked={form.sudoPasswordAutoFill}
|
|
onChange={(v) => setField("sudoPasswordAutoFill", v)}
|
|
/>
|
|
</SettingRow>
|
|
{form.sudoPasswordAutoFill && (
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.sudoPasswordLabel")}
|
|
</label>
|
|
<PasswordInput
|
|
className="h-8 text-xs pr-8"
|
|
placeholder={
|
|
form.sudoPassword === "existing_sudo_password"
|
|
? t("hosts.sudoPasswordSaved")
|
|
: "••••••••"
|
|
}
|
|
value={
|
|
form.sudoPassword === "existing_sudo_password"
|
|
? ""
|
|
: form.sudoPassword
|
|
}
|
|
onFocus={() => {
|
|
if (form.sudoPassword === "existing_sudo_password")
|
|
setField("sudoPassword", "");
|
|
}}
|
|
onChange={(e) =>
|
|
setField("sudoPassword", e.target.value)
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</SectionCard>
|
|
</>
|
|
)}
|
|
|
|
{activeTab === "terminal" && (
|
|
<>
|
|
<SectionCard
|
|
title={t("hosts.terminalAppearance")}
|
|
icon={<Palette className="size-3.5" />}
|
|
>
|
|
<div className="flex flex-col gap-4 py-3">
|
|
<SettingRow
|
|
label={t("hosts.useUserDefaults", {
|
|
defaultValue: "Use user defaults",
|
|
})}
|
|
description={t("hosts.useUserTerminalDefaultsDesc", {
|
|
defaultValue:
|
|
"Keep this host synchronized with Terminal defaults from User Profile.",
|
|
})}
|
|
>
|
|
<FakeSwitch
|
|
checked={form.inheritTerminalAppearance}
|
|
onChange={(value) =>
|
|
setField("inheritTerminalAppearance", value)
|
|
}
|
|
/>
|
|
</SettingRow>
|
|
<fieldset
|
|
disabled={form.inheritTerminalAppearance}
|
|
className={
|
|
form.inheritTerminalAppearance
|
|
? "contents opacity-60"
|
|
: "contents"
|
|
}
|
|
>
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.themePreview")}
|
|
</label>
|
|
<TerminalPreview
|
|
theme={form.theme}
|
|
fontSize={form.fontSize}
|
|
fontFamily={form.fontFamily}
|
|
cursorStyle={form.cursorStyle}
|
|
cursorBlink={form.cursorBlink}
|
|
letterSpacing={form.letterSpacing}
|
|
lineHeight={form.lineHeight}
|
|
customThemeColors={form.customThemeColors ?? undefined}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.colorTheme")}
|
|
</label>
|
|
<select
|
|
value={form.theme}
|
|
onChange={(e) => {
|
|
const newTheme = e.target.value;
|
|
setField("theme", newTheme);
|
|
setPreviewTerminalTheme(newTheme);
|
|
if (
|
|
newTheme === "custom" &&
|
|
!form.customThemeColors
|
|
) {
|
|
setField("customThemeColors", {
|
|
...TERMINAL_THEMES.termixDark.colors,
|
|
});
|
|
}
|
|
}}
|
|
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
|
>
|
|
{Object.entries(TERMINAL_THEMES)
|
|
.filter(
|
|
([key]) =>
|
|
key !== "termixDark" && key !== "termixLight",
|
|
)
|
|
.map(([key, theme]) => (
|
|
<option key={key} value={key}>
|
|
{theme.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<div className="flex items-center gap-1">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.fontFamilyLabel")}
|
|
</label>
|
|
<TooltipProvider delayDuration={200}>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Info className="size-3 text-muted-foreground" />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top">
|
|
{t("hosts.fontFamilyCustomHint")}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
<select
|
|
value={
|
|
isCustomFont ? CUSTOM_FONT_OPTION : form.fontFamily
|
|
}
|
|
onChange={(e) => {
|
|
if (e.target.value === CUSTOM_FONT_OPTION) {
|
|
setIsCustomFont(true);
|
|
setField("fontFamily", "");
|
|
} else {
|
|
setIsCustomFont(false);
|
|
setField("fontFamily", e.target.value);
|
|
}
|
|
}}
|
|
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring font-mono"
|
|
>
|
|
{TERMINAL_FONTS.map((f) => (
|
|
<option key={f.value} value={f.value}>
|
|
{f.label}
|
|
</option>
|
|
))}
|
|
<option value={CUSTOM_FONT_OPTION}>
|
|
{t("hosts.fontFamilyCustomOption")}
|
|
</option>
|
|
</select>
|
|
{isCustomFont && (
|
|
<Input
|
|
value={form.fontFamily}
|
|
onChange={(e) =>
|
|
setField("fontFamily", e.target.value)
|
|
}
|
|
placeholder={t("hosts.fontFamilyCustomPlaceholder")}
|
|
className="h-9 text-xs font-mono"
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.fontSizeLabel")}
|
|
</label>
|
|
<span className="text-[10px] text-muted-foreground tabular-nums">
|
|
{form.fontSize}px
|
|
</span>
|
|
</div>
|
|
<Slider
|
|
min={TERMINAL_FONT_ZOOM_MIN}
|
|
max={TERMINAL_FONT_ZOOM_MAX}
|
|
step={1}
|
|
value={[form.fontSize]}
|
|
onValueChange={([v]) => setField("fontSize", v)}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.cursorStyleLabel")}
|
|
</label>
|
|
<select
|
|
value={form.cursorStyle}
|
|
onChange={(e) =>
|
|
setField(
|
|
"cursorStyle",
|
|
e.target.value as HostCursorStyle,
|
|
)
|
|
}
|
|
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
|
>
|
|
{CURSOR_STYLES.map((s) => (
|
|
<option key={s.value} value={s.value}>
|
|
{s.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.letterSpacingPx")}
|
|
</label>
|
|
<span className="text-[10px] text-muted-foreground tabular-nums">
|
|
{form.letterSpacing}px
|
|
</span>
|
|
</div>
|
|
<Slider
|
|
min={-2}
|
|
max={10}
|
|
step={0.5}
|
|
value={[form.letterSpacing]}
|
|
onValueChange={([v]) => setField("letterSpacing", v)}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.lineHeightLabel")}
|
|
</label>
|
|
<span className="text-[10px] text-muted-foreground tabular-nums">
|
|
{form.lineHeight.toFixed(1)}
|
|
</span>
|
|
</div>
|
|
<Slider
|
|
min={1.0}
|
|
max={2.0}
|
|
step={0.1}
|
|
value={[form.lineHeight]}
|
|
onValueChange={([v]) => setField("lineHeight", v)}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.bellStyleLabel")}
|
|
</label>
|
|
<select
|
|
value={form.bellStyle}
|
|
onChange={(e) =>
|
|
setField(
|
|
"bellStyle",
|
|
e.target.value as HostBellStyle,
|
|
)
|
|
}
|
|
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
|
>
|
|
{BELL_STYLES.map((b) => (
|
|
<option key={b.value} value={b.value}>
|
|
{b.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.backspaceModeLabel")}
|
|
</label>
|
|
<select
|
|
value={form.backspaceMode}
|
|
onChange={(e) =>
|
|
setField(
|
|
"backspaceMode",
|
|
e.target.value as HostBackspaceMode,
|
|
)
|
|
}
|
|
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
|
>
|
|
<option value="normal">Normal (DEL)</option>
|
|
<option value="control-h">Control-H (BS)</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</fieldset>
|
|
{form.theme === "custom" && (
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex flex-col gap-1.5">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.savedThemesLabel")}
|
|
</label>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 text-[10px]"
|
|
disabled={savingTheme || !form.customThemeColors}
|
|
onClick={handleSaveAsGlobalTheme}
|
|
>
|
|
{t("hosts.saveAsGlobalTheme")}
|
|
</Button>
|
|
</div>
|
|
{savedThemes.length === 0 ? (
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{t("hosts.noSavedThemes")}
|
|
</p>
|
|
) : (
|
|
<div className="flex flex-col gap-1">
|
|
{savedThemes.map((theme) => (
|
|
<div
|
|
key={theme.id}
|
|
className="flex items-center justify-between gap-2 border border-border px-2 py-1"
|
|
>
|
|
<button
|
|
type="button"
|
|
title={t("hosts.applyGlobalThemeTooltip")}
|
|
onClick={() =>
|
|
handleApplyGlobalTheme(theme.id)
|
|
}
|
|
className="flex items-center gap-2 text-xs text-left flex-1 min-w-0 hover:text-foreground transition-colors"
|
|
>
|
|
<span
|
|
className="size-3.5 shrink-0 border border-border"
|
|
style={{
|
|
background: theme.colors.background,
|
|
}}
|
|
/>
|
|
<span className="truncate">{theme.name}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
title={t("hosts.deleteGlobalThemeTooltip")}
|
|
onClick={() =>
|
|
handleDeleteGlobalTheme(theme.id)
|
|
}
|
|
className="text-muted-foreground hover:text-destructive transition-colors shrink-0"
|
|
>
|
|
<X className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.customThemeColors")}
|
|
</label>
|
|
<button
|
|
type="button"
|
|
title={t("hosts.customThemeResetTooltip")}
|
|
onClick={() =>
|
|
setField("customThemeColors", {
|
|
...TERMINAL_THEMES.termixDark.colors,
|
|
})
|
|
}
|
|
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
{t("hosts.customThemeResetTooltip")}
|
|
</button>
|
|
</div>
|
|
{(
|
|
[
|
|
["background", "customThemeBackground"],
|
|
["foreground", "customThemeForeground"],
|
|
["cursor", "customThemeCursor"],
|
|
["cursorAccent", "customThemeCursorAccent"],
|
|
["selectionBackground", "customThemeSelection"],
|
|
] as const
|
|
).map(([key, labelKey]) => (
|
|
<div
|
|
key={key}
|
|
className="flex items-center justify-between gap-2"
|
|
>
|
|
<label className="text-xs text-muted-foreground min-w-0 flex-1">
|
|
{t(`hosts.${labelKey}`)}
|
|
</label>
|
|
<div className="flex items-center gap-1.5">
|
|
<input
|
|
type="color"
|
|
value={
|
|
form.customThemeColors?.[key] ??
|
|
TERMINAL_THEMES.termixDark.colors[key]
|
|
}
|
|
onChange={(e) =>
|
|
setField("customThemeColors", {
|
|
...(form.customThemeColors ??
|
|
TERMINAL_THEMES.termixDark.colors),
|
|
[key]: e.target.value,
|
|
})
|
|
}
|
|
className="h-7 w-10 cursor-pointer border border-border bg-background p-0.5"
|
|
/>
|
|
<span className="text-[10px] font-mono text-muted-foreground w-16 tabular-nums">
|
|
{form.customThemeColors?.[key] ??
|
|
TERMINAL_THEMES.termixDark.colors[key]}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mt-1">
|
|
{t("hosts.customThemeAnsiColors")}
|
|
</label>
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
|
|
{(
|
|
[
|
|
["black", "customThemeBlack"],
|
|
["brightBlack", "customThemeBrightBlack"],
|
|
["red", "customThemeRed"],
|
|
["brightRed", "customThemeBrightRed"],
|
|
["green", "customThemeGreen"],
|
|
["brightGreen", "customThemeBrightGreen"],
|
|
["yellow", "customThemeYellow"],
|
|
["brightYellow", "customThemeBrightYellow"],
|
|
["blue", "customThemeBlue"],
|
|
["brightBlue", "customThemeBrightBlue"],
|
|
["magenta", "customThemeMagenta"],
|
|
["brightMagenta", "customThemeBrightMagenta"],
|
|
["cyan", "customThemeCyan"],
|
|
["brightCyan", "customThemeBrightCyan"],
|
|
["white", "customThemeWhite"],
|
|
["brightWhite", "customThemeBrightWhite"],
|
|
] as const
|
|
).map(([key, labelKey]) => (
|
|
<div
|
|
key={key}
|
|
className="flex items-center justify-between gap-2"
|
|
>
|
|
<label className="text-xs text-muted-foreground min-w-0 flex-1 truncate">
|
|
{t(`hosts.${labelKey}`)}
|
|
</label>
|
|
<div className="flex items-center gap-1.5">
|
|
<input
|
|
type="color"
|
|
value={
|
|
form.customThemeColors?.[key] ??
|
|
TERMINAL_THEMES.termixDark.colors[key]
|
|
}
|
|
onChange={(e) =>
|
|
setField("customThemeColors", {
|
|
...(form.customThemeColors ??
|
|
TERMINAL_THEMES.termixDark.colors),
|
|
[key]: e.target.value,
|
|
})
|
|
}
|
|
className="h-7 w-10 cursor-pointer border border-border bg-background p-0.5"
|
|
/>
|
|
<span className="text-[10px] font-mono text-muted-foreground w-16 tabular-nums">
|
|
{form.customThemeColors?.[key] ??
|
|
TERMINAL_THEMES.termixDark.colors[key]}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<SettingRow
|
|
label={t("hosts.cursorBlinking")}
|
|
description={t("hosts.cursorBlinkingDesc")}
|
|
>
|
|
<FakeSwitch
|
|
checked={form.cursorBlink}
|
|
onChange={(v) => setField("cursorBlink", v)}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
label={t("hosts.rightClickSelectsWordLabel")}
|
|
description={t("hosts.rightClickSelectsWordShortDesc")}
|
|
>
|
|
<FakeSwitch
|
|
checked={form.rightClickSelectsWord}
|
|
onChange={(v) => setField("rightClickSelectsWord", v)}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
label={t("hosts.macOptionIsMetaLabel")}
|
|
description={t("hosts.macOptionIsMetaShortDesc")}
|
|
>
|
|
<FakeSwitch
|
|
checked={form.macOptionIsMeta}
|
|
onChange={(v) => setField("macOptionIsMeta", v)}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
label={t("hosts.syntaxHighlightingLabel")}
|
|
description={t("hosts.syntaxHighlightingDesc")}
|
|
>
|
|
<FakeSwitch
|
|
checked={form.syntaxHighlighting}
|
|
onChange={(v) => setField("syntaxHighlighting", v)}
|
|
/>
|
|
</SettingRow>
|
|
{form.syntaxHighlighting && (
|
|
<div className="flex flex-col ml-4">
|
|
{(
|
|
[
|
|
[
|
|
"logLevels",
|
|
"syntaxCategoryLogLevels",
|
|
"syntaxCategoryLogLevelsDesc",
|
|
],
|
|
[
|
|
"paths",
|
|
"syntaxCategoryPaths",
|
|
"syntaxCategoryPathsDesc",
|
|
],
|
|
[
|
|
"timestamps",
|
|
"syntaxCategoryTimestamps",
|
|
"syntaxCategoryTimestampsDesc",
|
|
],
|
|
[
|
|
"ipAddresses",
|
|
"syntaxCategoryIpAddresses",
|
|
"syntaxCategoryIpAddressesDesc",
|
|
],
|
|
[
|
|
"urls",
|
|
"syntaxCategoryUrls",
|
|
"syntaxCategoryUrlsDesc",
|
|
],
|
|
[
|
|
"numbers",
|
|
"syntaxCategoryNumbers",
|
|
"syntaxCategoryNumbersDesc",
|
|
],
|
|
] as const
|
|
).map(([key, labelKey, descKey]) => (
|
|
<SettingRow
|
|
key={key}
|
|
label={t(`hosts.${labelKey}`)}
|
|
description={t(`hosts.${descKey}`)}
|
|
>
|
|
<FakeSwitch
|
|
checked={
|
|
form.syntaxHighlightingOptions?.[key] ?? true
|
|
}
|
|
onChange={(v) =>
|
|
setField("syntaxHighlightingOptions", {
|
|
...form.syntaxHighlightingOptions,
|
|
[key]: v,
|
|
})
|
|
}
|
|
/>
|
|
</SettingRow>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.backgroundImageLabel")}
|
|
</label>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{t("hosts.backgroundImageDesc")}
|
|
</p>
|
|
<input
|
|
type="url"
|
|
value={form.backgroundImage}
|
|
onChange={(e) =>
|
|
setField("backgroundImage", e.target.value)
|
|
}
|
|
placeholder="https://example.com/image.jpg"
|
|
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring font-mono"
|
|
/>
|
|
</div>
|
|
{form.backgroundImage && (
|
|
<div className="flex flex-col gap-1.5">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.backgroundImageOpacityLabel")}
|
|
</label>
|
|
<span className="text-[10px] text-muted-foreground tabular-nums">
|
|
{Math.round(form.backgroundImageOpacity * 100)}%
|
|
</span>
|
|
</div>
|
|
<Slider
|
|
min={0.05}
|
|
max={1}
|
|
step={0.05}
|
|
value={[form.backgroundImageOpacity]}
|
|
onValueChange={([v]) =>
|
|
setField("backgroundImageOpacity", v)
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<SectionCard
|
|
title={t("hosts.behaviorAndAdvanced")}
|
|
icon={<Zap className="size-3.5" />}
|
|
>
|
|
<div className="flex flex-col gap-4 py-3">
|
|
<div className="flex flex-col gap-1.5">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.scrollbackBufferLabel")}
|
|
</label>
|
|
<span className="text-[10px] text-muted-foreground tabular-nums">
|
|
{form.scrollback.toLocaleString()}{" "}
|
|
{t("hosts.scrollbackMaxLines")}
|
|
</span>
|
|
</div>
|
|
<Slider
|
|
min={1000}
|
|
max={100000}
|
|
step={1000}
|
|
value={[form.scrollback]}
|
|
onValueChange={([v]) => setField("scrollback", v)}
|
|
/>
|
|
</div>
|
|
<SettingRow
|
|
label={t("hosts.enableAutoTmux")}
|
|
description={
|
|
<>
|
|
{t("hosts.enableAutoTmuxDesc")}{" "}
|
|
<a
|
|
href="https://docs.termix.site/features/terminal/tmux"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-accent-brand hover:underline"
|
|
>
|
|
{t("hosts.docsLink")}
|
|
</a>
|
|
</>
|
|
}
|
|
>
|
|
<FakeSwitch
|
|
checked={form.autoTmux}
|
|
onChange={(v) => setField("autoTmux", v)}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
label={t("hosts.sshAgentForwardingLabel")}
|
|
description={t("hosts.sshAgentForwardingShortDesc")}
|
|
>
|
|
<FakeSwitch
|
|
checked={form.agentForwarding}
|
|
onChange={(v) => setField("agentForwarding", v)}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
label={t("hosts.useSSHTitleLabel")}
|
|
description={t("hosts.useSSHTitleDesc")}
|
|
>
|
|
<FakeSwitch
|
|
checked={form.useSSHTitle}
|
|
onChange={(v) => setField("useSSHTitle", v)}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
label={t("hosts.enableAutoMosh")}
|
|
description={t("hosts.enableAutoMoshDesc")}
|
|
>
|
|
<FakeSwitch
|
|
checked={form.autoMosh}
|
|
onChange={(v) => setField("autoMosh", v)}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
label={t("hosts.enableSessionLogging")}
|
|
description={
|
|
<>
|
|
{t("hosts.enableSessionLoggingDesc")}{" "}
|
|
<a
|
|
href="https://docs.termix.site/features/terminal/session-recording"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-accent-brand hover:underline"
|
|
>
|
|
{t("hosts.docsLink")}
|
|
</a>
|
|
</>
|
|
}
|
|
>
|
|
<FakeSwitch
|
|
checked={form.enableSessionLogging}
|
|
onChange={(v) => setField("enableSessionLogging", v)}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
label={t("hosts.allowSessionSharing")}
|
|
description={t("hosts.allowSessionSharingDesc")}
|
|
>
|
|
<FakeSwitch
|
|
checked={form.allowSessionSharing}
|
|
onChange={(v) => setField("allowSessionSharing", v)}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
label={t("hosts.enableCommandHistory")}
|
|
description={
|
|
<>
|
|
{t("hosts.enableCommandHistoryDesc")}{" "}
|
|
<a
|
|
href="https://docs.termix.site/features/terminal/command-history"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-accent-brand hover:underline"
|
|
>
|
|
{t("hosts.docsLink")}
|
|
</a>
|
|
</>
|
|
}
|
|
>
|
|
<FakeSwitch
|
|
checked={form.enableCommandHistory}
|
|
onChange={(v) => setField("enableCommandHistory", v)}
|
|
/>
|
|
</SettingRow>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.localEchoLabel")}
|
|
</label>
|
|
<select
|
|
value={form.localEcho}
|
|
onChange={(e) =>
|
|
setField(
|
|
"localEcho",
|
|
e.target.value as "default" | "off" | "auto" | "on",
|
|
)
|
|
}
|
|
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
|
>
|
|
<option value="default">
|
|
{t("hosts.localEchoDefault")}
|
|
</option>
|
|
<option value="off">{t("hosts.localEchoOff")}</option>
|
|
<option value="auto">{t("hosts.localEchoAuto")}</option>
|
|
<option value="on">{t("hosts.localEchoOn")}</option>
|
|
</select>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{t("hosts.localEchoDesc")}{" "}
|
|
<a
|
|
href="https://docs.termix.site/features/terminal/appearance"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-accent-brand hover:underline"
|
|
>
|
|
{t("hosts.docsLink")}
|
|
</a>
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.linkClickBehaviorLabel")}
|
|
</label>
|
|
<select
|
|
value={form.linkClickBehavior}
|
|
onChange={(e) =>
|
|
setField(
|
|
"linkClickBehavior",
|
|
e.target.value as "default" | "confirm" | "direct",
|
|
)
|
|
}
|
|
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
|
>
|
|
<option value="default">
|
|
{t("hosts.linkClickBehaviorDefault")}
|
|
</option>
|
|
<option value="confirm">
|
|
{t("hosts.linkClickBehaviorConfirm")}
|
|
</option>
|
|
<option value="direct">
|
|
{t("hosts.linkClickBehaviorDirect")}
|
|
</option>
|
|
</select>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{t("hosts.linkClickBehaviorDesc")}
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.environmentVariablesLabel")}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
|
|
onClick={() =>
|
|
setField("environmentVariables", [
|
|
...form.environmentVariables,
|
|
{ key: "", value: "" },
|
|
])
|
|
}
|
|
>
|
|
<Plus className="size-3 mr-1" />{" "}
|
|
{t("hosts.addVariableBtn")}
|
|
</Button>
|
|
</div>
|
|
{form.environmentVariables.length === 0 && (
|
|
<p className="text-[10px] text-muted-foreground/50">
|
|
{t("hosts.noEnvVars")}
|
|
</p>
|
|
)}
|
|
<div className="flex flex-col gap-2">
|
|
{form.environmentVariables.map((ev, i) => (
|
|
<div key={i} className="flex items-center gap-2">
|
|
<Input
|
|
className="h-7 text-xs flex-1"
|
|
placeholder="KEY"
|
|
value={ev.key}
|
|
onChange={(e) => {
|
|
const updated = [...form.environmentVariables];
|
|
updated[i] = {
|
|
...updated[i],
|
|
key: e.target.value,
|
|
};
|
|
setField("environmentVariables", updated);
|
|
}}
|
|
/>
|
|
<Input
|
|
className="h-7 text-xs flex-1"
|
|
placeholder="VALUE"
|
|
value={ev.value}
|
|
onChange={(e) => {
|
|
const updated = [...form.environmentVariables];
|
|
updated[i] = {
|
|
...updated[i],
|
|
value: e.target.value,
|
|
};
|
|
setField("environmentVariables", updated);
|
|
}}
|
|
/>
|
|
<button
|
|
className="text-destructive"
|
|
onClick={() =>
|
|
setField(
|
|
"environmentVariables",
|
|
form.environmentVariables.filter(
|
|
(_, idx) => idx !== i,
|
|
),
|
|
)
|
|
}
|
|
>
|
|
<X className="size-4" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-border pt-4">
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.fastScrollModifierLabel")}
|
|
</label>
|
|
<select
|
|
value={form.fastScrollModifier}
|
|
onChange={(e) =>
|
|
setField(
|
|
"fastScrollModifier",
|
|
e.target.value as HostFastScrollModifier,
|
|
)
|
|
}
|
|
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
|
>
|
|
{FAST_SCROLL_MODIFIERS.map((m) => (
|
|
<option key={m.value} value={m.value}>
|
|
{m.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.fastScrollSensitivityLabel")}
|
|
</label>
|
|
<span className="text-[10px] text-muted-foreground tabular-nums">
|
|
{form.fastScrollSensitivity}
|
|
</span>
|
|
</div>
|
|
<Slider
|
|
min={1}
|
|
max={10}
|
|
step={1}
|
|
value={[form.fastScrollSensitivity]}
|
|
onValueChange={([v]) =>
|
|
setField("fastScrollSensitivity", v)
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{form.autoMosh && (
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.moshCommandLabel")}
|
|
</label>
|
|
<Input
|
|
placeholder="mosh"
|
|
value={form.moshCommand}
|
|
onChange={(e) =>
|
|
setField("moshCommand", e.target.value)
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-border pt-4">
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.startupSnippetLabel")}
|
|
</label>
|
|
<select
|
|
value={form.startupSnippetId ?? ""}
|
|
onChange={(e) =>
|
|
setField(
|
|
"startupSnippetId",
|
|
e.target.value ? Number(e.target.value) : null,
|
|
)
|
|
}
|
|
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
|
>
|
|
<option value="">{t("hosts.none")}</option>
|
|
{snippets.map((s) => (
|
|
<option key={s.id} value={s.id}>
|
|
{s.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-border pt-4">
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.keepaliveIntervalLabel")}
|
|
</label>
|
|
<Input
|
|
type="number"
|
|
value={form.keepaliveInterval}
|
|
onChange={(e) =>
|
|
setField("keepaliveInterval", Number(e.target.value))
|
|
}
|
|
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.maxKeepaliveMisses")}
|
|
</label>
|
|
<Input
|
|
type="number"
|
|
value={form.keepaliveCountMax}
|
|
onChange={(e) =>
|
|
setField("keepaliveCountMax", Number(e.target.value))
|
|
}
|
|
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</SectionCard>
|
|
{/* --- tmux-monitor --- */}
|
|
<SectionCard
|
|
title={t("tmuxMonitor.title")}
|
|
icon={<Layers className="size-3.5" />}
|
|
>
|
|
<div className="flex flex-col gap-4 py-3">
|
|
<SettingRow
|
|
label={t("hosts.enableTmuxMonitor")}
|
|
description={
|
|
<>
|
|
{t("hosts.enableTmuxMonitorDesc")}{" "}
|
|
<a
|
|
href="https://docs.termix.site/features/terminal/tmux"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-accent-brand hover:underline"
|
|
>
|
|
{t("hosts.docsLink")}
|
|
</a>
|
|
</>
|
|
}
|
|
>
|
|
<FakeSwitch
|
|
checked={form.enableTmuxMonitor}
|
|
onChange={(v) => setField("enableTmuxMonitor", v)}
|
|
/>
|
|
</SettingRow>
|
|
</div>
|
|
</SectionCard>
|
|
<SectionCard
|
|
title={t("terminalToolbar.title")}
|
|
icon={<LayoutGrid className="size-3.5" />}
|
|
>
|
|
<div className="flex flex-col gap-4 py-3">
|
|
<SettingRow
|
|
label={t("hosts.enableTerminalToolbar")}
|
|
description={t("hosts.enableTerminalToolbarDesc")}
|
|
>
|
|
<FakeSwitch
|
|
checked={form.enableTerminalToolbar}
|
|
onChange={(v) => setField("enableTerminalToolbar", v)}
|
|
/>
|
|
</SettingRow>
|
|
</div>
|
|
</SectionCard>
|
|
</>
|
|
)}
|
|
|
|
{activeTab === "tunnels" && (
|
|
<>
|
|
<SectionCard
|
|
title={t("hosts.tunnelSettings")}
|
|
icon={<Network className="size-3.5" />}
|
|
>
|
|
<div className="flex flex-col gap-4 py-3">
|
|
<SettingRow
|
|
label={t("hosts.enableTunneling")}
|
|
description={
|
|
<>
|
|
{t("hosts.enableTunnelingDesc")}{" "}
|
|
<a
|
|
href="https://docs.termix.site/features/networking/tunnels"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-accent-brand hover:underline"
|
|
>
|
|
{t("hosts.docsLink")}
|
|
</a>
|
|
</>
|
|
}
|
|
>
|
|
<FakeSwitch
|
|
checked={form.enableTunnel}
|
|
onChange={(v) => setField("enableTunnel", v)}
|
|
/>
|
|
</SettingRow>
|
|
<div className="text-xs text-muted-foreground p-3 bg-muted/30 border border-border space-y-1">
|
|
<p>{t("hosts.tunnelRequirementsText")}</p>
|
|
</div>
|
|
</div>
|
|
</SectionCard>
|
|
<SectionCard
|
|
title={t("hosts.serverTunnelsSection")}
|
|
icon={<Network className="size-3.5" />}
|
|
action={
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
|
|
onClick={() =>
|
|
setField("serverTunnels", [
|
|
...form.serverTunnels,
|
|
{
|
|
mode: "local" as const,
|
|
sourcePort: 8080,
|
|
endpointHost: "",
|
|
endpointPort: 80,
|
|
bindHost: "127.0.0.1",
|
|
maxRetries: 3,
|
|
retryInterval: 10,
|
|
autoStart: false,
|
|
},
|
|
])
|
|
}
|
|
>
|
|
<Plus className="size-3 mr-1" /> {t("hosts.addTunnelBtn")}
|
|
</Button>
|
|
}
|
|
>
|
|
<div className="flex flex-col gap-3 py-3">
|
|
{form.serverTunnels.length === 0 && (
|
|
<p className="text-[10px] text-muted-foreground/50 px-1">
|
|
{t("hosts.noTunnelsConfigured")}
|
|
</p>
|
|
)}
|
|
{form.serverTunnels.map((tun, i) => {
|
|
const endpointValue = (tun.endpointHost ?? "").trim();
|
|
const selectedEndpointHost = findHostByTunnelEndpoint(
|
|
hosts,
|
|
endpointValue,
|
|
);
|
|
const directEndpoint =
|
|
!endpointValue ||
|
|
endpointValue === "127.0.0.1" ||
|
|
endpointValue === "localhost";
|
|
const endpointInputId = `server-tunnel-endpoint-${host?.id ?? "new"}-${i}`;
|
|
const hostLabel =
|
|
host?.name ||
|
|
(host ? `${host.username}@${host.ip}` : "new");
|
|
const tunnelName = `${host?.id ?? "new"}::${i}::${hostLabel}::${tun.sourcePort}::${endpointValue}::${tun.endpointPort}`;
|
|
const tunnelStatus = tunnelStatuses[tunnelName]?.status as
|
|
string | undefined;
|
|
const isConnected = tunnelStatus === "connected";
|
|
return (
|
|
<div
|
|
key={i}
|
|
className="flex flex-col gap-3 p-3 border border-border bg-muted/20 relative group"
|
|
>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs font-bold text-muted-foreground">
|
|
{t("hosts.tunnelLabel", { number: i + 1 })}
|
|
</span>
|
|
<div
|
|
className={`size-1.5 rounded-full shrink-0 ${
|
|
isConnected
|
|
? "bg-accent-brand shadow-[0_0_4px_rgba(251,146,60,0.4)]"
|
|
: tunnelStatus === "error"
|
|
? "bg-red-400"
|
|
: "bg-muted-foreground/25"
|
|
}`}
|
|
title={tunnelStatus ?? "not connected"}
|
|
/>
|
|
{host && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={connectingTunnel === i}
|
|
className={`h-6 text-[10px] px-2 ${isConnected ? "border-destructive/40 text-destructive hover:bg-destructive/10" : "border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"}`}
|
|
onClick={async () => {
|
|
setConnectingTunnel(i);
|
|
try {
|
|
if (isConnected) {
|
|
await disconnectTunnel(tunnelName);
|
|
toast.success(
|
|
t("hosts.tunnelDisconnected"),
|
|
);
|
|
} else {
|
|
await connectTunnel({
|
|
name: tunnelName,
|
|
mode: tun.mode,
|
|
sourceHostId: Number(host.id),
|
|
tunnelIndex: i,
|
|
hostName: host.name,
|
|
sourceIP: host.ip,
|
|
sourceSSHPort:
|
|
host.sshPort ?? host.port,
|
|
sourceUsername: form.username,
|
|
sourcePassword:
|
|
form.password || undefined,
|
|
sourceAuthMethod: form.authType,
|
|
sourceSSHKey: form.key || undefined,
|
|
sourceKeyPassword:
|
|
form.keyPassword || undefined,
|
|
sourceCredentialId: form.credentialId
|
|
? Number(form.credentialId)
|
|
: undefined,
|
|
endpointIP: directEndpoint
|
|
? host.ip
|
|
: (selectedEndpointHost?.ip ??
|
|
endpointValue),
|
|
endpointSSHPort:
|
|
directEndpoint || selectedEndpointHost
|
|
? directEndpoint
|
|
? (host.sshPort ?? host.port)
|
|
: (selectedEndpointHost?.sshPort ??
|
|
selectedEndpointHost?.port ??
|
|
22)
|
|
: 22,
|
|
endpointHost: endpointValue,
|
|
endpointUsername: directEndpoint
|
|
? form.username
|
|
: (selectedEndpointHost?.username ??
|
|
""),
|
|
endpointAuthMethod:
|
|
selectedEndpointHost?.authType ??
|
|
"none",
|
|
endpointCredentialId:
|
|
selectedEndpointHost?.credentialId
|
|
? Number(
|
|
selectedEndpointHost.credentialId,
|
|
)
|
|
: undefined,
|
|
sourcePort: tun.sourcePort,
|
|
endpointPort: tun.endpointPort ?? 0,
|
|
bindHost: tun.bindHost ?? "127.0.0.1",
|
|
maxRetries: tun.maxRetries ?? 3,
|
|
retryInterval: tun.retryInterval ?? 10,
|
|
autoStart: tun.autoStart ?? false,
|
|
isPinned: false,
|
|
});
|
|
toast.success(
|
|
t("hosts.tunnelConnecting"),
|
|
);
|
|
}
|
|
} catch {
|
|
toast.error(
|
|
isConnected
|
|
? t("hosts.failedToDisconnectTunnel")
|
|
: t("hosts.failedToConnectTunnel"),
|
|
);
|
|
} finally {
|
|
setConnectingTunnel(null);
|
|
}
|
|
}}
|
|
>
|
|
{connectingTunnel === i
|
|
? "..."
|
|
: isConnected
|
|
? t("hosts.disconnectBtn")
|
|
: t("hosts.connectBtn")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 text-[10px] px-2 text-destructive"
|
|
onClick={() =>
|
|
setField(
|
|
"serverTunnels",
|
|
form.serverTunnels.filter(
|
|
(_, idx) => idx !== i,
|
|
),
|
|
)
|
|
}
|
|
>
|
|
{t("common.delete")}
|
|
</Button>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold text-muted-foreground">
|
|
{t("hosts.tunnelType")}
|
|
</label>
|
|
<div className="flex gap-2">
|
|
{(["remote", "local", "dynamic"] as const).map(
|
|
(m) => (
|
|
<button
|
|
key={m}
|
|
onClick={() => {
|
|
const updated = [...form.serverTunnels];
|
|
updated[i] = { ...updated[i], mode: m };
|
|
setField("serverTunnels", updated);
|
|
}}
|
|
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${tun.mode === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
|
>
|
|
{m}
|
|
</button>
|
|
),
|
|
)}
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground/70 mt-0.5">
|
|
{tun.mode === "local"
|
|
? t("hosts.tunnelModeLocalDesc")
|
|
: tun.mode === "remote"
|
|
? t("hosts.tunnelModeRemoteDesc")
|
|
: t("hosts.tunnelModeDynamicDesc")}
|
|
</p>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
{tun.mode !== "dynamic" && (
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[10px] font-bold text-muted-foreground">
|
|
{t("hosts.endpointHost")}
|
|
</label>
|
|
<Input
|
|
className="h-7 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
|
|
list={endpointInputId}
|
|
placeholder={t("hosts.endpointHostPlaceholder")}
|
|
value={tun.endpointHost ?? ""}
|
|
onChange={(e) => {
|
|
const updated = [...form.serverTunnels];
|
|
updated[i] = {
|
|
...updated[i],
|
|
endpointHost: e.target.value,
|
|
};
|
|
setField("serverTunnels", updated);
|
|
}}
|
|
/>
|
|
<datalist id={endpointInputId}>
|
|
{hosts
|
|
.filter((h) => h.enableSsh)
|
|
.map((h) => (
|
|
<option key={h.id} value={h.ip}>
|
|
{h.name || h.ip} ({h.ip})
|
|
</option>
|
|
))}
|
|
</datalist>
|
|
</div>
|
|
)}
|
|
{tun.mode !== "dynamic" && (
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[10px] font-bold text-muted-foreground">
|
|
{t("hosts.endpointPort")}
|
|
</label>
|
|
<Input
|
|
className="h-7 text-xs [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
|
type="number"
|
|
value={tun.endpointPort}
|
|
onChange={(e) => {
|
|
const updated = [...form.serverTunnels];
|
|
updated[i] = {
|
|
...updated[i],
|
|
endpointPort: Number(e.target.value),
|
|
};
|
|
setField("serverTunnels", updated);
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[10px] font-bold text-muted-foreground">
|
|
{t("hosts.bindHost")}
|
|
</label>
|
|
<Input
|
|
className="h-7 text-xs"
|
|
placeholder="127.0.0.1"
|
|
value={tun.bindHost ?? ""}
|
|
onChange={(e) => {
|
|
const updated = [...form.serverTunnels];
|
|
updated[i] = {
|
|
...updated[i],
|
|
bindHost: e.target.value,
|
|
};
|
|
setField("serverTunnels", updated);
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[10px] font-bold text-muted-foreground">
|
|
{t("hosts.sourcePort")}
|
|
</label>
|
|
<Input
|
|
className="h-7 text-xs [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
|
type="number"
|
|
value={tun.sourcePort}
|
|
onChange={(e) => {
|
|
const updated = [...form.serverTunnels];
|
|
updated[i] = {
|
|
...updated[i],
|
|
sourcePort: Number(e.target.value),
|
|
};
|
|
setField("serverTunnels", updated);
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[10px] font-bold text-muted-foreground">
|
|
{t("hosts.maxRetries")}
|
|
</label>
|
|
<Input
|
|
className="h-7 text-xs [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
|
type="number"
|
|
value={tun.maxRetries}
|
|
onChange={(e) => {
|
|
const updated = [...form.serverTunnels];
|
|
updated[i] = {
|
|
...updated[i],
|
|
maxRetries: Number(e.target.value),
|
|
};
|
|
setField("serverTunnels", updated);
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[10px] font-bold text-muted-foreground">
|
|
{t("hosts.retryIntervalS")}
|
|
</label>
|
|
<Input
|
|
className="h-7 text-xs [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
|
type="number"
|
|
value={tun.retryInterval}
|
|
onChange={(e) => {
|
|
const updated = [...form.serverTunnels];
|
|
updated[i] = {
|
|
...updated[i],
|
|
retryInterval: Number(e.target.value),
|
|
};
|
|
setField("serverTunnels", updated);
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<SettingRow
|
|
label={t("hosts.autoStartLabel")}
|
|
description={t("hosts.autoStartDesc")}
|
|
>
|
|
<FakeSwitch
|
|
checked={tun.autoStart}
|
|
onChange={(v) => {
|
|
const updated = [...form.serverTunnels];
|
|
updated[i] = { ...updated[i], autoStart: v };
|
|
setField("serverTunnels", updated);
|
|
}}
|
|
/>
|
|
</SettingRow>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</SectionCard>
|
|
</>
|
|
)}
|
|
|
|
{activeTab === "docker" && (
|
|
<HostDockerTab form={form} setField={setField} />
|
|
)}
|
|
|
|
{activeTab === "proxmox" && (
|
|
<HostProxmoxTab form={form} setField={setField} />
|
|
)}
|
|
|
|
{activeTab === "files" && (
|
|
<HostFilesTab form={form} setField={setField} />
|
|
)}
|
|
|
|
{activeTab === "host-metrics" && (
|
|
<HostStatsTab form={form} setField={setField} snippets={snippets} />
|
|
)}
|
|
|
|
{activeTab === "rdp" && (
|
|
<>
|
|
<SectionCard
|
|
title={t("hosts.rdpDefaults", {
|
|
defaultValue: "RDP defaults",
|
|
})}
|
|
icon={<Settings className="size-3.5" />}
|
|
>
|
|
<SettingRow
|
|
label={t("hosts.useUserDefaults", {
|
|
defaultValue: "Use user defaults",
|
|
})}
|
|
description={t("hosts.useUserRdpDefaultsDesc", {
|
|
defaultValue:
|
|
"Inherit performance, redirection, and clipboard settings from User Profile.",
|
|
})}
|
|
>
|
|
<FakeSwitch
|
|
checked={form.inheritRemoteDesktopDefaults}
|
|
onChange={(value) =>
|
|
setField("inheritRemoteDesktopDefaults", value)
|
|
}
|
|
/>
|
|
</SettingRow>
|
|
</SectionCard>
|
|
<HostEditorRdpTab
|
|
form={form}
|
|
setField={setField}
|
|
setGuacField={setGuacField}
|
|
host={host}
|
|
credentials={availableCredentials}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{activeTab === "vnc" && (
|
|
<HostEditorVncTab
|
|
form={form}
|
|
setField={setField}
|
|
setGuacField={setGuacField}
|
|
host={host}
|
|
credentials={availableCredentials}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === "telnet" && (
|
|
<HostEditorTelnetTab
|
|
form={form}
|
|
setField={setField}
|
|
setGuacField={setGuacField}
|
|
host={host}
|
|
credentials={availableCredentials}
|
|
/>
|
|
)}
|
|
</div>
|
|
</fieldset>
|
|
|
|
<div className="flex justify-end gap-3 mt-3 mb-6">
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setPreviewTerminalTheme(null);
|
|
onBack();
|
|
}}
|
|
disabled={saving}
|
|
>
|
|
{t("hosts.guac.cancelBtn")}
|
|
</Button>
|
|
{!readOnly && (
|
|
<Button
|
|
variant="outline"
|
|
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand px-8"
|
|
onClick={handleSave}
|
|
disabled={saving}
|
|
>
|
|
{saving
|
|
? t("hosts.guac.savingBtn")
|
|
: host
|
|
? t("hosts.guac.updateHostBtn")
|
|
: t("hosts.guac.addHostBtn")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{showQuickCredentialDialog && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
|
|
<div className="bg-popover border border-border shadow-xl w-full max-w-sm flex flex-col gap-4 p-4">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-bold">
|
|
{t("hosts.createCredentialFromHostTitle")}
|
|
</span>
|
|
<button
|
|
onClick={() => setShowQuickCredentialDialog(false)}
|
|
className="text-muted-foreground hover:text-foreground"
|
|
>
|
|
<X className="size-4" />
|
|
</button>
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{t("hosts.createCredentialFromHostDesc")}
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.friendlyNameLabel")}
|
|
</label>
|
|
<Input
|
|
placeholder="e.g. Production SSH Key"
|
|
value={quickCredentialName}
|
|
onChange={(e) => setQuickCredentialName(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setShowQuickCredentialDialog(false)}
|
|
disabled={creatingQuickCredential}
|
|
>
|
|
{t("hosts.cancelBtn")}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
|
onClick={handleQuickCreateCredential}
|
|
disabled={creatingQuickCredential}
|
|
>
|
|
{creatingQuickCredential
|
|
? t("hosts.savingBtn")
|
|
: t("hosts.addCredentialBtn")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|