Files
Termix/src/ui/sidebar/HostEditor.tsx
T
66e8ce6df7 v26.10.2 (#1672)
* fix: keep concurrent ssh pool connects from overwriting each other (#1619)

* fix: stop auto-accepting changed jump host keys (#1620)

A jump hop whose key no longer matched the pinned fingerprint was
silently accepted and the pin overwritten. Background status probes
reconnect to jump hosts every cycle, so a MITM on that hop got the
credentials with no user interaction. Changed jump host keys now go
through the same path as any other host: prompt when there is a
terminal socket, otherwise refuse.

* fix(electron): validate external login URL scheme and callback port (#1621)

* fix(plugins): read artifact manifest with the same tar parser as the unpack (#1622)

The consent check read the first manifest.json with a hand-written tar
reader, while the unpack keeps the last one and also follows pax and
long-name headers. An archive with two manifests could pass the check
with one capability list and get granted another. Read the manifest
with node-tar, count only plain files, and refuse archives that hold
more than one manifest.json.

* fix: only trust the server's origin in the desktop login frame (#1624)

The desktop sign-in iframe accepted AUTH_SUCCESS and system browser requests from whatever page it had navigated to, and posted replies (including 2FA temp tokens) to any origin. Messages must now come from the server's origin and replies are posted to it.

The web UI also treated any framing page as the desktop app and posted the JWT to it. It now also requires the Termix-Desktop user agent.

* fix: check trusted proxy login against nginx's peer, not nginx (#1625)

The backend only sees the bundled nginx on loopback, so the allowlist
could only work by trusting 127.0.0.1, which let any client that reached
nginx log in as anyone by sending the identity headers. nginx now passes
its own TCP peer (before real_ip rewriting) in X-Termix-Proxy-Peer, and
loopback connections are judged by that address instead.

* fix: keep foreign keys on during user data import (#1626)

Import credentials before hosts and remap the file's credential ids to
the ones created here, so hosts and protocol logins no longer point at
another server's ids. With rows written in dependency order, the import
no longer turns off SQLite foreign keys on the shared connection. The
uploaded file is now removed on every exit path.

* fix: enforce unique usernames (#1628)

* fix: enforce unique usernames

Registration checked for a taken name, hashed the password, then
inserted, with nothing in the schema enforcing uniqueness. Concurrent
sign-ups could create two accounts with one name, after which login,
password reset, proxy login and admin lookups by username picked
between them arbitrarily.

Add a unique index on users.username for every dialect. Existing
duplicates are renamed (later rows get a -<id> suffix, the oldest keeps
the name) rather than deleted. Registration and admin user creation now
return the same 409 when the insert loses the race.

* fix: avoid username collisions during migration

* fix: don't auto-enable synced plugins signed only by a server-sent key (#1623)

* fix: don't auto-enable synced plugins signed only by a server-sent key

Registry keys a linked server sends still let its plugins load on the desktop, but no longer count as a signer for switching them on or copying their grants. Keys the desktop added itself keep their trust.

* fix: enforce local plugin trust across remote source labels

* fix: require positive local trust for synced plugin keys

* fix(desktop): harden external editor and session cookie IPC (#1627)

* fix(desktop): stop the renderer choosing the external editor binary or reading the session cookie

open-external-editor now launches only the editor saved from a file dialog
and refuses to hand executable file types to the OS opener. The unused
get-session-cookie and wait-session-cookie channels, which returned the
httpOnly jwt, are removed.

* fix: validate setting keys before editor authorization

* fix(sidebar): sort hosts by IP address numerically (#1629)

* fix(desktop): find spawn-helper in universal macOS builds (#1630)

The universal build ships app-arm64.asar and app-x64.asar instead of
app.asar, so node-pty's helper path rewrite never pointed at the
unpacked copy and every local terminal failed with posix_spawnp failed.

* fix(sync): send shared hosts with text columns in their stored form (#1631)

resolveHostById turns forceKeyboardInteractive into a boolean, and the
shared-host export passed it on as is. A desktop could not bind the boolean
into the text column, so every shared host failed to apply.

* fix: drop ssh_data.vault_profile_id so removing the vault plugin keeps hosts writable (#1633)

The vault plugin renames vault_profiles to p_vault_profiles, and SQLite
rewrites ssh_data's foreign key to follow it. Uninstalling the plugin then
dropped p_vault_profiles and left ssh_data pointing at a missing table, so
creating, cloning and deleting hosts failed with "no such table:
main.p_vault_profiles". Postgres and MySQL refused the uninstall instead.

The column was already copied into the vault plugin's host settings by 2.9,
so retire it on every engine. The SQLite boot drop also repairs databases
already in the broken state; MySQL drops the column's foreign key first.

* fix(sync): let a linked desktop enable mirrored plugins it does not trust on its own (#1632)

* fix(sync): let a linked desktop enable mirrored plugins it does not trust on its own

* fix(sync): switch off an untrusted mirrored plugin when the server updates it

* fix: require local consent for mirrored plugin activation

* docs: describe consent fields on the plugin state endpoint

* fix(i18n): keep Indonesian technical terms consistent (#1636)

Use the English terms Indonesian users know for tunnel, snippet, deploy,
file manager, remote desktop, jump host, passphrase, sidebar, toolbar,
endpoint, browser and SSH agent, instead of mixing literal translations
(terowongan, cuplikan, terapkan, bilah sisi, titik akhir, ...) with the
English words. Match the split shortcuts to the split screen menu.

Also fix the user data export description, which said secrets are
encrypted while the English says they are decrypted.

* fix(settings): leave user manage view when switching admin sections (#1649)

The admin panel stays mounted across Settings pages, so an open user
manage view kept replacing every other admin section until Back to users
was clicked. Reset it whenever the selected section changes.

* fix(sync): don't delete a local row when the server can't send its copy (#1650)

* fix(sync): don't delete a local row when the server can't send its copy

A push conflict on a live record whose server row failed to serialize
came back as row: null, deleted: false, and the desktop treated that as
a delete. The server now rejects that op as unavailable, the desktop
never deletes on a conflict without a row, and serialization failures
are logged.

* fix(sync): retry temporarily unavailable records

* fix: smooth host editor jumps to lower sections

* chore: add changelog entries for 26.10.2 fixes

* fix: stop building ssh2 native crypto in the docker image (#1644)

* chore: test that an update keeps a changed auth type (#1601)

* feat: option to hide the sidebar after opening a host (#1652)

* feat: sort the dashboard host status card (Plugin-Host-Metrics#6)

* fix: let the hover side menu overlay content instead of resizing it (Plugin-Remote-Desktop#2)

* fix: say the server is down and keep retrying instead of a database error (#1637)

* fix: show why a client tunnel failed to start (Plugin-Tunnels#2)

* fix(sdk): skip comment-only chunks when splitting migrations

* feat: setting to let the hover side menu push content again

* fix: use the vite proxy for plugin sockets on any dev port

* feat(sdk): let a plugin raise its public route rate limit

* chore: update translations

* chore: add the overview video to the readme

* chore: update the overview video and add a play button to its thumbnail

* feat: plugin builds bundle worker entries and import wasm and fonts as bytes

* feat: plugin packs include LICENSE and NOTICE

* chore: drop guacd from compose files and dev runner

* chore: update remote desktop origin comment for the built-in engine

* fix: show plugin names and descriptions in the UI language (#1658)

* fix: show plugin names and descriptions in the UI language

Read plugin.name and plugin.description from each plugin's own locale
namespace, falling back to the manifest, everywhere core shows a plugin's
name: the Plugins manager, onboarding picker, host editor tabs and
sections, host filters and bulk actions, beta updates, interface presets
and plugin crash/loading notices.

* docs(sdk): document plugin.name and plugin.description locale keys

---------

Co-authored-by: ZacharyZcR <zkl205305@gmail.com>

* fix(hosts): reject or infer a missing authType on host create (#1664)

Creating an SSH host through the API without authType hit the NOT NULL
auth_type column and came back as a 500. The create route now infers the
type from the credential, key or password in the body, falls back to the
host defaults, and otherwise answers 400. A non-string authType is a 400
on create and update.

* chore: changelog for merged plugin name and host auth type PRs

* fix: move macOS split pane navigation to Cmd+Option+Arrow

* fix: pick a free port for the desktop backend when 30001 is taken (#1663)

* feat: build the Windows desktop app for ARM64 (#1615)

* fix(sync): keep the changes cursor within the 32-bit seq column (#1665)

The desktop learned the server's entity types by asking for changes after
Number.MAX_SAFE_INTEGER. sync_records.seq is a 32-bit integer on Postgres,
which rejects that bound value, so every pass needing the probe failed with
22003 out of range.

The server now clamps the cursor to the largest seq the column holds and
answers 400 for a cursor or limit that is not a non-negative integer. The
desktop asks for a one-row page from its saved cursor instead.

* fix(audit): coalesce identical plugin routine audits so they cannot flush the log (#1667)

Successful plugin_as_user and plugin_service_* entries written by a plugin
with the same user, action, resource and details are recorded once per
10 minutes. Failures and every other action are still written each time.

* feat(plugins): publish host.created when a host is added (#1668)

Plugins that keep an inventory of hosts had no way to hear about a new
one short of polling. Core now publishes host.created with the same
{ hostId, userId } payload as host.updated wherever a host row is
created: the host create route, ctx.hosts.create, JSON and SSH config
bulk import, database file import, and sync writing a host it did not
have yet.

* fix: show the login for signed-out full-screen view links and return to them (#1669)

A ?view= link opened without a session spun forever: the gate rendered
the bare view when the session check failed, so the plugin runtime never
started and the view never registered. A regular browser now keeps the
link in sessionStorage, shows the normal login and opens the link again
after any login, including redirect logins. Only same-origin paths with
a view are restored. The desktop app, embedded sign-in frames and guest
views keep their current behavior.

* fix(desktop): apply linked server proxy headers and certificate exception to WebSockets (#1670)

* fix(desktop): send linked server proxy headers on WebSocket upgrades

isLinkedOrigin compared the request origin with the link's http(s)
origin, so the terminal WebSocket (wss://host/...) never matched and its
upgrade went out without the proxy headers and basic auth. Behind a
forward-auth proxy that upgrade was redirected to the login and the
terminal closed with "connection closed unexpectedly". ws and wss now
count as http and https for the comparison, the same mapping the
WebSocket handshake itself uses, with host and port still matched
exactly.

* fix(desktop): allow a linked server's self-signed certificate for its WebSockets

isInvalidCertificateAllowedForUrl compared the URL with a local
getOrigin copy, so wss://host never matched the link's https://host
origin and an allowed self-signed certificate did not cover the
terminal and tunnel sockets. It now uses linkedServer.isLinkedOrigin,
which maps ws and wss the same way as the proxy headers, and the local
copy is removed.

* feat(sdk): let plugins mint a short-lived user session and read a user's locale (#1666)

ctx.auth.issueUserToken(userId, { purpose, ttlSeconds }) gives background
work a session for calling Termix's own API as an existing user. It needs
users:impersonate, defaults to 300s and is capped at 900s, is stored as a
normal session row marked as plugin-issued, and every issue is audited.
Such sessions are refused on routes that change the user's password,
second factors, API keys or linked logins.

ctx.users.getLocale(userId) reads the user's UI language under users:read.

SDK 1.0.11, plugin API 1.3.0.

* chore: changelog for merged sync, audit, event and desktop PRs

* feat: add shell.rightEdge slot so plugin panels can push the page over

* chore: pin bundled plugins to the latest releases

* chore: lint, format, and bump version to 26.10.2

---------

Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: Ferdina Kusumah <19812460+FerdinaKusumah@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zkl205305@gmail.com>
Co-authored-by: Lukas <80832688+0skater0@users.noreply.github.com>
2026-10-18 00:34:50 -05:00

1560 lines
62 KiB
TypeScript

import { Select2 } from "@/components/select2";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { PasswordInput } from "@/components/password-input";
import {
Globe,
Pencil,
Plus,
Puzzle,
Settings,
Shield,
Terminal,
Upload,
X,
} from "lucide-react";
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
import {
EditorPane,
EditorSection,
LazySection,
type EditorNavBand,
} from "@/manage/EditorPane";
import { validateHostForm } from "@/manage/host-validation";
import { toast } from "sonner";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import {
createSSHHost,
updateSSHHost,
getUserInfo,
getHostPassword,
adminCreateUserHost,
adminUpdateUserHost,
adminGetHostPassword,
createCredential,
adminCreateUserCredential,
} from "@/main-axios";
import {
getHostDefaultsLevel,
previewHostDefaultsLevel,
resolveHostDefaults,
saveHostDefaultsLevel,
waitForDefaultsJob,
type HostDefaultsChange,
type HostDefaultsTarget,
} from "@/api/host-defaults-api";
import type { ResolvedHostDefault } from "@/types/host-defaults";
import {
HostDefaultBadge,
HostDefaultsContext,
HostOnly,
type HostDefaultsContextValue,
} from "@/lib/host-defaults-context";
import { useEditorDefaultKeys } from "./host-defaults/catalog";
import {
applyDefaultToForm,
changedKeys,
classifyForm,
draftOwnKeys,
formValueForKey,
isOwnDefault,
ownDefaultKeys,
ownPluginValues,
withAllNamespaces,
withOwnKeys,
withoutOwnKey,
} from "./host-defaults/form-mapping";
import type { Host } from "@/types/ui-types";
import type { SSHHost } from "@/types";
import { updatePluginHostSettings } from "@/api/plugins-api";
import {
applyHostDraft,
buildHostEditorPayload,
createHostEditorForm,
omitOwnerSshAuthFromSharedEdit,
type HostAuthType,
type HostEditorForm,
type HostProtocols,
} from "./HostEditorData";
import { HostEditorGeneralTab } from "./HostEditorGeneralTab";
import { withProtocolSettings } from "./host-protocols";
import { canEditHost } from "./host-permissions";
import {
getHostEditorSection,
makeHostSshSubTabs,
makeHostTabs,
useHostEditorSections,
} from "./HostManagerTabs";
import { useSshAuthEditors } from "@/plugin-host/auth-registry";
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
import { SshAuthProviderFields } from "./SshAuthProviderFields";
import type { PluginSettingsField } from "@termix-ssh/plugin-sdk/manifest";
import type { HostDraft } from "@termix-ssh/plugin-sdk/frontend";
import { PluginComponent } from "@/plugin-host/component-registry";
import {
toCredentialOption,
type CredentialOption,
} from "./quick-created-credential";
export function HostEditor({
host,
draft,
advanced = true,
banner,
onBack,
onSave,
protocols,
onProtocolChange,
onDirtyChange,
hosts,
credentials,
adminTargetUserId,
onEditCredential,
defaultsTarget,
onDefaultsSaved,
}: {
host: Host | null;
/** Fields a plugin filled in for a new host. */
draft?: HostDraft;
/**
* False keeps to what is needed to reach a host: General and SSH, with the
* organizing fields folded away. Hidden fields still save unchanged.
*/
advanced?: boolean;
/** Shown above the form, like the defaults level's description. */
banner?: React.ReactNode;
onBack: () => void;
onSave: (saved: SSHHost) => void;
protocols: HostProtocols;
onProtocolChange: (p: Partial<typeof protocols>) => void;
onDirtyChange?: (dirty: boolean) => 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;
/** Edits a level of host defaults instead of a host. */
defaultsTarget?: HostDefaultsTarget;
onDefaultsSaved?: () => void;
}) {
const { t } = useTranslation();
const confirm = useConfirm();
// Re-render when a plugin adds or removes a section.
useHostEditorSections();
const simpleMode = !advanced;
const defaultsMode = !!defaultsTarget;
const revealRef = useRef<((id: string) => void) | null>(null);
const [pendingReveal, setPendingReveal] = useState<string | null>(null);
const defaultKeys = useEditorDefaultKeys();
const trackedKeys = useRef<string[]>([]);
trackedKeys.current = [...defaultKeys.keys()];
const [form, setForm] = useState(() => {
const initial = applyHostDraft(
createHostEditorForm(host),
host ? undefined : draft,
);
if (defaultsTarget) return { ...initial, username: "" };
// What a plugin filled in for a new host is that host's own.
return host
? initial
: { ...initial, defaultOverrides: draftOwnKeys(draft) };
});
/**
* What each key would be without the host's own value: its defaults, or in
* the defaults editor, the levels above the one being edited.
*/
const [resolved, setResolved] = useState<Record<string, ResolvedHostDefault>>(
{},
);
/** The keys the level held when it was loaded, in the defaults editor. */
const [levelKeys, setLevelKeys] = useState<string[]>([]);
/** A value the user changed is the host's own from then on. */
const markOwn = (before: HostEditorForm, after: HostEditorForm) => {
if (after.defaultOverrides === null) return after;
const changed = changedKeys(before, after, trackedKeys.current);
if (changed.length === 0) return after;
return {
...after,
defaultOverrides: withOwnKeys(after.defaultOverrides, changed),
};
};
const setField = <K extends keyof typeof form>(k: K, v: (typeof form)[K]) => {
onDirtyChange?.(true);
setForm((p) => markOwn(p, { ...p, [k]: v }));
};
/** Sets several fields at once, from the latest form. For plugin sections. */
const updateForm = (
patch: (current: Record<string, unknown>) => Record<string, unknown>,
) => {
onDirtyChange?.(true);
setForm((current) =>
markOwn(current, {
...current,
...patch(current as unknown as Record<string, unknown>),
} as HostEditorForm),
);
};
const [saving, setSaving] = useState(false);
const [isExternalUser, setIsExternalUser] = 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);
useEffect(() => {
getUserInfo()
.then((info) => setIsExternalUser(info.is_external))
.catch(() => {});
}, []);
useEffect(() => {
let cancelled = false;
if (defaultsTarget) {
getHostDefaultsLevel(defaultsTarget)
.then((data) => {
if (cancelled) return;
setResolved(data.inherited);
setLevelKeys(Object.keys(data.values));
let next: HostEditorForm = {
...createHostEditorForm(null),
username: "",
};
for (const [key, entry] of Object.entries(data.inherited)) {
next = applyDefaultToForm(next, key, entry.value);
}
for (const [key, value] of Object.entries(data.values)) {
next = applyDefaultToForm(next, key, value);
}
setForm({
...next,
defaultOverrides: withOwnKeys({}, Object.keys(data.values)),
});
})
.catch(() => toast.error(t("hostDefaults.loadFailed")));
return () => {
cancelled = true;
};
}
if (host) {
setForm(createHostEditorForm(host));
resolveHostDefaults({ hostId: Number(host.id) }, adminTargetUserId)
.then((values) => {
if (cancelled) return;
setResolved(values);
// A host saved before host defaults: whatever matches follows them.
setForm((current) =>
current.defaultOverrides !== null
? current
: {
...current,
defaultOverrides: classifyForm(
current,
values,
trackedKeys.current,
),
},
);
})
.catch(() => {});
}
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [host, defaultsTarget?.level, defaultsTarget?.folderId]);
// A new host follows the defaults of wherever it is being placed.
const newHostPlacement =
!host && !defaultsTarget
? `${form.folder}\u0000${form.parentHostId}`
: null;
useEffect(() => {
if (newHostPlacement === null) return;
let cancelled = false;
resolveHostDefaults(
{
folder: form.folder || null,
parentHostId: form.parentHostId ? Number(form.parentHostId) : null,
},
adminTargetUserId,
)
.then((values) => {
if (cancelled) return;
setResolved(values);
setForm((current) => {
let next = current;
for (const [key, entry] of Object.entries(values)) {
if (isOwnDefault(current.defaultOverrides, key)) continue;
next = applyDefaultToForm(next, key, entry.value);
}
return next;
});
})
.catch(() => {});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [newHostPlacement]);
const isAvailable = (fullKey: string) => {
const entry = defaultKeys.get(fullKey);
if (!entry) return false;
return !defaultsTarget || entry.levels.includes(defaultsTarget.level);
};
const defaultsContext: HostDefaultsContextValue = {
mode: defaultsMode ? "defaults" : "host",
level: defaultsTarget?.level,
isOwn: (fullKey) =>
form.defaultOverrides === null ||
isOwnDefault(form.defaultOverrides, fullKey),
source: (fullKey) => resolved[fullKey]?.source,
isAvailable,
reset: (fullKey) => {
onDirtyChange?.(true);
setForm((current) => {
const entry = resolved[fullKey];
const next = entry
? applyDefaultToForm(current, fullKey, entry.value)
: current;
return {
...next,
defaultOverrides: withoutOwnKey(
current.defaultOverrides ??
classifyForm(current, resolved, trackedKeys.current),
fullKey,
),
};
});
},
};
/**
* Writes each plugin's host-scope values through its own route.
*
* One request per plugin rather than one per field, and a plugin that
* rejects a value is reported without failing the host save that already
* succeeded.
*/
const savePluginHostSettings = async (
hostId: number,
values: Record<string, Record<string, unknown>> | undefined,
): Promise<Record<string, Record<string, unknown>>> => {
const saved: Record<string, Record<string, unknown>> = {};
if (!values || !Number.isInteger(hostId)) return saved;
for (const [pluginId, fields] of Object.entries(values)) {
if (!fields || Object.keys(fields).length === 0) continue;
try {
saved[pluginId] = await updatePluginHostSettings(
pluginId,
hostId,
fields,
);
} catch {
toast.error(t("settings.pluginSettingsSaveFailed"));
}
}
return saved;
};
const saveDefaults = async () => {
if (!defaultsTarget) return;
const own = ownDefaultKeys(form.defaultOverrides).filter(isAvailable);
const change: HostDefaultsChange = {
set: Object.fromEntries(
own.map((key) => [key, formValueForKey(form, key)]),
),
unset: levelKeys.filter((key) => !own.includes(key)),
};
setSaving(true);
try {
const preview = await previewHostDefaultsLevel(defaultsTarget, change);
setSaving(false);
const ok = await confirm({
title: t("hostDefaults.confirmTitle"),
description:
preview.changedHosts > 0
? t("hostDefaults.confirmChanges", { count: preview.changedHosts })
: t("hostDefaults.confirmNoChanges"),
confirmLabel: t("hostDefaults.save"),
destructive: false,
});
if (ok) await confirmDefaults(change);
} catch {
toast.error(t("hostDefaults.saveFailed"));
} finally {
setSaving(false);
}
};
const confirmDefaults = async (change: HostDefaultsChange) => {
if (!defaultsTarget) return;
setSaving(true);
try {
const result = await saveHostDefaultsLevel(defaultsTarget, change);
setLevelKeys(Object.keys(change.set));
onDirtyChange?.(false);
if (result.jobId) {
toast.success(t("hostDefaults.savedInBackground"));
void waitForDefaultsJob(result.jobId).then((changed) => {
if (changed !== null) {
toast.success(t("hostDefaults.appliedToHosts", { count: changed }));
}
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
});
} else {
toast.success(
t("hostDefaults.saved", { count: result.changedHosts ?? 0 }),
);
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
}
onDefaultsSaved?.();
} catch {
toast.error(t("hostDefaults.saveFailed"));
} finally {
setSaving(false);
}
};
const handleSave = async () => {
if (defaultsMode) {
await saveDefaults();
return;
}
setSaving(true);
try {
const withOverrides = {
...form,
defaultOverrides: form.defaultOverrides
? withAllNamespaces(form.defaultOverrides, defaultKeys)
: null,
};
const fullData = buildHostEditorPayload(withOverrides, 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);
}
// After the host: a new one has no id to scope settings to until it
// exists. A failure here must not claim the host itself failed to save.
// Values that follow the defaults were written by the server already.
const pluginValues = withProtocolSettings(form.pluginSettings, protocols);
const savedPluginValues = await savePluginHostSettings(
Number(saved.id),
ownPluginValues(
pluginValues,
withOverrides.defaultOverrides,
defaultKeys,
),
);
// The host was read back before these were written.
saved = {
...saved,
pluginSettings: {
...(saved.pluginSettings ?? {}),
...savedPluginValues,
},
};
toast.success(host ? t("hosts.hostUpdated") : t("hosts.hostCreated"));
onSave(saved);
} catch {
toast.error(t("hosts.failedToSave"));
} finally {
setSaving(false);
}
};
const authMethod = form.authType;
// Auth types a plugin adds (Tailscale) bring their own editor and label.
const sshAuthEditors = useSshAuthEditors();
const activeAuthEditor = sshAuthEditors.find(
(editor) => editor.id === authMethod,
);
const ActiveAuthEditor = activeAuthEditor?.component;
const sshAuthProviders = useSshAuthProviders();
const authEditorLabel = (method: string) => {
const option = sshAuthProviders.find(method);
if (option?.editorTitleKey) return t(option.editorTitleKey);
if (option?.labelKey) return t(option.labelKey, { defaultValue: method });
return method;
};
// A default never carries a login of its own, and a credential belongs to
// one user, so the server level cannot hold one.
const selectableAuthMethods = sshAuthProviders.providers
.filter((option) => option.available)
.map((option) => option.type)
.filter(
(type) =>
!defaultsMode ||
(type !== "password" &&
type !== "key" &&
(type !== "credential" || defaultsTarget?.level !== "admin")),
);
const currentAuthOption = sshAuthProviders.find(authMethod);
// The host uses a type nothing here provides (its plugin is off).
const missingAuthNotice =
sshAuthProviders.loaded && authMethod && !currentAuthOption?.available
? currentAuthOption?.missingPlugin
? t("hosts.authTypeNeedsPlugin", {
type: authMethod,
plugin: currentAuthOption.missingPlugin.name,
})
: t("hosts.authTypeUnknown", { type: authMethod })
: null;
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 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 });
if (!value) return;
if (proto === "enableSsh") {
setPendingReveal("ssh");
return;
}
const before = protocols as unknown as Record<string, boolean>;
const after = { ...before, [proto]: value };
const visibleBefore = new Set(
makeHostTabs((k) => k, before).map((tab) => tab.id),
);
const revealed = makeHostTabs((k) => k, after)
.map((tab) => tab.id)
.find((id) => id !== "ssh" && !visibleBefore.has(id));
if (revealed) setPendingReveal(revealed);
};
useEffect(() => {
if (!pendingReveal) return;
const id = pendingReveal;
setPendingReveal(null);
requestAnimationFrame(() => revealRef.current?.(id));
}, [pendingReveal]);
const flags = protocols as unknown as Record<string, boolean>;
// The editor is one scrolling form. Sections follow the old tab order:
// General, then SSH and its plugin sections, then other plugins.
const sectionBands = useMemo(() => {
const top = makeHostTabs(t, flags, defaultsMode).filter(
(tab) => tab.id !== "general" && tab.id !== "ssh",
);
const sshGroup = makeHostSshSubTabs(t, flags, defaultsMode).filter(
(tab) => tab.id !== "ssh",
);
const bands: EditorNavBand[] = [
{
id: "host",
label: t("manage.bandHost"),
items: [
{
id: "general",
label: t("hosts.tabGeneral"),
icon: <Settings className="size-3.5" />,
},
],
},
];
if (protocols.enableSsh || defaultsMode) {
bands.push({
id: "ssh",
label: t("hosts.tabSsh"),
items: [
{
id: "ssh",
label: t("manage.sshConnection"),
icon: <Terminal className="size-3.5" />,
},
...(simpleMode ? [] : sshGroup),
],
});
}
if (!simpleMode && top.length > 0) {
bands.push({
id: "features",
label: t("manage.bandFeatures"),
items: top.map((tab) => ({
...tab,
icon: tab.icon ?? <Puzzle className="size-3.5" />,
})),
});
}
return bands;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [t, JSON.stringify(flags), defaultsMode, simpleMode]);
const errors = defaultsMode ? {} : validateHostForm(form, protocols);
const errorCount = Object.keys(errors).length;
const errorSections = new Set<string>(
Object.keys(errors).map((key) => (key === "sshPort" ? "ssh" : "general")),
);
const bands = sectionBands.map((band) => ({
...band,
items: band.items.map((item) => ({
...item,
error: errorSections.has(item.id),
})),
}));
const pluginSectionIds = bands
.flatMap((band) => band.items.map((item) => item.id))
.filter((id) => id !== "general" && id !== "ssh");
const renderPluginSection = (id: string) => {
const section = getHostEditorSection(id);
if (!section) return null;
const Section = section.component;
return (
<EditorSection
key={id}
id={id}
label={t(section.labelKey, { defaultValue: section.label })}
>
<LazySection>
<Section
form={form}
setField={
setField as unknown as (key: string, value: unknown) => void
}
updateForm={updateForm}
host={host}
credentials={availableCredentials}
adminTargetUserId={adminTargetUserId}
protocols={flags}
mode={defaultsMode ? "defaults" : "host"}
/>
</LazySection>
</EditorSection>
);
};
return (
<HostDefaultsContext.Provider value={defaultsContext}>
<EditorPane
bands={bands}
revealRef={revealRef}
errorCount={errorCount}
banner={
<>
{banner}
{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>
)}
</>
}
footer={
<>
<Button
variant="ghost"
size="sm"
onClick={onBack}
disabled={saving}
>
{t("hosts.editorCancel")}
</Button>
{!readOnly && (
<Button
variant="outline"
size="sm"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand px-6"
onClick={handleSave}
disabled={saving || errorCount > 0}
>
{saving
? t("hosts.editorSaving")
: defaultsMode
? t("hostDefaults.save")
: host
? t("hosts.editorUpdate")
: t("hosts.editorAdd")}
</Button>
)}
</>
}
>
<fieldset
disabled={readOnly}
className={`flex flex-col gap-2 min-w-0 ${readOnly ? "opacity-80" : ""}`}
>
<EditorSection id="general" label={t("hosts.tabGeneral")}>
<HostEditorGeneralTab
form={form}
setField={setField}
protocols={protocols}
handleProtocolToggle={handleProtocolToggle}
hosts={hosts}
host={host}
simpleMode={simpleMode}
errors={errors}
/>
</EditorSection>
{(protocols.enableSsh || defaultsMode) && (
<EditorSection id="ssh" label={t("manage.sshConnection")}>
<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="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.sshPort")}
<HostDefaultBadge settingKey="core.sshPort" />
</label>
<Input
type="number"
placeholder="22"
value={form.sshPort}
aria-invalid={!!errors.sshPort}
onChange={(e) =>
setField("sshPort", Number(e.target.value))
}
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
{errors.sshPort && (
<span className="text-[10px] text-destructive">
{t(errors.sshPort)}
</span>
)}
</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="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.keepaliveIntervalLabel")}
<HostDefaultBadge settingKey="core.keepaliveInterval" />
</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="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.maxKeepaliveMisses")}
<HostDefaultBadge settingKey="core.keepaliveCountMax" />
</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>
<SettingRow
label={t("hosts.sshAgentForwardingLabel")}
description={t("hosts.sshAgentForwardingShortDesc")}
defaultKey="core.agentForwarding"
>
<FakeSwitch
checked={form.agentForwarding}
onChange={(v) => setField("agentForwarding", v)}
/>
</SettingRow>
<div className="flex flex-col gap-3 border-t border-border pt-4">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.environmentVariablesLabel")}
<HostDefaultBadge settingKey="core.environmentVariables" />
</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
type="button"
className="text-destructive"
onClick={() =>
setField(
"environmentVariables",
form.environmentVariables.filter(
(_, idx) => idx !== i,
),
)
}
>
<X className="size-4" />
</button>
</div>
))}
</div>
</div>
</div>
</SectionCard>
<SectionCard
title={t("hosts.authenticationLabel")}
icon={<Shield className="size-3.5" />}
action={
!isSharedHost &&
!defaultsMode &&
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="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.authenticationMethod")}
<HostDefaultBadge settingKey="core.auth" />
</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")}
>
{selectableAuthMethods.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"}`}
>
{authEditorLabel(m)}
</button>
))}
</div>
{lockAuthReferences && (
<p className="text-[10px] text-muted-foreground/60">
{t("hosts.sharing.ownerOnlyControl")}
</p>
)}
{missingAuthNotice && (
<p className="text-[10px] text-destructive">
{missingAuthNotice}
</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="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.username")}
<HostDefaultBadge settingKey="core.username" />
</label>
<Input
placeholder="root"
value={form.username}
disabled={
authMethod === "credential" &&
!!selectedCredential?.username &&
!form.overrideCredentialUsername
}
onFocus={() => {
if (!defaultsMode && form.username === "root")
setField("username", "");
}}
onBlur={() => {
if (!defaultsMode && form.username === "")
setField("username", "root");
}}
onChange={(e) => setField("username", e.target.value)}
/>
{isExternalUser && (
<p className="text-[10px] text-muted-foreground/60">
{t("hosts.externalUsernameHint")}
</p>
)}
{activeAuthEditor?.hintKey && (
<p className="text-[10px] text-muted-foreground/60">
{t(activeAuthEditor.hintKey)}
</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
}
onChange={(e) => setField("password", e.target.value)}
/>
<PluginComponent
id="credentials.secretHint"
onManage={() => setShowSecretSources((v) => !v)}
/>
</div>
)}
{(authMethod === "password" || authMethod === "key") &&
showSecretSources && (
<PluginComponent
id="credentials.secretManager"
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
}
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
}
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>
<Select2
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>
</Select2>
</div>
</>
)}
{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">
<Select2
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>
))}
</Select2>
{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>
)}
<HostOnly>
<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
}
onChange={(e) =>
setField("password", e.target.value)
}
/>
</div>
</HostOnly>
</>
)}
</div>
{!ActiveAuthEditor &&
currentAuthOption?.available &&
currentAuthOption.pluginId !== "core" &&
currentAuthOption.fields.length > 0 && (
<SshAuthProviderFields
pluginId={currentAuthOption.pluginId}
hostId={host?.id ? Number(host.id) : undefined}
fields={
currentAuthOption.fields as unknown as PluginSettingsField[]
}
/>
)}
{ActiveAuthEditor && (
<ActiveAuthEditor
form={form}
setField={
setField as unknown as (
key: string,
value: unknown,
) => void
}
/>
)}
{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>
)}
<HostOnly>
<SettingRow
label={t("hosts.shareSshAuthLabel")}
description={t("hosts.shareSshAuthDesc")}
>
<FakeSwitch
checked={form.shareSshAuth}
onChange={(v) => setField("shareSshAuth", v)}
/>
</SettingRow>
</HostOnly>
<SettingRow
label={t("hosts.forceKeyboardInteractiveLabel")}
description={t("hosts.forceKeyboardInteractiveShortDesc")}
defaultKey="core.forceKeyboardInteractive"
>
<FakeSwitch
checked={form.forceKeyboardInteractive}
onChange={(v) => setField("forceKeyboardInteractive", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.allowLegacyAlgorithmsLabel")}
defaultKey="core.allowLegacyAlgorithms"
badge={
form.allowLegacyAlgorithms
? t("hosts.insecure")
: undefined
}
description={t("hosts.allowLegacyAlgorithmsDesc")}
>
<FakeSwitch
checked={form.allowLegacyAlgorithms}
onChange={(v) => setField("allowLegacyAlgorithms", v)}
/>
</SettingRow>
<HostOnly>
<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
}
onChange={(e) =>
setField("sudoPassword", e.target.value)
}
/>
<p className="text-[10px] text-muted-foreground">
{t("hosts.sudoPasswordDesc")}
</p>
</div>
</HostOnly>
</div>
</SectionCard>
</EditorSection>
)}
{pluginSectionIds.map(renderPluginSection)}
</fieldset>
</EditorPane>
<InlineView
open={showQuickCredentialDialog}
onOpenChange={setShowQuickCredentialDialog}
title={t("hosts.createCredentialFromHostTitle")}
footer={
<div className="ml-auto flex 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>
}
>
<p className="text-xs text-muted-foreground">
{t("hosts.createCredentialFromHostDesc")}
</p>
<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
autoFocus
placeholder={t("placeholders.credentialName")}
value={quickCredentialName}
onChange={(e) => setQuickCredentialName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleQuickCreateCredential();
}}
/>
</div>
</InlineView>
</HostDefaultsContext.Provider>
);
}