feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning

This commit is contained in:
LukeGus committed 2026-09-25 13:06:33 -05:00
1 parent c21bca8cf0
commit fbe736bc20
70 files changed
+1117 -1139

No files matched your search

+34 -11
View File
@@ -254,8 +254,8 @@ handler)` for a socket. Core runs the same middleware in front of every plugin:
1. **Auth**, unless the path is listed in `options.public`. A public path is
audited when the router is registered and logged on every request, because
"this plugin opened a hole in auth" should be findable later. **D2** lists
a running plugin's public HTTP paths and public socket paths in the admin
plugin dialog (`publicRoutes` on `GET /plugins`), and audits a public
a running plugin's public HTTP paths and public socket paths as
`publicRoutes` on `GET /plugins` (admins only), and audits a public
socket when it is registered. It matches the
full path, with `:param` segments allowed, so declaring `/webhook/:token`
public cannot open `/webhook/:token/anything`. A trailing `/*` is the one
@@ -360,8 +360,22 @@ the plugin's own permissions exactly as `ctx.rbac` does.
`hidden: true` (**B14**) keeps a field out of the generic form: it is stored,
validated and encrypted like any other, but the plugin edits it in its own
UI. Remote desktop's host options are hidden fields, edited in its RDP, VNC
and Telnet host editor tabs, so they do not appear a second time in the
Plugins tab.
and Telnet host editor tabs.
**Where fields render.** In 2.9.0 the plugin system is invisible to users:
there is no plugin list, enable switch or capability dialog, and nothing in
the UI says "plugin". That UI arrives with the store in 3.0.0. Settings show
up as features instead:
- `user` fields render as an accordion section named after the plugin in the
User Profile panel, and `admin` fields do the same in the Admin Settings
panel (`src/ui/settings/FeatureSettingsSections.tsx`). `group` becomes a
card inside that section.
- `host` fields get a host editor tab of their own, named after the plugin
(`src/ui/settings/host-feature-tabs.tsx`). `editorGroup` (`"top"` or
`"ssh"`, default `"top"`) and `editorOrder` place it among the other tabs.
A plugin that registers its own section with `registerHostEditorSection`
gets no generated tab, so it must draw any non-hidden host field itself.
`type: "custom"` names a component the frontend registered, for the few things
a schema cannot express (a device browser, a provider list). It is deliberately
@@ -419,8 +433,8 @@ allowlist (`CORE_SETTINGS_ALLOWLIST`) rather than the whole settings table.
**D0** added three pieces:
- `onValidate(scope, validator)` checks a save from the settings screen or the
host editor before anything is written. The validator gets every value in
- `onValidate(scope, validator)` checks a save from the profile or admin
panel or the host editor before anything is written. The validator gets every value in
the save (and the host id for host scope) and returns field key to message
for what is wrong. ssh-terminal checks that its image paths are absolute,
step-ca that its CA URL is https and its fingerprint a SHA-256 digest.
@@ -645,6 +659,13 @@ answers undefined while its plugin is off.
shared session") and replaces `ShellApi.openTerminalTab`, which the shell no
longer has.
`ShellApi.openHostEditor(draft?)` opens the host editor for a new host with
a `HostDraft` (name, ip, port, username, authType) filled in, which the user
reviews and saves (tailscale's "Add host" on a device). `saveQuickConnect`
saves only what core's own quick connect payload carries, so the terminal
offers it only on a host with `quickConnectSavable`, which core sets for the
password, key, credential, none and agent auth types.
**Components by id.** `app.registerComponent(id, component)` offers a
component to other plugins and core, which render it with `PluginComponent`
from `@termix/plugin-sdk/ui` (a fallback while the owner is off) or
@@ -766,7 +787,7 @@ mode are still preset-seeded localStorage keys core writes, as in 2.8.
origin, as `app.apiFor` but usable in a component. The terminal toolbar's
metrics bars use it for a host on the desktop app's remote server, and
tunnels merges the remote server's statuses through `app.apiFor("remote")`.
- `app.onSettingsChanged(listener)`: fires after the settings screen or host
- `app.onSettingsChanged(listener)`: fires after the profile panel, admin panel or host
editor saved one of the plugin's settings, instead of polling on focus.
- A `tab.menu` slot: entries in a tab's right-click menu, invoked with the
tab's surface handle; `when` sees `{ tab, handle }`. Session sharing adds its
@@ -1258,8 +1279,8 @@ base types and branches on no plugin type name.
in it. Its public routes are `/plugin-api/sso/start` (sends the browser to
the provider), `/callback` (GET and POST), `/backchannel-logout` and
`/config`; provider management is `/plugin-api/sso/providers`, gated on
`sso.manage`, and renders as the custom `providers` field on the plugin's
admin settings page. Login state (nonce, PKCE verifier, return address)
`sso.manage`, and renders as the custom `providers` field in the plugin's
Admin Settings section. Login state (nonce, PKCE verifier, return address)
lives in `ctx.kv` for ten minutes.
- The redirect URI is `<base>/plugin-api/sso/callback`. Providers that
existed before 2.9 keep sending `<base>/users/oidc/callback`, the URI their
@@ -1937,6 +1958,8 @@ not do.
},
],
"host": {
"editorGroup": "ssh", // host editor strip for the generated tab, default "top"
"editorOrder": 30, // position among that strip's tabs
"enableKey": "enableExample", // boolean rendered first, gating the rest
"enableLabelKey": "k",
"fields": [
@@ -2696,8 +2719,8 @@ None of the checks in this document stop that. They are not a sandbox.
`system:tls`, `ssh:connect`, `process:spawn`, `users:write`,
`auth:provide`, `device:serial`) means the plugin can reach your hosts,
your users or the server itself. Ask why it needs each one.
- **Read the public routes.** Admin > Plugins > Permissions lists every path
it serves without login. Each one is attack surface on your server.
- **Read the public routes.** `publicRoutes` on `GET /plugins` lists every
path it serves without login. Each one is attack surface on your server.
- **Check its dependencies.** A plugin's npm dependencies run with the same
reach as the plugin.
- **Watch the audit log after installing.** Filter on `plugin:<id>` and look
@@ -54,6 +54,13 @@ export async function build({ cwd }) {
},
});
// Without this Node finds the host's typeless package.json, tries backend.js
// as CommonJS first and warns before reparsing it as ESM.
fs.writeFileSync(
path.join(outDir, "package.json"),
`${JSON.stringify({ type: "module" }, null, 2)}\n`,
);
const frontendEntry = resolveEntry(cwd, FRONTEND_ENTRIES);
if (frontendEntry) {
await esbuild.build({
@@ -793,6 +793,14 @@
"enableDefault": {
"type": "boolean",
"description": "What the enable switch reads before a host saves it. Off by default."
},
"editorGroup": {
"enum": ["top", "ssh"],
"description": "Host editor strip the generated tab sits in. Defaults to top."
},
"editorOrder": {
"type": "number",
"description": "Position among that strip's tabs, core ones included."
}
}
}
+14
View File
@@ -74,6 +74,15 @@ export interface ShellLayout {
}
/** What a plugin may ask of the shell. */
/** Fields a plugin can fill in when it opens the host editor. */
export interface HostDraft {
name?: string;
ip?: string;
port?: number;
username?: string;
authType?: string;
}
export interface ShellApi {
openTab: (
host: PluginHostRecord | null,
@@ -88,6 +97,11 @@ export interface ShellApi {
tab: PluginTabRecord,
host: PluginHostRecord,
) => Promise<void>;
/**
* Opens the host editor for a new host with these fields filled in. The
* user reviews and saves it. Absent in some shells.
*/
openHostEditor?: (draft?: HostDraft) => void;
/** Opens a rail view in the left sidebar. */
openRailView: (id: string) => void;
/** Closes a rail view wherever it is shown. */
+26 -1
View File
@@ -271,6 +271,10 @@ export interface PluginHostSettingsContribution {
enableLabelKey?: string;
/** What the enable switch reads before a host saves it. Off by default. */
enableDefault?: boolean;
/** Host editor strip the generated tab sits in. Defaults to "top". */
editorGroup?: "top" | "ssh";
/** Position among that strip's tabs, core ones included. */
editorOrder?: number;
fields: PluginSettingsField[];
}
@@ -1086,7 +1090,14 @@ function validateSettings(settings: unknown, errors: string[]): void {
}
rejectUnknown(
host,
["enableKey", "enableLabelKey", "enableDefault", "fields"],
[
"enableKey",
"enableLabelKey",
"enableDefault",
"editorGroup",
"editorOrder",
"fields",
],
`"${at}"`,
errors,
);
@@ -1104,6 +1115,20 @@ function validateSettings(settings: unknown, errors: string[]): void {
if ("enableDefault" in host && typeof host.enableDefault !== "boolean") {
errors.push(`${at}.enableDefault must be a boolean`);
}
if (
"editorGroup" in host &&
host.editorGroup !== "top" &&
host.editorGroup !== "ssh"
) {
errors.push(`${at}.editorGroup must be "top" or "ssh"`);
}
if (
"editorOrder" in host &&
(typeof host.editorOrder !== "number" ||
!Number.isFinite(host.editorOrder))
) {
errors.push(`${at}.editorOrder must be a number`);
}
if (!Array.isArray(host.fields)) {
errors.push(`${at}.fields must be an array`);
+4 -4
View File
@@ -13,12 +13,12 @@
"description": "Let the assistant carry out the changes it suggests."
},
"services.use": {
"title": "Use AI from other plugins",
"description": "Other plugins may call the assistant on your behalf."
"title": "Use AI from other features",
"description": "Other features may call the assistant on your behalf."
},
"secrets.share": {
"title": "Share the API key",
"description": "Other plugins may use the stored AI API key without seeing it."
"description": "Other features may use the stored AI API key without seeing it."
}
},
"settings": {
@@ -98,7 +98,7 @@
"runInTerminal": "Run",
"terminalContextPrefill": "Here is my terminal output:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "Let other plugins use your AI provider API key for their own requests. The key is never copied, and you can revoke this at any time."
"shareApiKey": "Let other features use your AI provider API key for their own requests. The key is never copied, and you can revoke this at any time."
}
},
"nav": {
+2
View File
@@ -173,6 +173,8 @@
}
],
"host": {
"editorGroup": "ssh",
"editorOrder": 90,
"fields": [
{
"key": "enableAiAssistant",
+1 -1
View File
@@ -159,7 +159,7 @@
"continue": "Carry on"
},
"needsPlugin": "Needs {{plugin}}",
"needsPluginHint": "This automation is skipped until the plugin it needs is turned on again.",
"needsPluginHint": "This automation is skipped until the feature it needs is available again.",
"offBadge": "Off",
"docsLink": "View docs",
"ago": {
+2
View File
@@ -53,6 +53,8 @@
],
"settings": {
"host": {
"editorGroup": "ssh",
"editorOrder": 30,
"enableKey": "enableDocker",
"enableLabelKey": "settings.host.enable.label",
"fields": [
+2
View File
@@ -71,6 +71,8 @@
],
"settings": {
"host": {
"editorGroup": "ssh",
"editorOrder": 60,
"enableKey": "enableFileManager",
"enableLabelKey": "settings.host.enable.label",
"enableDefault": true,
+1 -1
View File
@@ -183,7 +183,7 @@
"wgHandshakeNever": "Never",
"wgTransfer": "Transfer"
},
"snippetsUnavailable": "The Snippets plugin is off, so quick actions cannot run."
"snippetsUnavailable": "Snippets are turned off, so quick actions cannot run."
},
"metricsHistory": {
"title": "Metrics History",
@@ -32,7 +32,7 @@ export function RedirectUriSetting({ values }: SettingsComponentProps) {
<Input
value={uri}
readOnly
className="rounded-none font-mono text-xs"
className="min-w-0 flex-1 rounded-none font-mono text-xs"
/>
<Button
type="button"
+1
View File
@@ -81,6 +81,7 @@
"nativeRdpFailed": "Failed to open Windows Remote Desktop",
"openNativeRdp": "Open in Windows Remote Desktop",
"guac": {
"toolbar": "Toolbar",
"connection": "Connection",
"authentication": "Authentication",
"storedCredential": "Stored Credential",
+2 -1
View File
@@ -427,7 +427,8 @@
"type": "boolean",
"labelKey": "settings.host.enableToolbar.label",
"descriptionKey": "settings.host.enableToolbar.description",
"default": true
"default": true,
"hidden": true
}
]
}
+32 -1
View File
@@ -14,7 +14,12 @@ import {
type TabProps,
type TermixApp,
} from "@termix/plugin-sdk/frontend";
import { SectionCard, isElectron } from "@termix/plugin-sdk/ui";
import {
FakeSwitch,
SectionCard,
SettingRow,
isElectron,
} from "@termix/plugin-sdk/ui";
import { toast } from "sonner";
import GuacamoleApp, { type GuacamoleAppHandle } from "./GuacamoleApp";
import { GuacamoleDisplay } from "./GuacamoleDisplay";
@@ -146,6 +151,29 @@ function SectionNotes({ protocol }: { protocol: Protocol }) {
);
}
function ToolbarCard({
form,
setField,
}: Pick<ReturnType<typeof remoteDesktopForm>, "form" | "setField">) {
const { t } = useTranslation();
return (
<SectionCard
title={t("hosts.guac.toolbar")}
icon={<MonitorUp className="size-3.5" />}
>
<SettingRow
label={t("settings.host.enableToolbar.label")}
description={t("settings.host.enableToolbar.description")}
>
<FakeSwitch
checked={form.enableToolbar}
onChange={(value) => setField("enableToolbar", value)}
/>
</SettingRow>
</SectionCard>
);
}
function RdpSection(props: HostEditorSectionProps) {
const { form, setField, setGuacField } = remoteDesktopForm(props);
return (
@@ -157,6 +185,7 @@ function RdpSection(props: HostEditorSectionProps) {
host={props.host as { macAddress?: string | null } | undefined}
credentials={props.credentials as never}
/>
<ToolbarCard form={form} setField={setField} />
<SectionNotes protocol="rdp" />
</>
);
@@ -173,6 +202,7 @@ function VncSection(props: HostEditorSectionProps) {
host={props.host as { macAddress?: string | null } | undefined}
credentials={props.credentials as never}
/>
<ToolbarCard form={form} setField={setField} />
<SectionNotes protocol="vnc" />
</>
);
@@ -188,6 +218,7 @@ function TelnetSection(props: HostEditorSectionProps) {
setGuacField={setGuacField}
credentials={props.credentials as never}
/>
<ToolbarCard form={form} setField={setField} />
<SectionNotes protocol="telnet" />
</>
);
@@ -16,6 +16,7 @@ export interface RemoteDesktopForm {
vncPort: number;
telnetPort: number;
guacamoleConfig: Record<string, any>;
enableToolbar: boolean;
rdpAuthType: "direct" | "credential" | "none";
rdpCredentialId: string;
rdpUser: string;
@@ -42,6 +43,7 @@ const SETTING_FOR_FIELD: Partial<Record<keyof RemoteDesktopForm, string>> = {
telnetPort: "telnetPort",
security: "rdpSecurity",
ignoreCert: "rdpIgnoreCert",
enableToolbar: "enableToolbar",
};
type PluginSettingsBag = Record<string, Record<string, unknown>>;
@@ -72,6 +74,7 @@ export function remoteDesktopForm(props: HostEditorSectionProps): {
security: options.rdpSecurity,
ignoreCert: options.rdpIgnoreCert,
guacamoleConfig: options.guacamoleConfig as Record<string, any>,
enableToolbar: options.enableToolbar,
};
const setField: RemoteFormSetField = (key, value) => {
+2
View File
@@ -59,6 +59,8 @@
}
],
"host": {
"editorGroup": "ssh",
"editorOrder": 85,
"enableKey": "enableSessionRecording",
"enableLabelKey": "settings.host.enable.label",
"fields": []
+2
View File
@@ -84,6 +84,8 @@
}
],
"host": {
"editorGroup": "ssh",
"editorOrder": 80,
"fields": [
{
"key": "allowSessionSharing",
+1 -1
View File
@@ -105,7 +105,7 @@
"clearAll": "Clear All",
"noHistoryEntries": "No history entries",
"trackingDisabled": "History tracking is disabled",
"trackingDisabledHint": "Turn on Command History for this host in its SSH Terminal plugin settings.",
"trackingDisabledHint": "Turn on Command History in this host's SSH Terminal tab.",
"commandCount_one": "{{count}} command",
"commandCount_other": "{{count}} commands"
},
+2
View File
@@ -216,6 +216,8 @@
}
],
"host": {
"editorGroup": "ssh",
"editorOrder": 12,
"enableKey": "enableTerminal",
"enableLabelKey": "settings.host.enableTerminal.label",
"enableDefault": true,
@@ -109,7 +109,7 @@ export function TouchInputSettings({
{t("touchInput.advanced")}
</button>
{advancedOpen && (
<div className="grid grid-cols-1 gap-3 pb-3 sm:grid-cols-2">
<div className="grid grid-cols-1 gap-3 pb-3">
{fields.map(({ key, label, unit }) => {
const bounds = TOUCH_INPUT_NUMERIC_BOUNDS[key];
return (
@@ -114,7 +114,7 @@ export function TerminalTabContent({
}
isQuickConnect={String(host.id).startsWith("quick-connect-")}
onSaveQuickConnect={
shell.saveQuickConnect
shell.saveQuickConnect && hostRecord?.quickConnectSavable
? () => shell.saveQuickConnect!(tabRecord, hostRecord!)
: undefined
}
+1 -1
View File
@@ -4,7 +4,7 @@
},
"editor": {
"label": "Step CA",
"description": "Sign in through your identity provider and let your Step CA issue a short-lived SSH certificate for this host. The CA URL, root fingerprint and OIDC provisioner are set by an administrator in the Step CA plugin settings.",
"description": "Sign in through your identity provider and let your Step CA issue a short-lived SSH certificate for this host. The CA URL, root fingerprint and OIDC provisioner are set by an administrator in the Step CA section of Admin Settings.",
"docs": "View docs"
},
"dialog": {
@@ -32,7 +32,7 @@ export function RedirectUriSetting({ values }: SettingsComponentProps) {
<Input
value={uri}
readOnly
className="rounded-none font-mono text-xs"
className="min-w-0 flex-1 rounded-none font-mono text-xs"
/>
<Button
type="button"
+5 -1
View File
@@ -42,7 +42,11 @@
"tailscaleLoadingDevices": "Loading devices...",
"tailscaleDeviceLoadFailed": "Failed to load Tailscale devices. Check the API URL and network connection.",
"tailscaleNoDevices": "No devices found in your tailnet.",
"tailscaleDeviceAutoFill": "Selecting a device will auto-fill the host IP address."
"tailscaleDeviceAutoFill": "Selecting a device will auto-fill the host IP address.",
"tailscaleAddHost": "Add host",
"tailscaleCopyIp": "Copy Tailscale IP",
"tailscaleIpCopied": "IP copied",
"tailscaleIpCopyFailed": "Could not copy the IP"
},
"terminal": {
"tailscaleCheckRequired": "Tailscale Authentication Required",
+1 -1
View File
@@ -76,7 +76,7 @@ export function startTailscaleService(
if (!context) {
return res
.status(503)
.json({ error: "Plugin is not running", devices: [] });
.json({ error: "Tailscale is not available", devices: [] });
}
const apiKey = (await context.settings.get<string>("apiKey")) ?? "";
if (!apiKey) {
@@ -1,6 +1,10 @@
import { useEffect, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Loader2, RefreshCw, Terminal } from "lucide-react";
import {
useToast,
useTranslation,
type HostDraft,
} from "@termix/plugin-sdk/frontend";
import { Copy, Loader2, Plus, RefreshCw, Terminal } from "lucide-react";
import { Button } from "@termix/plugin-sdk/ui";
import { Input } from "@termix/plugin-sdk/ui";
import type { PluginHostRecord as Host } from "@termix/plugin-sdk/frontend";
@@ -18,6 +22,8 @@ interface TailscaleDevice {
interface TailscaleDevicesPanelProps {
onConnect: (host: Host, type: "terminal") => void;
/** Absent when the shell cannot open the host editor. */
onAddHost?: (draft: HostDraft) => void;
}
function deviceIp(device: TailscaleDevice): string {
@@ -30,8 +36,10 @@ function deviceIp(device: TailscaleDevice): string {
export function TailscaleDevicesPanel({
onConnect,
onAddHost,
}: TailscaleDevicesPanelProps) {
const { t } = useTranslation();
const toast = useToast();
const [devices, setDevices] = useState<TailscaleDevice[]>([]);
const [hasApiKey, setHasApiKey] = useState(true);
const [loading, setLoading] = useState(true);
@@ -52,10 +60,33 @@ export function TailscaleDevicesPanel({
useEffect(load, []);
function usernameFor(device: TailscaleDevice): string {
return usernames[device.id]?.trim() || "root";
}
function copyIp(ip: string) {
navigator.clipboard
?.writeText(ip)
.then(() => toast.success(t("hosts.tailscaleIpCopied")))
.catch(() => toast.error(t("hosts.tailscaleIpCopyFailed")));
}
function addHost(device: TailscaleDevice) {
const ip = deviceIp(device);
if (!ip || !onAddHost) return;
onAddHost({
name: device.hostname || device.name,
ip,
port: 22,
username: usernameFor(device),
authType: "tailscale",
});
}
function connect(device: TailscaleDevice) {
const ip = deviceIp(device);
if (!ip) return;
const username = usernames[device.id]?.trim() || "root";
const username = usernameFor(device);
const host = createQuickConnectHost({
ip,
port: 22,
@@ -118,14 +149,33 @@ export function TailscaleDevicesPanel({
key={device.id}
className="flex flex-col gap-1.5 border border-border p-2"
>
<div className="flex flex-col gap-0.5">
<span className="text-xs font-semibold">
<div className="flex items-start justify-between gap-2">
<span className="text-xs font-semibold min-w-0 break-all">
{device.hostname || device.name}
</span>
<span className="text-[10px] text-muted-foreground">
{ip} {device.os ? `· ${device.os}` : ""}
</span>
{device.os && (
<span className="shrink-0 border border-border px-1 text-[10px] text-muted-foreground">
{device.os}
</span>
)}
</div>
{ip && (
<div className="flex items-center gap-1">
<span className="text-[10px] text-muted-foreground font-mono">
{ip}
</span>
<Button
variant="ghost"
size="icon"
className="size-5 text-muted-foreground hover:text-foreground"
title={t("hosts.tailscaleCopyIp")}
aria-label={t("hosts.tailscaleCopyIp")}
onClick={() => copyIp(ip)}
>
<Copy className="size-3" />
</Button>
</div>
)}
<Input
placeholder={t("newUi.sidebar.quickConnect.usernameLabel")}
value={usernames[device.id] ?? "root"}
@@ -140,15 +190,28 @@ export function TailscaleDevicesPanel({
}}
className="h-7 text-xs"
/>
<Button
onClick={() => connect(device)}
disabled={!ip}
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors"
variant="outline"
>
<Terminal className="size-3.5" />
{t("newUi.sidebar.quickConnect.connectToTerminal")}
</Button>
<div className="flex gap-1.5">
<Button
onClick={() => connect(device)}
disabled={!ip}
className="flex flex-1 items-center justify-center gap-1.5 h-7 border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors"
variant="outline"
>
<Terminal className="size-3.5" />
{t("newUi.sidebar.quickConnect.connectToTerminal")}
</Button>
{onAddHost && (
<Button
onClick={() => addHost(device)}
disabled={!ip}
variant="outline"
className="h-7 gap-1 text-xs shrink-0"
>
<Plus className="size-3.5" />
{t("hosts.tailscaleAddHost")}
</Button>
)}
</div>
</div>
);
})}
@@ -52,7 +52,7 @@ export function TailscaleDevicesStatus({ running }: SettingsComponentProps) {
const label = (key: string) => t(key);
return (
<div className="flex items-center justify-between gap-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-3 py-3">
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<span className="text-sm font-medium leading-snug">
{label("settings.devices.label")}
+5
View File
@@ -18,6 +18,11 @@ function Panel({ shell }: PanelProps) {
type,
)
}
onAddHost={
shell.openHostEditor
? (draft) => shell.openHostEditor!(draft)
: undefined
}
/>
);
}
@@ -74,4 +74,75 @@ describe("TailscaleDevicesPanel", () => {
expect(host.authType).toBe("tailscale");
expect(host.username).toBe("root");
});
const device = {
id: "d1",
name: "box.tailnet.ts.net",
hostname: "box",
addresses: ["100.64.0.1", "fd7a::1"],
os: "linux",
lastSeen: "2026-01-01T00:00:00Z",
};
it("opens the host editor with the device filled in", async () => {
const user = userEvent.setup();
getTailscaleDevicesMock.mockResolvedValue({
hasApiKey: true,
devices: [device],
});
const onAddHost = vi.fn();
render(<TailscaleDevicesPanel onConnect={vi.fn()} onAddHost={onAddHost} />);
await waitFor(() => expect(screen.getByText("box")).toBeTruthy());
await user.clear(
screen.getByPlaceholderText("newUi.sidebar.quickConnect.usernameLabel"),
);
await user.type(
screen.getByPlaceholderText("newUi.sidebar.quickConnect.usernameLabel"),
"luke",
);
await user.click(screen.getByText("hosts.tailscaleAddHost"));
expect(onAddHost).toHaveBeenCalledWith({
name: "box",
ip: "100.64.0.1",
port: 22,
username: "luke",
authType: "tailscale",
});
});
it("hides Add host when the shell cannot open the editor", async () => {
getTailscaleDevicesMock.mockResolvedValue({
hasApiKey: true,
devices: [device],
});
render(<TailscaleDevicesPanel onConnect={vi.fn()} />);
await waitFor(() => expect(screen.getByText("box")).toBeTruthy());
expect(screen.queryByText("hosts.tailscaleAddHost")).toBeNull();
});
it("copies the tailnet IP", async () => {
const user = userEvent.setup();
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
getTailscaleDevicesMock.mockResolvedValue({
hasApiKey: true,
devices: [device],
});
render(<TailscaleDevicesPanel onConnect={vi.fn()} />);
await waitFor(() => expect(screen.getByText("box")).toBeTruthy());
expect(screen.getByText("linux")).toBeTruthy();
await user.click(screen.getByLabelText("hosts.tailscaleCopyIp"));
expect(writeText).toHaveBeenCalledWith("100.64.0.1");
});
});
@@ -32,7 +32,7 @@ export function RedirectUriSetting({ values }: SettingsComponentProps) {
<Input
value={uri}
readOnly
className="rounded-none font-mono text-xs"
className="min-w-0 flex-1 rounded-none font-mono text-xs"
/>
<Button
type="button"
+2
View File
@@ -38,6 +38,8 @@
],
"settings": {
"host": {
"editorGroup": "top",
"editorOrder": 90,
"fields": [
{
"key": "macAddress",
+2
View File
@@ -21,6 +21,8 @@
},
"settings": {
"host": {
"editorGroup": "ssh",
"editorOrder": 95,
"fields": [
{
"key": "useWarpgate",
+42
View File
@@ -0,0 +1,42 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { build } from "../packages/plugin-sdk/cli/commands/build.mjs";
const cleanups: Array<() => void> = [];
afterEach(() => {
while (cleanups.length) cleanups.pop()?.();
vi.restoreAllMocks();
});
function fixturePlugin(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-build-"));
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
fs.writeFileSync(
path.join(dir, "manifest.json"),
JSON.stringify({ id: "build-fixture" }),
);
fs.mkdirSync(path.join(dir, "src", "backend"), { recursive: true });
fs.writeFileSync(
path.join(dir, "src", "backend", "index.ts"),
"export function activate() {}\n",
);
return dir;
}
describe("termix-plugin build", () => {
it("marks dist as ESM so Node loads backend.js without reparsing", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
const dir = fixturePlugin();
await build({ cwd: dir });
const pkg = JSON.parse(
fs.readFileSync(path.join(dir, "dist", "package.json"), "utf8"),
);
expect(pkg).toEqual({ type: "module" });
expect(fs.existsSync(path.join(dir, "dist", "backend.js"))).toBe(true);
});
});
@@ -70,14 +70,14 @@ router.use("/:pluginId", (req: Request, res: Response, next) => {
/** 503 for an installed plugin that is off, 404 for an unknown id. */
function notServing(res: Response, pluginId: string): void {
if (isPluginInstalled(pluginId)) {
res.status(503).json({ error: "Plugin is not running", pluginId });
res.status(503).json({ error: "This feature is not available", pluginId });
return;
}
databaseLogger.warn("Plugin API request for unregistered plugin", {
operation: "plugin_api_dispatch",
pluginId,
});
res.status(404).json({ error: "Plugin not installed" });
res.status(404).json({ error: "This feature is not available" });
}
/**
+1 -1
View File
@@ -175,7 +175,7 @@ export function createPluginRouter({
next();
return;
}
res.status(503).json({ error: "Plugin is not running", pluginId });
res.status(503).json({ error: "This feature is not available", pluginId });
});
// The grant lives in the database, so it cannot be checked in the
@@ -1,9 +1,12 @@
import { describe, expect, it, vi } from "vitest";
import { createServer } from "http";
import type { AddressInfo } from "net";
import type { LookupAddress, LookupOptions } from "dns";
import {
createDnsLookupHook,
isBlockedAddress,
readResponseTextLimited,
safeOutboundFetch,
} from "../../utils/safe-outbound-fetch.js";
describe("isBlockedAddress", () => {
@@ -257,3 +260,24 @@ describe("createDnsLookupHook", () => {
);
});
});
describe("safeOutboundFetch", () => {
it("returns a large body the caller reads after the call", async () => {
const body = "x".repeat(1_000_000);
const server = createServer((_req, res) => res.end(body));
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve),
);
const { port } = server.address() as AddressInfo;
try {
const response = await safeOutboundFetch(
`http://127.0.0.1:${port}/`,
{},
["127.0.0.1"],
);
expect((await response.text()).length).toBe(body.length);
} finally {
server.close();
}
}, 10_000);
});
+7 -3
View File
@@ -237,12 +237,16 @@ export async function safeOutboundFetch(
});
try {
return await undiciFetch(url.toString(), {
const response = await undiciFetch(url.toString(), {
...options,
dispatcher,
redirect: "error",
});
} finally {
await dispatcher.close();
// close() waits for the body, which the caller reads after we return.
dispatcher.close().catch(() => {});
return response;
} catch (error) {
await dispatcher.destroy().catch(() => {});
throw error;
}
}
+3 -4
View File
@@ -38,6 +38,8 @@ export type Host = {
keyType?: string;
notes?: string;
pin?: boolean;
/** Quick connect only: core can save this host as-is. */
quickConnectSavable?: boolean;
sortOrder?: number | null;
/** Stable identity across a desktop/server sync pair. */
@@ -217,14 +219,11 @@ export type AdminSection =
| "sessions"
| "roles"
| "host-defaults"
| "image-storage"
| "branding"
| "database"
| "api-keys"
| "audit-log"
| "ssl"
| "plugins"
| "touch-input";
| "ssl";
export type ThemeId =
| "dark"
| "light"
+29 -13
View File
@@ -86,11 +86,14 @@ const MacrosPanel = lazy(() =>
import("@/sidebar/MacrosPanel").then((m) => ({ default: m.MacrosPanel })),
);
// One screen behind both rail entries. It lazy-loads the profile and admin
// panels itself, so the shell no longer imports either directly.
const SettingsScreen = lazy(() =>
import("@/settings/SettingsScreen").then((m) => ({
default: m.SettingsScreen,
const UserProfilePanel = lazy(() =>
import("@/sidebar/UserProfilePanel").then((m) => ({
default: m.UserProfilePanel,
})),
);
const AdminSettingsPanel = lazy(() =>
import("@/sidebar/AdminSettingsPanel").then((m) => ({
default: m.AdminSettingsPanel,
})),
);
const CredentialsPanel = lazy(() =>
@@ -2269,6 +2272,17 @@ export function AppShell({
setRailView((prev) => (prev === id ? "hosts" : prev));
setRightRailView((prev) => (prev === id ? null : prev));
},
openHostEditor: (draft) => {
setSidebarOpen(true);
setRailView("hosts");
setTimeout(
() =>
window.dispatchEvent(
new CustomEvent("host-manager:add-host", { detail: draft }),
),
0,
);
},
saveQuickConnect: saveQuickConnectHost,
};
@@ -2480,14 +2494,9 @@ export function AppShell({
</div>
)}
{(railView === "user-profile" ||
(railView === "admin-settings" && showAdminUI)) && (
<div className="flex flex-col flex-1 min-h-0">
<SettingsScreen
initialSection={
railView === "admin-settings" ? "admin" : "profile"
}
isAdmin={showAdminUI}
{railView === "user-profile" && (
<div className="flex-1 min-h-0 overflow-y-auto">
<UserProfilePanel
username={username}
onLogout={onLogout}
userPrefs={userPrefs}
@@ -2499,6 +2508,13 @@ export function AppShell({
onRemoteSyncReconnectHandled={() =>
setRemoteSyncReconnectRequested(false)
}
/>
</div>
)}
{railView === "admin-settings" && showAdminUI && (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<AdminSettingsPanel
onEditingChange={setSidebarEditing}
onOpenHostTab={(host) => {
connectHost(host);
+2 -27
View File
@@ -42,6 +42,8 @@ export interface PluginHostSettingsContribution {
enableKey?: string;
enableLabelKey?: string;
enableDefault?: boolean;
editorGroup?: "top" | "ssh";
editorOrder?: number;
fields: PluginSettingsField[];
}
@@ -246,30 +248,3 @@ export async function getPlugins(): Promise<PluginSummary[]> {
const response = await rbacApi.get("/plugins");
return Array.isArray(response.data) ? response.data : [];
}
export async function setPluginEnabled(
pluginId: string,
enabled: boolean,
): Promise<void> {
await rbacApi.patch(`/plugins/${encodeURIComponent(pluginId)}/state`, {
enabled,
});
}
export async function grantPluginCapability(
pluginId: string,
capability: string,
): Promise<void> {
await rbacApi.post(`/plugins/${encodeURIComponent(pluginId)}/grants`, {
capability,
});
}
export async function revokePluginCapability(
pluginId: string,
capability: string,
): Promise<void> {
await rbacApi.delete(
`/plugins/${encodeURIComponent(pluginId)}/grants/${encodeURIComponent(capability)}`,
);
}
+15 -40
View File
@@ -1067,8 +1067,8 @@
"filterAuthAgent": "SSH Agent",
"filterAuthVault": "Vault",
"filterAuthTailscale": "Tailscale",
"authTypeNeedsPlugin": "This host uses {{type}}, which needs the {{plugin}} plugin. Turn it on to connect.",
"authTypeUnknown": "This host uses {{type}}, which no enabled plugin provides.",
"authTypeNeedsPlugin": "This host uses {{type}}, which needs {{plugin}}. It is turned off on this server.",
"authTypeUnknown": "This host uses {{type}}, which is not available on this server.",
"authFieldsAfterSave": "Save the host first, then fill in these fields.",
"enablePluginFeature": "Enable {{name}}",
"disablePluginFeature": "Disable {{name}}",
@@ -1099,7 +1099,7 @@
"navigate": "Navigate",
"select": "Select",
"toggleWith": "Toggle with",
"pluginActions": "From plugins"
"pluginActions": "More actions"
},
"splitScreen": {
"paneEmpty": "Pane {{index}} - empty",
@@ -1527,7 +1527,7 @@
"brandingLogoInvalid": "Invalid image. Use a PNG, JPEG or WEBP file up to 750 KB.",
"brandingSaved": "Branding settings saved",
"brandingSaveFailed": "Failed to save branding settings",
"sslDescription": "The certificate Termix serves over HTTPS. Upload your own here, or let the ACME Certificates plugin get and renew one from Let's Encrypt.",
"sslDescription": "The certificate Termix serves over HTTPS. Upload your own here, or get and renew one from Let's Encrypt in the ACME Certificates section.",
"sslDocsLink": "View SSL docs",
"sslCertStatus": "Certificate Status",
"sslCertStatusNone": "No certificate",
@@ -1811,7 +1811,7 @@
"saveError": "Failed to save role permissions",
"save": "Save",
"saving": "Saving...",
"pluginDisabled": "Plugin disabled",
"pluginDisabled": "Feature disabled",
"groups": {
"hosts": "Hosts",
"credentials": "Credentials",
@@ -1821,30 +1821,15 @@
"notificationPrivateEndpoints": "Allowed private notification hosts",
"notificationPrivateEndpointsDesc": "Exact private hosts that notification channels may contact. Separate them with commas.",
"updateNotificationEndpointsFailed": "Failed to update notification endpoint allowlist",
"plugins": "Plugins",
"pluginsNone": "No plugins installed.",
"pluginsLoadFailed": "Failed to load plugins",
"pluginToggleFailed": "Failed to change the plugin",
"pluginBuiltIn": "BUILT IN",
"pluginEnabled": "{{name}} enabled",
"pluginDisabled": "{{name}} disabled",
"pluginPermissions": "Permissions",
"pluginPermissionsNone": "This plugin does not declare any capabilities that need to be granted.",
"pluginPublicRoutes": "Public routes",
"pluginPublicRoutesDescription": "Anyone can reach these without signing in. The plugin checks access on its own.",
"pluginPermissionGranted": "Granted",
"pluginPermissionNotGranted": "Not granted",
"pluginGrantFailed": "Failed to grant the capability",
"pluginRevokeFailed": "Failed to revoke the capability",
"secondFactorsSectionTitle": "Second factors",
"secondFactorsNone": "No second factors enrolled",
"secondFactorUnavailable": "The {{plugin}} plugin is off",
"secondFactorUnavailable": "Unavailable",
"resetSecondFactors": "Reset second factors",
"resetSecondFactorsConfirm": "Remove every second factor for {{username}}? They can sign in with their password or SSO and enrol again.",
"secondFactorsResetSuccess": "Second factors reset",
"secondFactorsResetFailed": "Failed to reset second factors",
"passwordLoginForced": "Password login is turned off, but no other login method is enabled, so it stays on for now. Nobody gets locked out.",
"sslNotRenewed": "Nothing renews this certificate. It expires {{date}}. Turn on the ACME Certificates plugin and its automatic renewal, or upload a new one before then.",
"sslNotRenewed": "Nothing renews this certificate. It expires {{date}}. Turn on automatic renewal in the ACME Certificates section, or upload a new one before then.",
"sslSelfSigned": "Self-signed certificate made by Termix. Browsers will warn about it.",
"sslRenewedBy": "Renewed automatically by {{plugin}}",
"sslIssuer": "Issued by {{issuer}}",
@@ -2506,36 +2491,26 @@
}
},
"runtime": {
"needsPlugin": "This needs the {{name}} plugin",
"needsUnknownPlugin": "This needs a plugin that is not installed",
"disabledHint": "An admin can turn it on in Settings under Plugins.",
"missingHint": "It was added by a plugin that has since been removed.",
"failed": "The {{name}} plugin could not start",
"failedHint": "An admin can see why in Settings under Plugins.",
"needsPlugin": "{{name}} is not available",
"needsUnknownPlugin": "This feature is not available",
"disabledHint": "It is turned off on this server.",
"missingHint": "The feature it came from has been removed.",
"failed": "{{name}} could not start",
"failedHint": "An admin can check the server logs for details.",
"loading": "Loading {{name}}",
"crashed": "Something went wrong in the {{name}} plugin",
"crashed": "Something went wrong in {{name}}",
"retry": "Try again"
}
},
"settings": {
"title": "Settings",
"bandYou": "You",
"bandPlugins": "Plugins",
"bandInstance": "Instance",
"account": "Account",
"secretSet": "Set",
"secretNotSet": "Not set",
"secretReplace": "Replace",
"pluginGroupAdmin": "Server settings",
"pluginGroupUser": "Your settings",
"pluginNoSettings": "{{name}} has no settings you can change.",
"pluginStoppedNotice": "{{name}} is installed but not running. These settings are saved but will not take effect.",
"pluginSettingsSaved": "Settings saved",
"pluginSettingsInvalid": "Some settings could not be saved. Check the highlighted fields.",
"pluginSettingsSaveFailed": "Failed to save settings",
"pluginSettingsLoadFailed": "Failed to load settings",
"pluginsGroupLabel": "Plugins",
"hostPluginNotRunning": "{{name}} is installed but not running. These settings are saved but will not take effect."
"featureUnavailable": "{{name}} is unavailable right now. These settings are saved but will not take effect."
},
"managerCard": {
"refresh": "Refresh",
+2 -2
View File
@@ -1,4 +1,4 @@
import { Puzzle } from "lucide-react";
import { CircleSlash } from "lucide-react";
import { useTranslation } from "react-i18next";
import { usePluginStore } from "./plugin-store";
import { unregisteredViewStatus, type ViewKind } from "./view-ownership";
@@ -55,7 +55,7 @@ export function PluginViewPlaceholder({
data-status={status}
className={`flex h-full w-full flex-col items-center justify-center gap-2 text-center ${compact ? "p-3" : "p-6"}`}
>
<Puzzle className="size-6 text-muted-foreground/40" />
<CircleSlash className="size-6 text-muted-foreground/40" />
<span className="text-sm font-semibold text-muted-foreground">
{title}
</span>
+4 -3
View File
@@ -16,7 +16,7 @@ import {
setPluginSummaries,
} from "./plugin-store";
import { workspaceFrontends, workspaceLocales } from "./workspace-plugins";
import { syncHostPluginsTab } from "@/settings/host-plugins-tab";
import { syncHostFeatureTabs } from "@/settings/host-feature-tabs";
/**
* Loads plugin frontends into the running shell.
@@ -273,8 +273,6 @@ async function loadNamespaces(summaries: PluginSummary[]): Promise<void> {
async function reconcile(summaries: PluginSummary[]): Promise<void> {
setPluginSummaries(summaries);
// The host editor's Plugins tab exists only while something fills it.
syncHostPluginsTab(summaries);
await loadNamespaces(summaries);
const { order, blocked } = orderForActivation(summaries);
@@ -301,6 +299,9 @@ async function reconcile(summaries: PluginSummary[]): Promise<void> {
}
await activateConcurrently(order);
// After activation, so a plugin that registered its own host editor
// section is not given a second, generated one.
syncHostFeatureTabs(summaries);
}
/**
+1
View File
@@ -87,6 +87,7 @@ export const shell: TabShellCallbacks = {
renameTab: (...args) => callbacks?.renameTab(...args),
openRailView: (...args) => callbacks?.openRailView(...args),
closeRailView: (...args) => callbacks?.closeRailView(...args),
openHostEditor: (...args) => callbacks?.openHostEditor?.(...args),
saveQuickConnect: (...args) =>
callbacks?.saveQuickConnect?.(...args) ?? Promise.resolve(),
};
+1
View File
@@ -80,6 +80,7 @@ function recordingShell(calls: ShellCall[]): TabShellCallbacks {
renameTab: record("renameTab"),
openRailView: record("openRailView"),
closeRailView: record("closeRailView"),
openHostEditor: record("openHostEditor"),
saveQuickConnect: async (...args) => {
calls.push({ method: "saveQuickConnect", args });
},
@@ -1,10 +1,7 @@
/* eslint-disable react-refresh/only-export-components */
/**
* One plugin's settings page, drawn from its manifest.
*
* The plugin declares groups and fields; Termix renders them with the same
* SectionCard and SettingRow everything else uses. A plugin cannot ship its
* own form styling and drift from the rest of the app, and a page disappears
* cleanly when the plugin is uninstalled.
* Manifest-declared settings shown as ordinary sections in the profile and
* admin panels, one per feature, named after it.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
@@ -13,9 +10,11 @@ import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { SectionCard } from "@/components/section-card";
import { AccordionSection } from "@/sidebar/AdminSettingsShared";
import { pluginKey } from "@/lib/plugin-i18n";
import { PluginIcon } from "@/lib/plugin-icon";
import {
getPlugins,
getPluginAdminSettings,
getPluginUserSettings,
updatePluginAdminSettings,
@@ -26,83 +25,82 @@ import {
type PluginSummary,
} from "@/api/plugins-api";
import { SettingsFieldRow } from "./SettingsFields";
import { isFieldActive } from "./settings-fields-util";
import { hasVisibleFields, isFieldActive } from "./settings-fields-util";
interface PluginSettingsPageProps {
plugin: PluginSummary;
isAdmin: boolean;
export type FeatureSettingsScope = "admin" | "user";
export type FeatureSectionId = `feature:${string}`;
export function featureSectionId(pluginId: string): FeatureSectionId {
return `feature:${pluginId}`;
}
type Scope = "admin" | "user";
/** Enabled features with fields in this scope, sorted by name. */
export function useFeatureSettings(
scope: FeatureSettingsScope,
): PluginSummary[] {
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
export function PluginSettingsPage({
plugin,
isAdmin,
}: PluginSettingsPageProps) {
const { t } = useTranslation();
const settings = plugin.contributes?.settings;
const running = plugin.enabled && plugin.state !== "failed";
useEffect(() => {
let cancelled = false;
void getPlugins()
.then((loaded) => {
if (!cancelled) setPlugins(loaded);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
// Admin fields are not merely disabled for a non-admin: the values behind
// them are install-wide configuration they have no business reading.
const scopes = useMemo(() => {
const entries: { scope: Scope; fields: PluginSettingsField[] }[] = [];
if (isAdmin && (settings?.admin?.length ?? 0) > 0) {
entries.push({ scope: "admin", fields: settings!.admin! });
}
if ((settings?.user?.length ?? 0) > 0) {
entries.push({ scope: "user", fields: settings!.user! });
}
return entries;
}, [settings, isAdmin]);
if (scopes.length === 0) {
return (
<div className="flex flex-col gap-2 p-2.5">
<p className="text-xs text-muted-foreground px-1">
{t("settings.pluginNoSettings", { name: plugin.name })}
</p>
</div>
);
}
return (
<div className="flex flex-col gap-2 p-2.5">
{!running && (
<div className="border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-500">
{t("settings.pluginStoppedNotice", { name: plugin.name })}
</div>
)}
{scopes.map(({ scope, fields }) => (
<PluginScopeForm
key={scope}
plugin={plugin}
scope={scope}
fields={fields}
running={running}
/>
))}
</div>
return useMemo(
() =>
plugins
.filter(
(plugin) =>
plugin.enabled &&
hasVisibleFields(plugin.contributes?.settings?.[scope] ?? []),
)
.sort((a, b) => a.name.localeCompare(b.name)),
[plugins, scope],
);
}
/**
* One scope's form.
*
* Admin and user settings are separate requests with separate permissions, so
* they save separately rather than sharing one dirty state.
*/
function PluginScopeForm({
export function FeatureSettingsSection({
plugin,
scope,
open,
onToggle,
}: {
plugin: PluginSummary;
scope: FeatureSettingsScope;
open: boolean;
onToggle: () => void;
}) {
return (
<AccordionSection
label={plugin.name}
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<FeatureSettingsForm
plugin={plugin}
scope={scope}
fields={plugin.contributes?.settings?.[scope] ?? []}
/>
</AccordionSection>
);
}
function FeatureSettingsForm({
plugin,
scope,
fields,
running,
}: {
plugin: PluginSummary;
scope: Scope;
scope: FeatureSettingsScope;
fields: PluginSettingsField[];
running: boolean;
}) {
const { t } = useTranslation();
const [values, setValues] = useState<Record<string, unknown>>({});
@@ -110,6 +108,7 @@ function PluginScopeForm({
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const running = plugin.enabled && plugin.state !== "failed";
const load = useCallback(async () => {
setLoading(true);
@@ -166,13 +165,9 @@ function PluginScopeForm({
}
};
// Fields keep their declared order within a group, and groups appear in the
// order their first field does, so a manifest controls the whole layout.
// Groups appear in the order of their first field, so the manifest sets the layout.
const groups = useMemo(() => {
const ordered: {
key: string | undefined;
fields: PluginSettingsField[];
}[] = [];
const ordered: { key?: string; fields: PluginSettingsField[] }[] = [];
for (const field of fields) {
const existing = ordered.find((group) => group.key === field.group);
if (existing) existing.fields.push(field);
@@ -183,46 +178,53 @@ function PluginScopeForm({
if (loading) {
return (
<div className="flex items-center gap-2 px-1 py-4 text-xs text-muted-foreground">
<div className="flex items-center gap-2 py-3 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
{t("common.loading")}
</div>
);
}
const rows = (group: PluginSettingsField[]) =>
group
.filter((field) => isFieldActive(field, values))
.map((field) => (
<SettingsFieldRow
key={field.key}
pluginId={plugin.id}
field={field}
values={values}
setValue={setValue}
running={running}
error={errors[field.key]}
/>
));
const icon = <PluginIcon name={plugin.icon} className="size-3.5" />;
return (
<>
{groups.map((group, index) => (
<SectionCard
key={group.key ?? `group-${index}`}
title={
group.key
? t(pluginKey(plugin.id, group.key))
: scope === "admin"
? t("settings.pluginGroupAdmin")
: t("settings.pluginGroupUser")
}
icon={icon}
>
{group.fields
.filter((field) => isFieldActive(field, values))
.map((field) => (
<SettingsFieldRow
key={field.key}
pluginId={plugin.id}
field={field}
values={values}
setValue={setValue}
running={running}
error={errors[field.key]}
/>
))}
</SectionCard>
))}
<div className="flex flex-col gap-2 pt-2">
{!running && (
<div className="border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-500">
{t("settings.featureUnavailable", { name: plugin.name })}
</div>
)}
<div className="flex justify-end px-1">
{groups.map((group, index) =>
group.key ? (
<SectionCard
key={group.key}
title={t(pluginKey(plugin.id, group.key))}
icon={icon}
>
{rows(group.fields)}
</SectionCard>
) : (
<div key={`group-${index}`}>{rows(group.fields)}</div>
),
)}
<div className="flex justify-end">
<Button
size="sm"
className="h-7 text-xs"
@@ -233,6 +235,6 @@ function PluginScopeForm({
{t("common.save")}
</Button>
</div>
</>
</div>
);
}
+1 -1
View File
@@ -139,7 +139,7 @@ function HostPluginSection({
>
{!running && (
<div className="my-3 border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-500">
{t("settings.hostPluginNotRunning", { name: plugin.name })}
{t("settings.featureUnavailable", { name: plugin.name })}
</div>
)}
+59 -28
View File
@@ -80,20 +80,49 @@ export function SettingsFieldRow({
<span className="text-destructive">{error}</span>
) : undefined;
const control = (
<FieldControl
pluginId={pluginId}
field={field}
value={value}
setValue={setValue}
readOnly={readOnly}
/>
);
// A switch fits beside its label. Anything wider goes underneath, since
// these rows also render in a narrow sidebar.
if (field.type === "boolean") {
return (
<SettingRow
label={label(field.labelKey)}
description={description}
badge={error ? t("common.error") : undefined}
>
{control}
</SettingRow>
);
}
return (
<SettingRow
label={label(field.labelKey)}
description={description}
badge={error ? t("common.error") : undefined}
>
<FieldControl
pluginId={pluginId}
field={field}
value={value}
setValue={setValue}
readOnly={readOnly}
/>
</SettingRow>
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-sm font-medium leading-snug">
{label(field.labelKey)}
</span>
{error && (
<span className="text-[10px] font-bold text-yellow-500 border border-yellow-500/40 px-1 shrink-0">
{t("common.error")}
</span>
)}
</div>
{description && (
<span className="text-xs text-muted-foreground leading-snug">
{description}
</span>
)}
<div className="min-w-0">{control}</div>
</div>
);
}
@@ -130,7 +159,7 @@ function FieldControl({
return (
<Input
type="number"
className="h-7 w-40 text-sm"
className="h-8 w-full max-w-40 text-sm"
disabled={readOnly}
min={field.min}
max={field.max}
@@ -152,7 +181,7 @@ function FieldControl({
value={typeof value === "string" ? value : ""}
onValueChange={(next) => setValue(field.key, next)}
>
<SelectTrigger className="h-7 w-52 text-sm">
<SelectTrigger className="h-8 w-full min-w-0 text-sm">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
@@ -168,13 +197,12 @@ function FieldControl({
case "multiselect": {
const selected = Array.isArray(value) ? (value as string[]) : [];
return (
<div className="flex flex-col gap-1.5 items-end">
<div className="flex flex-col gap-1.5">
{(field.options ?? []).map((option) => (
<label
key={option.value}
className="flex items-center gap-2 text-xs text-muted-foreground"
>
<span>{label(option.labelKey)}</span>
<Checkbox
disabled={readOnly}
checked={selected.includes(option.value)}
@@ -187,6 +215,7 @@ function FieldControl({
)
}
/>
<span>{label(option.labelKey)}</span>
</label>
))}
</div>
@@ -208,7 +237,7 @@ function FieldControl({
case "json":
return (
<Textarea
className="w-64 text-sm font-mono min-h-16"
className="w-full text-sm font-mono min-h-16"
disabled={readOnly}
placeholder={placeholder}
value={
@@ -225,7 +254,7 @@ function FieldControl({
default:
return (
<Input
className="h-7 w-52 text-sm"
className="h-8 w-full text-sm"
disabled={readOnly}
placeholder={placeholder}
value={typeof value === "string" ? value : ""}
@@ -259,7 +288,7 @@ function SecretControl({
if (redacted) {
return (
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-muted-foreground">
{stored ? t("settings.secretSet") : t("settings.secretNotSet")}
</span>
@@ -277,14 +306,16 @@ function SecretControl({
}
return (
<div className="flex items-center gap-2">
<PasswordInput
className="h-7 w-52 text-sm"
disabled={readOnly}
placeholder={placeholder}
value={typeof value === "string" ? value : ""}
onChange={(event) => setValue(field.key, event.target.value)}
/>
<div className="flex items-center gap-2 min-w-0">
<div className="min-w-0 flex-1">
<PasswordInput
className="h-8 w-full text-sm"
disabled={readOnly}
placeholder={placeholder}
value={typeof value === "string" ? value : ""}
onChange={(event) => setValue(field.key, event.target.value)}
/>
</div>
<Button
variant="ghost"
size="sm"
-273
View File
@@ -1,273 +0,0 @@
/**
* Everything that configures the app rather than operating a server.
*
* Profile and admin were two separate rail panels, each a fixed accordion of
* hardcoded sections. That is the wrong shape for the number of sections they
* hold, and it left no place at all for a plugin to contribute one. This is a
* single screen with a banded left nav, and a plugin's page is an entry in it
* that appears and disappears with the plugin.
*
* Both rail buttons still open this, each on its own section, so the Navigation
* visibility toggles and the habit of reaching for either one keep working.
*/
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
import {
ChevronDown,
Puzzle,
Settings as SettingsIcon,
User,
} from "lucide-react";
import { Separator } from "@/components/separator";
import { PluginIcon } from "@/lib/plugin-icon";
import { getPlugins, type PluginSummary } from "@/api/plugins-api";
import { PluginSettingsPage } from "./PluginSettingsPage";
const UserProfilePanel = lazy(() =>
import("@/sidebar/UserProfilePanel").then((m) => ({
default: m.UserProfilePanel,
})),
);
const AdminSettingsPanel = lazy(() =>
import("@/sidebar/AdminSettingsPanel").then((m) => ({
default: m.AdminSettingsPanel,
})),
);
// Taken from the panels themselves: this screen only forwards them, so
// restating their shapes here would be one more thing to keep in step.
type ProfileProps = ComponentProps<typeof UserProfilePanel>;
type AdminProps = ComponentProps<typeof AdminSettingsPanel>;
/** Built-in sections, plus "plugin:<id>" for a plugin's own settings page. */
export type SettingsSectionId = "profile" | "admin" | `plugin:${string}`;
export interface SettingsScreenProps {
/** Which page to land on. Both rail entries come in here. */
initialSection?: SettingsSectionId;
isAdmin?: boolean;
/** Forwarded to the profile panel. */
username?: ProfileProps["username"];
onLogout?: ProfileProps["onLogout"];
userPrefs?: ProfileProps["userPrefs"];
onPrefsChange?: ProfileProps["onPrefsChange"];
remoteSyncInitialServerUrl?: ProfileProps["remoteSyncInitialServerUrl"];
remoteSyncReconnectRequested?: ProfileProps["remoteSyncReconnectRequested"];
onRemoteSyncReconnectHandled?: ProfileProps["onRemoteSyncReconnectHandled"];
/** Forwarded to the admin panel. */
onEditingChange?: AdminProps["onEditingChange"];
onOpenHostTab?: AdminProps["onOpenHostTab"];
}
interface NavItem {
id: SettingsSectionId;
label: string;
icon: React.ElementType;
iconName?: string;
}
export function SettingsScreen({
initialSection = "profile",
isAdmin = false,
username,
onLogout,
userPrefs,
onPrefsChange,
remoteSyncInitialServerUrl,
remoteSyncReconnectRequested,
onRemoteSyncReconnectHandled,
onEditingChange,
onOpenHostTab,
}: SettingsScreenProps) {
const { t } = useTranslation();
const [section, setSection] = useState<SettingsSectionId>(initialSection);
const [navOpen, setNavOpen] = useState(false);
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
useEffect(() => {
let cancelled = false;
void getPlugins()
.then((loaded) => {
if (!cancelled) setPlugins(loaded);
})
.catch(() => {
// The plugin band is additive: the core sections still work without it.
});
return () => {
cancelled = true;
};
}, []);
// The rail can switch which entry opened the screen while it is mounted.
useEffect(() => setSection(initialSection), [initialSection]);
/**
* A plugin gets a page when it is enabled and declares settings this user
* could actually change. A non-admin seeing a page of admin-only fields
* would be a page they can read and never act on.
*/
const pluginPages = useMemo(
() =>
plugins.filter((plugin) => {
if (!plugin.enabled) return false;
const settings = plugin.contributes?.settings;
if (!settings) return false;
if ((settings.user?.length ?? 0) > 0) return true;
return isAdmin && (settings.admin?.length ?? 0) > 0;
}),
[plugins, isAdmin],
);
const bands = useMemo(() => {
const result: { heading: string; items: NavItem[] }[] = [
{
heading: t("settings.bandYou"),
items: [{ id: "profile", label: t("settings.account"), icon: User }],
},
];
if (pluginPages.length > 0) {
result.push({
heading: t("settings.bandPlugins"),
items: pluginPages.map((plugin) => ({
id: `plugin:${plugin.id}` as SettingsSectionId,
label: plugin.name,
icon: Puzzle,
iconName: plugin.icon,
})),
});
}
if (isAdmin) {
result.push({
heading: t("settings.bandInstance"),
items: [{ id: "admin", label: t("nav.admin"), icon: SettingsIcon }],
});
}
return result;
}, [t, pluginPages, isAdmin]);
const sections = bands.flatMap((band) => band.items);
const active = sections.find((item) => item.id === section) ?? sections[0];
// A plugin page can vanish under you: the plugin is disabled from the admin
// section in the same screen. Fall back rather than render an empty pane.
useEffect(() => {
if (
section.startsWith("plugin:") &&
!sections.some((s) => s.id === section)
) {
setSection("profile");
}
}, [section, sections]);
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
<header className="flex h-12.5 shrink-0 flex-row items-center border-b border-border">
<div className="flex min-w-0 flex-1 items-center gap-2 px-3">
<SettingsIcon className="size-4 shrink-0 text-accent-brand" />
<span className="truncate text-base font-bold tracking-tight">
{t("settings.title")}
</span>
<Separator orientation="vertical" className="h-4" />
<span className="hidden truncate text-[10px] font-semibold uppercase tracking-widest text-muted-foreground md:inline">
{active?.label}
</span>
{/* The nav column does not fit beside the content on a phone, so
there it collapses behind the section name. */}
<button
onClick={() => setNavOpen((open) => !open)}
aria-expanded={navOpen}
className="flex min-w-0 items-center gap-1 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground md:hidden"
>
<span className="truncate">{active?.label}</span>
<ChevronDown
className={`size-3 shrink-0 transition-transform ${navOpen ? "rotate-180" : ""}`}
/>
</button>
</div>
</header>
<div className="flex min-h-0 flex-1">
<nav
className={`${navOpen ? "flex" : "hidden"} w-full shrink-0 flex-col gap-3 overflow-y-auto border-r border-border py-2.5 md:flex md:w-60`}
>
{bands.map((band) => (
<div key={band.heading} className="flex flex-col">
<span className="px-2.5 pb-1.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{band.heading}
</span>
{band.items.map((item) => {
const Icon = item.icon;
const on = item.id === section;
return (
<button
key={item.id}
onClick={() => {
setSection(item.id);
setNavOpen(false);
}}
className={`flex w-full items-center gap-2 border-l-2 py-2.5 pl-2 pr-2 text-left transition-colors md:py-1.5 ${
on
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
: "border-transparent text-muted-foreground hover:bg-muted hover:text-foreground"
}`}
>
{item.iconName ? (
<PluginIcon
name={item.iconName}
className="size-3.5 shrink-0"
/>
) : (
<Icon className="size-3.5 shrink-0" />
)}
<span className="truncate text-xs font-medium">
{item.label}
</span>
</button>
);
})}
</div>
))}
</nav>
<div
className={`${navOpen ? "hidden" : ""} min-h-0 flex-1 overflow-y-auto md:block`}
>
<Suspense fallback={null}>
{section === "profile" && (
<UserProfilePanel
username={username}
onLogout={onLogout}
userPrefs={userPrefs}
onPrefsChange={onPrefsChange}
remoteSyncInitialServerUrl={remoteSyncInitialServerUrl}
remoteSyncReconnectRequested={remoteSyncReconnectRequested}
onRemoteSyncReconnectHandled={onRemoteSyncReconnectHandled}
/>
)}
{section === "admin" && isAdmin && (
<AdminSettingsPanel
onEditingChange={onEditingChange}
onOpenHostTab={onOpenHostTab}
/>
)}
{section.startsWith("plugin:") &&
(() => {
const plugin = pluginPages.find(
(candidate) => `plugin:${candidate.id}` === section,
);
if (!plugin) return null;
return <PluginSettingsPage plugin={plugin} isAdmin={isAdmin} />;
})()}
</Suspense>
</div>
</div>
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
/**
* Gives every feature with manifest-declared host settings its own host editor
* tab, named after the feature. A plugin that registered its own host editor
* section already edits its settings there, so it gets no generated tab.
*
* Re-run whenever the plugin list changes so tabs come and go without a reload.
*/
import { PluginIcon } from "@/lib/plugin-icon";
import {
hostEditorSectionList,
registerHostEditorSection,
unregisterHostEditorSection,
} from "@/sidebar/HostManagerTabs";
import type { PluginSummary } from "@/api/plugins-api";
import {
HostPluginSections,
type HostPluginSettings,
} from "./HostPluginSections";
import { hasVisibleFields } from "./settings-fields-util";
const TAB_PREFIX = "feature:";
export function hostFeatureTabId(pluginId: string): string {
return `${TAB_PREFIX}${pluginId}`;
}
interface HostFormLike {
pluginSettings?: HostPluginSettings;
}
/** Adds, updates and removes generated tabs to match the plugin list. */
export function syncHostFeatureTabs(plugins: PluginSummary[]): string[] {
const existing = hostEditorSectionList();
const ownSections = new Set(
existing
.filter(
(section) => section.pluginId && !section.id.startsWith(TAB_PREFIX),
)
.map((section) => section.pluginId),
);
const contributors = plugins.filter((plugin) => {
if (!plugin.enabled || ownSections.has(plugin.id)) return false;
const host = plugin.contributes?.settings?.host;
return !!host && (hasVisibleFields(host.fields) || !!host.enableKey);
});
const wanted = new Set(
contributors.map((plugin) => hostFeatureTabId(plugin.id)),
);
for (const section of existing) {
if (section.id.startsWith(TAB_PREFIX) && !wanted.has(section.id)) {
unregisterHostEditorSection(section.id);
}
}
for (const plugin of contributors) {
const host = plugin.contributes!.settings!.host!;
const icon = plugin.icon;
registerHostEditorSection({
id: hostFeatureTabId(plugin.id),
pluginId: plugin.id,
group: host.editorGroup ?? "top",
order: host.editorOrder ?? 100,
labelKey: plugin.name,
label: plugin.name,
icon: icon
? ({ className }: { className?: string }) => (
<PluginIcon name={icon} className={className} />
)
: undefined,
component: ({ form, setField }) => (
<HostPluginSections
plugins={[plugin]}
values={(form as HostFormLike)?.pluginSettings ?? {}}
setValue={(pluginId, key, value) => {
const current = (form as HostFormLike)?.pluginSettings ?? {};
setField("pluginSettings", {
...current,
[pluginId]: { ...(current[pluginId] ?? {}), [key]: value },
});
}}
/>
),
});
}
return [...wanted];
}
-73
View File
@@ -1,73 +0,0 @@
/**
* Registers the host editor's "Plugins" tab.
*
* Goes through the host editor section registry rather than a case in the
* editor, so the tab genuinely appears and disappears with the plugins that
* fill it. Nothing is registered when no enabled plugin declares
* host settings, which is what stops an empty tab from showing up.
*
* Re-run whenever the plugin list changes: enabling a plugin from the settings
* screen has to put the tab there without a reload.
*/
import { Puzzle } from "lucide-react";
import {
registerHostEditorSection,
unregisterHostEditorSection,
} from "@/sidebar/HostManagerTabs";
import type { PluginSummary } from "@/api/plugins-api";
import {
HostPluginSections,
type HostPluginSettings,
} from "./HostPluginSections";
import { hasVisibleFields } from "./settings-fields-util";
export const HOST_PLUGINS_TAB_ID = "plugins";
/** Only these two fields are read, so a test can pass a plain object. */
interface HostFormLike {
pluginSettings?: HostPluginSettings;
}
/**
* Adds or removes the tab to match the current plugin list.
*
* Returns whether the tab is now present, for a caller that needs to redirect
* away from it when the last contributor goes.
*/
export function syncHostPluginsTab(plugins: PluginSummary[]): boolean {
const contributors = plugins.filter((plugin) => {
if (!plugin.enabled) return false;
const host = plugin.contributes?.settings?.host;
return !!host && (hasVisibleFields(host.fields) || !!host.enableKey);
});
if (contributors.length === 0) {
unregisterHostEditorSection(HOST_PLUGINS_TAB_ID);
return false;
}
registerHostEditorSection({
id: HOST_PLUGINS_TAB_ID,
// In the main strip, so hosts without SSH reach it too.
group: "top",
order: 100,
labelKey: "settings.pluginsGroupLabel",
icon: Puzzle,
component: ({ form, setField }) => (
<HostPluginSections
plugins={contributors}
values={(form as HostFormLike)?.pluginSettings ?? {}}
setValue={(pluginId, key, value) => {
const current = (form as HostFormLike)?.pluginSettings ?? {};
setField("pluginSettings", {
...current,
[pluginId]: { ...(current[pluginId] ?? {}), [key]: value },
});
}}
/>
),
});
return true;
}
+3
View File
@@ -1,4 +1,5 @@
import type { ComponentType, Ref } from "react";
import type { HostDraft } from "@termix/plugin-sdk/frontend";
import type { Host, Tab } from "@/types/ui-types";
import { createRegistry } from "@/lib/registry";
import { viewOwner } from "@/plugin-host/view-ownership";
@@ -27,6 +28,8 @@ export interface TabShellCallbacks {
openRailView: (id: string) => void;
/** Closes a rail view wherever it is shown, e.g. when its feature goes away. */
closeRailView: (id: string) => void;
/** Opens the host editor for a new host with these fields filled in. */
openHostEditor?: (draft?: HostDraft) => void;
/** Saves a quick-connect tab's host. Core terminal only. */
saveQuickConnect?: (tab: Tab, host: Host) => Promise<void>;
}
-125
View File
@@ -1,125 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Puzzle, ShieldCheck } from "lucide-react";
import { toast } from "sonner";
import { SettingRow } from "@/components/section-card";
import { Button } from "@/components/button";
import {
getPlugins,
setPluginEnabled,
type PluginSummary,
} from "@/api/plugins-api";
import { syncPlugins } from "@/plugin-host/loader";
import { AccordionSection, AdminToggle } from "./AdminSettingsShared";
import { PluginPermissionsDialog } from "./PluginPermissionsDialog";
export function AdminPluginsSection({
open,
onToggle,
}: {
open: boolean;
onToggle: () => void;
}) {
const { t } = useTranslation();
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
const [busy, setBusy] = useState<string | null>(null);
const [loaded, setLoaded] = useState(false);
const [permissionsTargetId, setPermissionsTargetId] = useState<string | null>(
null,
);
const permissionsTarget =
plugins.find((plugin) => plugin.id === permissionsTargetId) ?? null;
const load = useCallback(async () => {
try {
setPlugins(await getPlugins());
} catch {
toast.error(t("admin.pluginsLoadFailed"));
} finally {
setLoaded(true);
}
}, [t]);
useEffect(() => {
if (open && !loaded) void load();
}, [open, loaded, load]);
const toggle = async (plugin: PluginSummary) => {
setBusy(plugin.id);
try {
await setPluginEnabled(plugin.id, !plugin.enabled);
await load();
// Re-read state so the shell picks the change up without a reload.
await syncPlugins();
toast.success(
plugin.enabled
? t("admin.pluginDisabled", { name: plugin.name })
: t("admin.pluginEnabled", { name: plugin.name }),
);
} catch {
toast.error(t("admin.pluginToggleFailed"));
} finally {
setBusy(null);
}
};
return (
<>
<AccordionSection
label={t("admin.plugins")}
icon={<Puzzle className="size-3.5" />}
open={open}
onToggle={onToggle}
>
{plugins.length === 0 ? (
<p className="text-xs text-muted-foreground py-3">
{loaded ? t("admin.pluginsNone") : t("common.loading")}
</p>
) : (
plugins.map((plugin) => (
<SettingRow
key={plugin.id}
label={plugin.name}
badge={
plugin.tier === "bundled" ? t("admin.pluginBuiltIn") : undefined
}
description={
plugin.lastError
? plugin.lastError
: `v${plugin.version} · ${plugin.state}`
}
>
<div className="flex items-center gap-2">
{(plugin.capabilities?.length ?? 0) > 0 && (
<Button
variant="outline"
size="sm"
className="rounded-none h-7 px-2 text-[10px] font-bold uppercase tracking-widest"
onClick={() => setPermissionsTargetId(plugin.id)}
>
<ShieldCheck className="size-3" />
{t("admin.pluginPermissions")}
</Button>
)}
<AdminToggle
on={plugin.enabled}
onToggle={() => void toggle(plugin)}
disabled={busy === plugin.id}
/>
</div>
</SettingRow>
))
)}
</AccordionSection>
<PluginPermissionsDialog
plugin={permissionsTarget}
open={permissionsTargetId !== null}
onOpenChange={(next) => {
if (!next) setPermissionsTargetId(null);
}}
onChanged={() => void load()}
/>
</>
);
}
+1 -3
View File
@@ -81,9 +81,7 @@ export function AdminSecondFactorsSection({
</span>
{!factor.available && (
<span className="text-[10px] text-destructive">
{t("admin.secondFactorUnavailable", {
plugin: factor.pluginId,
})}
{t("admin.secondFactorUnavailable")}
</span>
)}
</div>
+20 -9
View File
@@ -60,7 +60,12 @@ import {
type UserRole,
} from "@/main-axios";
import { type AdminSection, type Host } from "@/types/ui-types";
import { AdminPluginsSection } from "./AdminPluginsSection";
import {
FeatureSettingsSection,
featureSectionId,
useFeatureSettings,
type FeatureSectionId,
} from "@/settings/FeatureSettingsSections";
import {
AdminRolesSection,
AdminSessionsSection,
@@ -109,9 +114,10 @@ export function AdminSettingsPanel({
onOpenHostTab?: (host: Host) => void;
} = {}) {
const { t } = useTranslation();
const [openSections, setOpenSections] = useState<Set<AdminSection>>(
() => new Set(["general"]),
);
const [openSections, setOpenSections] = useState<
Set<AdminSection | FeatureSectionId>
>(() => new Set(["general"]));
const featureSettings = useFeatureSettings("admin");
const [manageUser, setManageUser] = useState<AdminUser | null>(null);
const [allowRegistration, setAllowRegistration] = useState(true);
const [allowPasswordLogin, setAllowPasswordLogin] = useState(true);
@@ -358,7 +364,7 @@ export function AdminSettingsPanel({
.catch(() => {});
}
function toggle(id: AdminSection) {
function toggle(id: AdminSection | FeatureSectionId) {
setOpenSections((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
@@ -970,10 +976,15 @@ export function AdminSettingsPanel({
users={users}
/>
<AdminPluginsSection
open={openSections.has("plugins")}
onToggle={() => toggle("plugins")}
/>
{featureSettings.map((plugin) => (
<FeatureSettingsSection
key={plugin.id}
plugin={plugin}
scope="admin"
open={openSections.has(featureSectionId(plugin.id))}
onToggle={() => toggle(featureSectionId(plugin.id))}
/>
))}
<AdminCreateUserDialog
open={createUserOpen}
+9 -1
View File
@@ -60,6 +60,7 @@ import type { SSHHost } from "@/types";
import { useTabsSafe } from "@/shell/TabContext";
import { updatePluginHostSettings } from "@/api/plugins-api";
import {
applyHostDraft,
buildHostEditorPayload,
createHostEditorForm,
mapSnippetResponse,
@@ -89,6 +90,7 @@ import { useSshAuthEditors } from "@/plugin-host/auth-registry";
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
import { SshAuthProviderFields } from "./SshAuthProviderFields";
import type { PluginSettingsField } from "@termix/plugin-sdk/manifest";
import type { HostDraft } from "@termix/plugin-sdk/frontend";
import { PluginComponent } from "@/plugin-host/component-registry";
import {
toCredentialOption,
@@ -99,6 +101,7 @@ const CUSTOM_FONT_OPTION = "__custom__";
export function HostEditor({
host,
draft,
activeTab,
onBack,
onSave,
@@ -113,6 +116,8 @@ export function HostEditor({
onEditCredential,
}: {
host: Host | null;
/** Fields a plugin filled in for a new host. */
draft?: HostDraft;
activeTab: string;
/** Collapses the General tab to the fields needed to reach a host. */
simpleMode?: boolean;
@@ -136,7 +141,10 @@ export function HostEditor({
const { setPreviewTerminalTheme } = useTabsSafe();
const connectionDefaults = useConnectionDefaults();
const [form, setForm] = useState(() =>
createHostEditorForm(host, undefined, connectionDefaults),
applyHostDraft(
createHostEditorForm(host, undefined, connectionDefaults),
host ? undefined : draft,
),
);
const [isCustomFont, setIsCustomFont] = useState(
() => !TERMINAL_FONTS.some((f) => f.value === form.fontFamily),
+19
View File
@@ -1,6 +1,7 @@
import { TERMINAL_THEMES } from "@/lib/terminal-themes";
import type { Host } from "@/types/ui-types";
import type { SSHHostData } from "@/types";
import type { HostDraft } from "@termix/plugin-sdk/frontend";
import type { HostDefaults } from "@/api/settings-api";
import type { TerminalDefaults } from "@/lib/connection-defaults";
import {
@@ -89,6 +90,24 @@ export function mapSnippetResponse(
}));
}
/** Overlays a plugin's draft on a new host's form. */
export function applyHostDraft(
form: HostEditorForm,
draft: HostDraft | undefined,
): HostEditorForm {
if (!draft) return form;
return {
...form,
...(draft.name !== undefined ? { name: draft.name } : {}),
...(draft.ip !== undefined ? { ip: draft.ip } : {}),
...(draft.port !== undefined ? { sshPort: draft.port } : {}),
...(draft.username !== undefined ? { username: draft.username } : {}),
...(draft.authType !== undefined
? { authType: draft.authType as HostAuthType }
: {}),
};
}
export function createHostEditorForm(
host: Host | null,
defaults?: HostDefaults,
+15 -2
View File
@@ -11,6 +11,7 @@ import React, {
type MutableRefObject,
} from "react";
import { useTranslation } from "react-i18next";
import type { HostDraft } from "@termix/plugin-sdk/frontend";
import { CredentialShareModal } from "./CredentialShareModal";
import { Button } from "@/components/button";
@@ -82,6 +83,12 @@ export function HostManager({
useHostEditorSections();
const { t } = useTranslation();
const [editingHost, setEditingHost] = useState<Host | "new" | null>(null);
// Fields a plugin filled in for a new host, keyed so a second draft remounts
// the editor.
const [hostDraft, setHostDraft] = useState<{
key: number;
draft: HostDraft;
} | null>(null);
const [shareCredential, setShareCredential] = useState<Credential | null>(
null,
);
@@ -189,6 +196,7 @@ export function HostManager({
pendingAction.current = null;
if (action === "add-host") {
setEditingHost("new");
setHostDraft(null);
setEditingCredential(null);
setEditingProtocols(hostProtocolFlags(null));
setActiveHostTab("general");
@@ -202,8 +210,10 @@ export function HostManager({
useEffect(() => {
if (!active) return;
const handleAddHost = () => {
const handleAddHost = (e: Event) => {
const draft = (e as CustomEvent<HostDraft | undefined>).detail;
setEditingHost("new");
setHostDraft(draft ? { key: Date.now(), draft } : null);
setEditingCredential(null);
setEditingProtocols(hostProtocolFlags(null));
setActiveHostTab("general");
@@ -518,9 +528,12 @@ export function HostManager({
{isHost ? (
<HostEditor
key={
editingHost === "new" ? "new-host" : (editingHost as Host).id
editingHost === "new"
? `new-host-${hostDraft?.key ?? ""}`
: (editingHost as Host).id
}
host={editingHost === "new" ? null : (editingHost as Host)}
draft={editingHost === "new" ? hostDraft?.draft : undefined}
activeTab={effectiveHostTab}
simpleMode={collapseAdvanced}
onBack={requestCloseHostEditor}
+3 -1
View File
@@ -43,6 +43,8 @@ export interface HostEditorSectionDef {
pluginId?: string;
group: "top" | "ssh";
labelKey: string;
/** Already-translated label, used instead of labelKey when set. */
label?: string;
icon?: ComponentType<{ className?: string }>;
/** Position among the group's tabs, core ones included. */
order?: number;
@@ -96,7 +98,7 @@ function mergeByOrder(
const Icon = section.icon;
return {
id: section.id,
label: t(section.labelKey),
label: section.label ?? t(section.labelKey),
icon: Icon ? <Icon className="size-3" /> : null,
order: section.order ?? 1000,
};
-158
View File
@@ -1,158 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { ShieldCheck } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import {
grantPluginCapability,
revokePluginCapability,
type PluginSummary,
} from "@/api/plugins-api";
import { AdminToggle } from "./AdminSettingsShared";
import { CAPABILITY_CATALOG } from "@termix/plugin-sdk/capabilities";
/**
* The catalog is the list. It is ordered worst first, so the riskiest thing a
* plugin asked for is the first row an admin reads rather than something they
* have to scroll for.
*/
const LABELED_PERMISSIONS = CAPABILITY_CATALOG.map((capability) => ({
capability: capability.id,
labelKey: capability.titleKey,
descriptionKey: capability.consequenceKey,
}));
export function PluginPermissionsDialog({
plugin,
open,
onOpenChange,
onChanged,
}: {
plugin: PluginSummary | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onChanged: () => void;
}) {
const { t } = useTranslation();
const [busy, setBusy] = useState<string | null>(null);
if (!plugin) return null;
const declared = plugin.capabilities ?? [];
const rows = LABELED_PERMISSIONS.filter((row) =>
declared.includes(row.capability),
);
const granted = new Set(plugin.grantedCapabilities ?? []);
const publicRoutes = [
...(plugin.publicRoutes?.http ?? []).map(
(path) => `/plugin-api/${plugin.id}${path}`,
),
...(plugin.publicRoutes?.ws ?? []).map(
(path) => `/plugin-ws/${plugin.id}${path}`,
),
];
const toggle = async (capability: string, isGranted: boolean) => {
setBusy(capability);
try {
if (isGranted) {
await revokePluginCapability(plugin.id, capability);
} else {
await grantPluginCapability(plugin.id, capability);
}
onChanged();
} catch {
toast.error(
isGranted
? t("admin.pluginRevokeFailed")
: t("admin.pluginGrantFailed"),
);
} finally {
setBusy(null);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg rounded-none border-border bg-card">
<DialogHeader>
<DialogTitle className="text-xs font-bold uppercase tracking-widest flex items-center gap-2">
<ShieldCheck className="size-4 text-accent-brand" />
{t("admin.pluginPermissions")}
</DialogTitle>
<DialogDescription className="text-[10px] font-bold tracking-tight text-muted-foreground">
{plugin.name}
</DialogDescription>
</DialogHeader>
<div className="py-3">
{rows.length === 0 ? (
<p className="text-xs text-muted-foreground py-3">
{t("admin.pluginPermissionsNone")}
</p>
) : (
<div className="border border-border overflow-hidden">
{rows.map((row, i) => {
const isGranted = granted.has(row.capability);
return (
<div
key={row.capability}
className={`flex items-center justify-between gap-3 px-3 py-3 ${
i < rows.length - 1 ? "border-b border-border" : ""
}`}
>
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<span className="text-xs font-semibold">
{t(row.labelKey)}
</span>
<span className="text-[11px] text-muted-foreground leading-snug">
{t(row.descriptionKey)}
</span>
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mt-0.5">
{isGranted
? t("admin.pluginPermissionGranted")
: t("admin.pluginPermissionNotGranted")}
</span>
</div>
<AdminToggle
on={isGranted}
onToggle={() => void toggle(row.capability, isGranted)}
disabled={busy === row.capability}
/>
</div>
);
})}
</div>
)}
</div>
{publicRoutes.length > 0 && (
<div className="pb-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1">
{t("admin.pluginPublicRoutes")}
</p>
<p className="text-[11px] text-muted-foreground leading-snug mb-2">
{t("admin.pluginPublicRoutesDescription")}
</p>
<div className="border border-border">
{publicRoutes.map((route) => (
<div
key={route}
className="px-3 py-1.5 text-[11px] font-mono break-all border-b border-border last:border-b-0"
>
{route}
</div>
))}
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}
+24 -1
View File
@@ -76,9 +76,21 @@ import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
import { Select2 } from "@/components/select2";
import { clearLocalAdaptivePreferences } from "@/lib/local-adaptive-preferences";
import { ConnectionDefaultsSettings } from "./ConnectionDefaultsSettings";
import {
FeatureSettingsSection,
featureSectionId,
useFeatureSettings,
type FeatureSectionId,
} from "@/settings/FeatureSettingsSections";
type UserProfileSection =
"account" | "interface" | "appearance" | "security" | "api-keys" | "data";
| "account"
| "interface"
| "appearance"
| "security"
| "api-keys"
| "data"
| FeatureSectionId;
const THEMES: { id: ThemeId; preview: string }[] = [
{ id: "system", preview: "auto" },
@@ -557,6 +569,7 @@ export function UserProfilePanel({
const [openSections, setOpenSections] = useState<Set<UserProfileSection>>(
() => new Set(["account"]),
);
const featureSettings = useFeatureSettings("user");
// User info
const [userId, setUserId] = useState("");
@@ -2201,6 +2214,16 @@ export function UserProfilePanel({
</div>
</AccordionSection>
{featureSettings.map((plugin) => (
<FeatureSettingsSection
key={plugin.id}
plugin={plugin}
scope="user"
open={openSections.has(featureSectionId(plugin.id))}
onToggle={() => toggle(featureSectionId(plugin.id))}
/>
))}
{/* Delete account dialog */}
<Dialog
open={showDeleteConfirm}
+13
View File
@@ -15,6 +15,16 @@ type QuickConnectInput = Pick<
const QUICK_CONNECT_ID_PREFIX = "quick-connect-";
// The auth types quickConnectHostToPayload carries over. A plugin auth type
// keeps its fields elsewhere, so saving it here would lose them.
const SAVABLE_AUTH_TYPES = new Set([
"password",
"key",
"credential",
"none",
"agent",
]);
export function isQuickConnectHost(host: Pick<Host, "id">): boolean {
return host.id.startsWith(QUICK_CONNECT_ID_PREFIX);
}
@@ -33,6 +43,7 @@ export function createQuickConnectHost(input: QuickConnectInput): Host {
...login,
port: input.port,
enableSsh: false,
quickConnectSavable: false,
domain: input.domain,
pluginSettings: {
[protocol.pluginId]: {
@@ -62,6 +73,8 @@ export function createQuickConnectHost(input: QuickConnectInput): Host {
quickActions: [],
enableSsh: true,
sshPort: input.port,
quickConnectSavable:
!input.authFields && SAVABLE_AUTH_TYPES.has(input.authType),
...input.authFields,
};
}
@@ -0,0 +1,185 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
cleanup,
fireEvent,
render,
renderHook,
screen,
waitFor,
} from "@testing-library/react";
import type { PluginSummary } from "@/api/plugins-api";
const api = vi.hoisted(() => ({
getPlugins: vi.fn(),
getPluginAdminSettings: vi.fn(),
getPluginUserSettings: vi.fn(),
updatePluginAdminSettings: vi.fn(),
updatePluginUserSettings: vi.fn(),
}));
vi.mock("@/api/plugins-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/api/plugins-api")>()),
...api,
}));
vi.mock("react-i18next", () => {
const t = (key: string, options?: { name?: string }) =>
options?.name ? `${key}:${options.name}` : key;
return { useTranslation: () => ({ t }) };
});
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import {
FeatureSettingsSection,
useFeatureSettings,
} from "@/settings/FeatureSettingsSections";
function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
return {
id: "ai",
name: "AI Assistant",
version: "1.0.0",
enabled: true,
state: "active",
contributes: {
settings: {
admin: [{ key: "on", type: "boolean", labelKey: "admin.on" }],
user: [{ key: "enabled", type: "boolean", labelKey: "user.enabled" }],
},
},
...overrides,
} as PluginSummary;
}
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(cleanup);
describe("useFeatureSettings", () => {
it("keeps enabled features with visible fields in the scope, sorted by name", async () => {
api.getPlugins.mockResolvedValue([
plugin({ id: "zeta", name: "Zeta" }),
plugin({ id: "off", name: "Off", enabled: false }),
plugin({
id: "hidden",
name: "Hidden",
contributes: {
settings: {
user: [{ key: "x", type: "boolean", labelKey: "x", hidden: true }],
},
},
}),
plugin({ id: "alpha", name: "Alpha" }),
]);
const { result } = renderHook(() => useFeatureSettings("user"));
await waitFor(() =>
expect(result.current.map((p) => p.id)).toEqual(["alpha", "zeta"]),
);
});
it("leaves out features with nothing in the scope", async () => {
api.getPlugins.mockResolvedValue([
plugin({
id: "admin-only",
contributes: {
settings: {
admin: [{ key: "a", type: "boolean", labelKey: "a" }],
},
},
}),
]);
const { result } = renderHook(() => useFeatureSettings("user"));
await waitFor(() => expect(api.getPlugins).toHaveBeenCalled());
expect(result.current).toEqual([]);
});
});
describe("FeatureSettingsSection", () => {
it("is titled with the feature name and loads nothing while closed", () => {
render(
<FeatureSettingsSection
plugin={plugin()}
scope="user"
open={false}
onToggle={vi.fn()}
/>,
);
expect(screen.getByText("AI Assistant")).toBeTruthy();
expect(api.getPluginUserSettings).not.toHaveBeenCalled();
});
it("loads and saves the scope's values once open", async () => {
api.getPluginUserSettings.mockResolvedValue({ enabled: false });
api.updatePluginUserSettings.mockResolvedValue({ enabled: true });
render(
<FeatureSettingsSection
plugin={plugin()}
scope="user"
open
onToggle={vi.fn()}
/>,
);
await screen.findByText("ai:user.enabled");
expect(api.getPluginUserSettings).toHaveBeenCalledWith("ai");
expect(api.getPluginAdminSettings).not.toHaveBeenCalled();
const save = screen.getByText("common.save").closest("button")!;
expect(save.disabled).toBe(true);
fireEvent.click(
screen
.getAllByRole("button")
.find((b) => b !== save && b.textContent === "")!,
);
fireEvent.click(save);
await waitFor(() =>
expect(api.updatePluginUserSettings).toHaveBeenCalledWith("ai", {
enabled: true,
}),
);
});
it("uses the admin endpoints for the admin scope", async () => {
api.getPluginAdminSettings.mockResolvedValue({ on: true });
render(
<FeatureSettingsSection
plugin={plugin()}
scope="admin"
open
onToggle={vi.fn()}
/>,
);
await screen.findByText("ai:admin.on");
expect(api.getPluginAdminSettings).toHaveBeenCalledWith("ai");
});
it("says when the feature is unavailable", async () => {
api.getPluginUserSettings.mockResolvedValue({});
render(
<FeatureSettingsSection
plugin={plugin({ state: "failed" })}
scope="user"
open
onToggle={vi.fn()}
/>,
);
expect(
await screen.findByText("settings.featureUnavailable:AI Assistant"),
).toBeTruthy();
});
});
@@ -1,182 +0,0 @@
/**
* The one Settings screen.
*
* What matters here is the navigation: which bands appear for whom, that a
* plugin's page shows up only when it has settings that user could change, and
* that the nav collapses rather than disappearing on a phone.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import type { PluginSummary } from "@/api/plugins-api";
const { getPluginsMock } = vi.hoisted(() => ({ getPluginsMock: vi.fn() }));
vi.mock("@/api/plugins-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/api/plugins-api")>()),
getPlugins: getPluginsMock,
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
// The two core panels are large and pull in most of the app; this screen only
// has to decide which one to mount.
vi.mock("@/sidebar/UserProfilePanel", () => ({
UserProfilePanel: () => <div>profile-panel</div>,
}));
vi.mock("@/sidebar/AdminSettingsPanel", () => ({
AdminSettingsPanel: () => <div>admin-panel</div>,
}));
const { SettingsScreen } = await import("@/settings/SettingsScreen");
function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
return {
id: "tailscale",
name: "Tailscale",
version: "1.0.0",
enabled: true,
state: "active",
contributes: {
settings: { admin: [{ key: "apiKey", type: "secret", labelKey: "k" }] },
},
...overrides,
} as PluginSummary;
}
beforeEach(() => {
getPluginsMock.mockReset();
getPluginsMock.mockResolvedValue([]);
});
afterEach(() => {
cleanup();
});
describe("navigation bands", () => {
it("always shows the You band", async () => {
render(<SettingsScreen />);
expect(await screen.findByText("settings.bandYou")).toBeTruthy();
});
it("shows the Instance band only to an admin", async () => {
const { unmount } = render(<SettingsScreen isAdmin={false} />);
await waitFor(() => expect(getPluginsMock).toHaveBeenCalled());
expect(screen.queryByText("settings.bandInstance")).toBeNull();
unmount();
render(<SettingsScreen isAdmin />);
expect(await screen.findByText("settings.bandInstance")).toBeTruthy();
});
it("shows no Plugins band when nothing declares settings", async () => {
render(<SettingsScreen isAdmin />);
await waitFor(() => expect(getPluginsMock).toHaveBeenCalled());
expect(screen.queryByText("settings.bandPlugins")).toBeNull();
});
it("lists a plugin with admin settings for an admin", async () => {
getPluginsMock.mockResolvedValue([plugin()]);
render(<SettingsScreen isAdmin />);
expect(await screen.findByText("settings.bandPlugins")).toBeTruthy();
expect(screen.getByText("Tailscale")).toBeTruthy();
});
it("hides an admin-only plugin page from a non-admin", async () => {
getPluginsMock.mockResolvedValue([plugin()]);
render(<SettingsScreen isAdmin={false} />);
await waitFor(() => expect(getPluginsMock).toHaveBeenCalled());
expect(screen.queryByText("Tailscale")).toBeNull();
});
it("shows a plugin with user settings to everyone", async () => {
getPluginsMock.mockResolvedValue([
plugin({
contributes: {
settings: { user: [{ key: "theme", type: "string", labelKey: "k" }] },
},
}),
]);
render(<SettingsScreen isAdmin={false} />);
expect(await screen.findByText("Tailscale")).toBeTruthy();
});
it("hides a disabled plugin's page", async () => {
getPluginsMock.mockResolvedValue([plugin({ enabled: false })]);
render(<SettingsScreen isAdmin />);
await waitFor(() => expect(getPluginsMock).toHaveBeenCalled());
expect(screen.queryByText("Tailscale")).toBeNull();
});
it("keeps working when the plugin list cannot be loaded", async () => {
getPluginsMock.mockRejectedValue(new Error("offline"));
render(<SettingsScreen isAdmin />);
expect(await screen.findByText("settings.bandYou")).toBeTruthy();
});
});
describe("initial section", () => {
it("lands on the profile panel by default", async () => {
render(<SettingsScreen />);
expect(await screen.findByText("profile-panel")).toBeTruthy();
});
it("lands on the admin panel when opened from the admin rail entry", async () => {
render(<SettingsScreen isAdmin initialSection="admin" />);
expect(await screen.findByText("admin-panel")).toBeTruthy();
});
it("does not mount the admin panel for a non-admin", async () => {
render(<SettingsScreen isAdmin={false} initialSection="admin" />);
await waitFor(() => expect(getPluginsMock).toHaveBeenCalled());
expect(screen.queryByText("admin-panel")).toBeNull();
});
it("switches section when the rail entry changes", async () => {
const { rerender } = render(
<SettingsScreen isAdmin initialSection="profile" />,
);
expect(await screen.findByText("profile-panel")).toBeTruthy();
rerender(<SettingsScreen isAdmin initialSection="admin" />);
expect(await screen.findByText("admin-panel")).toBeTruthy();
});
});
describe("phone layout", () => {
it("offers a disclosure button for the collapsed nav", async () => {
render(<SettingsScreen />);
await screen.findByText("settings.bandYou");
const toggle = document.querySelector("button[aria-expanded]");
expect(toggle).toBeTruthy();
expect(toggle?.getAttribute("aria-expanded")).toBe("false");
});
it("hides the nav below md and shows it from md up", async () => {
render(<SettingsScreen />);
await screen.findByText("settings.bandYou");
const nav = document.querySelector("nav")!;
expect(nav.className).toContain("hidden");
expect(nav.className).toContain("md:flex");
});
});
@@ -1,21 +1,18 @@
/**
* The host editor's Plugins group.
*
* No bundled plugin declares host settings yet, so a fixture stands in for one.
* The rule worth protecting is the empty case: with nothing to show, the tab
* must not be registered at all rather than appearing blank.
* Generated host editor tabs, one per feature with manifest host settings.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { PluginSummary } from "@/api/plugins-api";
import {
HOST_PLUGINS_TAB_ID,
syncHostPluginsTab,
} from "@/settings/host-plugins-tab";
hostFeatureTabId,
syncHostFeatureTabs,
} from "@/settings/host-feature-tabs";
import {
getHostEditorSection,
unregisterHostEditorSection,
registerHostEditorSection,
resetHostEditorSections,
} from "@/sidebar/HostManagerTabs";
import { HostPluginSections } from "@/settings/HostPluginSections";
@@ -47,18 +44,62 @@ function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
}
beforeEach(() => {
unregisterHostEditorSection(HOST_PLUGINS_TAB_ID);
resetHostEditorSections();
});
afterEach(() => {
cleanup();
unregisterHostEditorSection(HOST_PLUGINS_TAB_ID);
resetHostEditorSections();
});
describe("syncHostPluginsTab", () => {
it("registers the tab when a plugin contributes host settings", () => {
expect(syncHostPluginsTab([plugin()])).toBe(true);
expect(getHostEditorSection(HOST_PLUGINS_TAB_ID)).toBeDefined();
describe("syncHostFeatureTabs", () => {
it("registers one tab per feature, labelled with its name", () => {
const other = plugin({ id: "warpgate", name: "Warpgate" });
expect(syncHostFeatureTabs([plugin(), other])).toEqual([
"feature:docker",
"feature:warpgate",
]);
expect(getHostEditorSection(hostFeatureTabId("docker"))?.label).toBe(
"Docker",
);
expect(getHostEditorSection(hostFeatureTabId("warpgate"))).toBeDefined();
});
it("uses the manifest's editor group and order", () => {
const placed = plugin({
contributes: {
settings: {
host: {
editorGroup: "ssh",
editorOrder: 30,
enableKey: "enableDocker",
enableLabelKey: "k",
fields: [],
},
},
},
});
syncHostFeatureTabs([placed]);
const section = getHostEditorSection(hostFeatureTabId("docker"));
expect(section?.group).toBe("ssh");
expect(section?.order).toBe(30);
});
it("defaults to the main strip", () => {
syncHostFeatureTabs([plugin()]);
expect(getHostEditorSection(hostFeatureTabId("docker"))?.group).toBe("top");
});
it("skips a plugin that registered its own host editor section", () => {
registerHostEditorSection({
id: "docker-own",
pluginId: "docker",
group: "top",
labelKey: "k",
component: () => null,
});
expect(syncHostFeatureTabs([plugin()])).toEqual([]);
expect(getHostEditorSection(hostFeatureTabId("docker"))).toBeUndefined();
});
it("registers nothing for a plugin whose host fields are all hidden", () => {
@@ -73,12 +114,7 @@ describe("syncHostPluginsTab", () => {
},
},
} as Partial<PluginSummary>);
expect(syncHostPluginsTab([own])).toBe(false);
});
it("registers nothing when no plugin contributes", () => {
expect(syncHostPluginsTab([])).toBe(false);
expect(getHostEditorSection(HOST_PLUGINS_TAB_ID)).toBeUndefined();
expect(syncHostFeatureTabs([own])).toEqual([]);
});
it("ignores a plugin with no host settings", () => {
@@ -86,21 +122,20 @@ describe("syncHostPluginsTab", () => {
id: "ai",
contributes: { settings: { admin: [] } },
});
expect(syncHostPluginsTab([other])).toBe(false);
expect(syncHostFeatureTabs([other])).toEqual([]);
});
it("ignores a disabled plugin", () => {
expect(syncHostPluginsTab([plugin({ enabled: false })])).toBe(false);
expect(syncHostFeatureTabs([plugin({ enabled: false })])).toEqual([]);
});
it("removes the tab once the last contributor goes", () => {
syncHostPluginsTab([plugin()]);
expect(getHostEditorSection(HOST_PLUGINS_TAB_ID)).toBeDefined();
it("removes a tab once its feature goes", () => {
syncHostFeatureTabs([plugin()]);
expect(getHostEditorSection(hostFeatureTabId("docker"))).toBeDefined();
syncHostPluginsTab([plugin({ enabled: false })]);
syncHostFeatureTabs([plugin({ enabled: false })]);
expect(getHostEditorSection(HOST_PLUGINS_TAB_ID)).toBeUndefined();
expect(getHostEditorSection(hostFeatureTabId("docker"))).toBeUndefined();
});
it("registers for a plugin declaring only an enable switch", () => {
@@ -111,8 +146,7 @@ describe("syncHostPluginsTab", () => {
},
},
});
expect(syncHostPluginsTab([bare])).toBe(true);
expect(syncHostFeatureTabs([bare])).toEqual(["feature:docker"]);
});
});
@@ -184,7 +218,7 @@ describe("HostPluginSections", () => {
expect(setValue).toHaveBeenCalledWith("docker", "enableDocker", true);
});
it("warns when the plugin is installed but not running", () => {
it("warns when the feature is unavailable", () => {
render(
<HostPluginSections
plugins={[plugin({ state: "failed" })]}
@@ -193,7 +227,7 @@ describe("HostPluginSections", () => {
/>,
);
expect(screen.getByText("settings.hostPluginNotRunning")).toBeTruthy();
expect(screen.getByText("settings.featureUnavailable")).toBeTruthy();
});
it("renders nothing at all with no contributing plugins", () => {
@@ -44,7 +44,7 @@ describe("AdminSecondFactorsSection", () => {
/>,
);
expect(
await screen.findByText("admin.secondFactorUnavailable:yubi"),
await screen.findByText("admin.secondFactorUnavailable"),
).toBeTruthy();
// A plugin's label resolves in its own namespace.
expect(screen.getByText("totp:factor")).toBeTruthy();
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import { registerHostProtocol } from "../../sidebar/host-protocols";
import {
applyHostDraft,
createHostEditorForm,
buildHostEditorPayload,
omitOwnerSshAuthFromSharedEdit,
@@ -559,3 +560,27 @@ describe("plugin protocols", () => {
expect(payload.enableSsh).toBe(false);
});
});
describe("applyHostDraft", () => {
it("fills a new host's form from a plugin draft", () => {
const form = applyHostDraft(createHostEditorForm(null), {
name: "box",
ip: "100.64.0.1",
port: 2222,
username: "luke",
authType: "tailscale",
});
expect(form).toMatchObject({
name: "box",
ip: "100.64.0.1",
sshPort: 2222,
username: "luke",
authType: "tailscale",
});
});
it("leaves the form alone without a draft", () => {
const form = createHostEditorForm(null);
expect(applyHostDraft(form, undefined)).toBe(form);
});
});
@@ -112,4 +112,23 @@ describe("createQuickConnectHost for remote desktop protocols", () => {
pluginSettings: { demo: { profileId: 3 } },
});
});
it("marks only hosts core can save", () => {
const base = { ip: "10.0.0.2", port: 22, username: "root" };
expect(
createQuickConnectHost({ ...base, authType: "password" })
.quickConnectSavable,
).toBe(true);
expect(
createQuickConnectHost({ ...base, authType: "tailscale" })
.quickConnectSavable,
).toBe(false);
expect(
createQuickConnectHost({
...base,
authType: "password",
authFields: { extra: true },
}).quickConnectSavable,
).toBe(false);
});
});
+3
View File
@@ -66,6 +66,9 @@ export default defineConfig({
name: "frontend",
environment: "jsdom",
include: ["src/ui/**/*.test.{ts,tsx}"],
// The CLI bundle test builds under node_modules; inline it so its
// SDK imports hit the aliases instead of a second copy of the SDK.
server: { deps: { inline: [/cli-bundle-test/] } },
},
},
{