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 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 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 "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 a running plugin's public HTTP paths and public socket paths as
plugin dialog (`publicRoutes` on `GET /plugins`), and audits a public `publicRoutes` on `GET /plugins` (admins only), and audits a public
socket when it is registered. It matches the socket when it is registered. It matches the
full path, with `:param` segments allowed, so declaring `/webhook/:token` full path, with `:param` segments allowed, so declaring `/webhook/:token`
public cannot open `/webhook/:token/anything`. A trailing `/*` is the one 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, `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 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 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 and Telnet host editor tabs.
Plugins tab.
**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 `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 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: **D0** added three pieces:
- `onValidate(scope, validator)` checks a save from the settings screen or the - `onValidate(scope, validator)` checks a save from the profile or admin
host editor before anything is written. The validator gets every value in 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 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, 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. 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 shared session") and replaces `ShellApi.openTerminalTab`, which the shell no
longer has. 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 **Components by id.** `app.registerComponent(id, component)` offers a
component to other plugins and core, which render it with `PluginComponent` component to other plugins and core, which render it with `PluginComponent`
from `@termix/plugin-sdk/ui` (a fallback while the owner is off) or 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 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 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")`. 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. 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 - 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 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 in it. Its public routes are `/plugin-api/sso/start` (sends the browser to
the provider), `/callback` (GET and POST), `/backchannel-logout` and the provider), `/callback` (GET and POST), `/backchannel-logout` and
`/config`; provider management is `/plugin-api/sso/providers`, gated on `/config`; provider management is `/plugin-api/sso/providers`, gated on
`sso.manage`, and renders as the custom `providers` field on the plugin's `sso.manage`, and renders as the custom `providers` field in the plugin's
admin settings page. Login state (nonce, PKCE verifier, return address) Admin Settings section. Login state (nonce, PKCE verifier, return address)
lives in `ctx.kv` for ten minutes. lives in `ctx.kv` for ten minutes.
- The redirect URI is `<base>/plugin-api/sso/callback`. Providers that - The redirect URI is `<base>/plugin-api/sso/callback`. Providers that
existed before 2.9 keep sending `<base>/users/oidc/callback`, the URI their existed before 2.9 keep sending `<base>/users/oidc/callback`, the URI their
@@ -1937,6 +1958,8 @@ not do.
}, },
], ],
"host": { "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 "enableKey": "enableExample", // boolean rendered first, gating the rest
"enableLabelKey": "k", "enableLabelKey": "k",
"fields": [ "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`, `system:tls`, `ssh:connect`, `process:spawn`, `users:write`,
`auth:provide`, `device:serial`) means the plugin can reach your hosts, `auth:provide`, `device:serial`) means the plugin can reach your hosts,
your users or the server itself. Ask why it needs each one. your users or the server itself. Ask why it needs each one.
- **Read the public routes.** Admin > Plugins > Permissions lists every path - **Read the public routes.** `publicRoutes` on `GET /plugins` lists every
it serves without login. Each one is attack surface on your server. 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 - **Check its dependencies.** A plugin's npm dependencies run with the same
reach as the plugin. reach as the plugin.
- **Watch the audit log after installing.** Filter on `plugin:<id>` and look - **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); const frontendEntry = resolveEntry(cwd, FRONTEND_ENTRIES);
if (frontendEntry) { if (frontendEntry) {
await esbuild.build({ await esbuild.build({
@@ -793,6 +793,14 @@
"enableDefault": { "enableDefault": {
"type": "boolean", "type": "boolean",
"description": "What the enable switch reads before a host saves it. Off by default." "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. */ /** 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 { export interface ShellApi {
openTab: ( openTab: (
host: PluginHostRecord | null, host: PluginHostRecord | null,
@@ -88,6 +97,11 @@ export interface ShellApi {
tab: PluginTabRecord, tab: PluginTabRecord,
host: PluginHostRecord, host: PluginHostRecord,
) => Promise<void>; ) => 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. */ /** Opens a rail view in the left sidebar. */
openRailView: (id: string) => void; openRailView: (id: string) => void;
/** Closes a rail view wherever it is shown. */ /** Closes a rail view wherever it is shown. */
+26 -1
View File
@@ -271,6 +271,10 @@ export interface PluginHostSettingsContribution {
enableLabelKey?: string; enableLabelKey?: string;
/** What the enable switch reads before a host saves it. Off by default. */ /** What the enable switch reads before a host saves it. Off by default. */
enableDefault?: boolean; 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[]; fields: PluginSettingsField[];
} }
@@ -1086,7 +1090,14 @@ function validateSettings(settings: unknown, errors: string[]): void {
} }
rejectUnknown( rejectUnknown(
host, host,
["enableKey", "enableLabelKey", "enableDefault", "fields"], [
"enableKey",
"enableLabelKey",
"enableDefault",
"editorGroup",
"editorOrder",
"fields",
],
`"${at}"`, `"${at}"`,
errors, errors,
); );
@@ -1104,6 +1115,20 @@ function validateSettings(settings: unknown, errors: string[]): void {
if ("enableDefault" in host && typeof host.enableDefault !== "boolean") { if ("enableDefault" in host && typeof host.enableDefault !== "boolean") {
errors.push(`${at}.enableDefault must be a 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)) { if (!Array.isArray(host.fields)) {
errors.push(`${at}.fields must be an array`); 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." "description": "Let the assistant carry out the changes it suggests."
}, },
"services.use": { "services.use": {
"title": "Use AI from other plugins", "title": "Use AI from other features",
"description": "Other plugins may call the assistant on your behalf." "description": "Other features may call the assistant on your behalf."
}, },
"secrets.share": { "secrets.share": {
"title": "Share the API key", "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": { "settings": {
@@ -98,7 +98,7 @@
"runInTerminal": "Run", "runInTerminal": "Run",
"terminalContextPrefill": "Here is my terminal output:\n\n{{output}}\n\n", "terminalContextPrefill": "Here is my terminal output:\n\n{{output}}\n\n",
"secrets": { "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": { "nav": {
+2
View File
@@ -173,6 +173,8 @@
} }
], ],
"host": { "host": {
"editorGroup": "ssh",
"editorOrder": 90,
"fields": [ "fields": [
{ {
"key": "enableAiAssistant", "key": "enableAiAssistant",
+1 -1
View File
@@ -159,7 +159,7 @@
"continue": "Carry on" "continue": "Carry on"
}, },
"needsPlugin": "Needs {{plugin}}", "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", "offBadge": "Off",
"docsLink": "View docs", "docsLink": "View docs",
"ago": { "ago": {
+2
View File
@@ -53,6 +53,8 @@
], ],
"settings": { "settings": {
"host": { "host": {
"editorGroup": "ssh",
"editorOrder": 30,
"enableKey": "enableDocker", "enableKey": "enableDocker",
"enableLabelKey": "settings.host.enable.label", "enableLabelKey": "settings.host.enable.label",
"fields": [ "fields": [
+2
View File
@@ -71,6 +71,8 @@
], ],
"settings": { "settings": {
"host": { "host": {
"editorGroup": "ssh",
"editorOrder": 60,
"enableKey": "enableFileManager", "enableKey": "enableFileManager",
"enableLabelKey": "settings.host.enable.label", "enableLabelKey": "settings.host.enable.label",
"enableDefault": true, "enableDefault": true,
+1 -1
View File
@@ -183,7 +183,7 @@
"wgHandshakeNever": "Never", "wgHandshakeNever": "Never",
"wgTransfer": "Transfer" "wgTransfer": "Transfer"
}, },
"snippetsUnavailable": "The Snippets plugin is off, so quick actions cannot run." "snippetsUnavailable": "Snippets are turned off, so quick actions cannot run."
}, },
"metricsHistory": { "metricsHistory": {
"title": "Metrics History", "title": "Metrics History",
@@ -32,7 +32,7 @@ export function RedirectUriSetting({ values }: SettingsComponentProps) {
<Input <Input
value={uri} value={uri}
readOnly readOnly
className="rounded-none font-mono text-xs" className="min-w-0 flex-1 rounded-none font-mono text-xs"
/> />
<Button <Button
type="button" type="button"
+1
View File
@@ -81,6 +81,7 @@
"nativeRdpFailed": "Failed to open Windows Remote Desktop", "nativeRdpFailed": "Failed to open Windows Remote Desktop",
"openNativeRdp": "Open in Windows Remote Desktop", "openNativeRdp": "Open in Windows Remote Desktop",
"guac": { "guac": {
"toolbar": "Toolbar",
"connection": "Connection", "connection": "Connection",
"authentication": "Authentication", "authentication": "Authentication",
"storedCredential": "Stored Credential", "storedCredential": "Stored Credential",
+2 -1
View File
@@ -427,7 +427,8 @@
"type": "boolean", "type": "boolean",
"labelKey": "settings.host.enableToolbar.label", "labelKey": "settings.host.enableToolbar.label",
"descriptionKey": "settings.host.enableToolbar.description", "descriptionKey": "settings.host.enableToolbar.description",
"default": true "default": true,
"hidden": true
} }
] ]
} }
+32 -1
View File
@@ -14,7 +14,12 @@ import {
type TabProps, type TabProps,
type TermixApp, type TermixApp,
} from "@termix/plugin-sdk/frontend"; } 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 { toast } from "sonner";
import GuacamoleApp, { type GuacamoleAppHandle } from "./GuacamoleApp"; import GuacamoleApp, { type GuacamoleAppHandle } from "./GuacamoleApp";
import { GuacamoleDisplay } from "./GuacamoleDisplay"; 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) { function RdpSection(props: HostEditorSectionProps) {
const { form, setField, setGuacField } = remoteDesktopForm(props); const { form, setField, setGuacField } = remoteDesktopForm(props);
return ( return (
@@ -157,6 +185,7 @@ function RdpSection(props: HostEditorSectionProps) {
host={props.host as { macAddress?: string | null } | undefined} host={props.host as { macAddress?: string | null } | undefined}
credentials={props.credentials as never} credentials={props.credentials as never}
/> />
<ToolbarCard form={form} setField={setField} />
<SectionNotes protocol="rdp" /> <SectionNotes protocol="rdp" />
</> </>
); );
@@ -173,6 +202,7 @@ function VncSection(props: HostEditorSectionProps) {
host={props.host as { macAddress?: string | null } | undefined} host={props.host as { macAddress?: string | null } | undefined}
credentials={props.credentials as never} credentials={props.credentials as never}
/> />
<ToolbarCard form={form} setField={setField} />
<SectionNotes protocol="vnc" /> <SectionNotes protocol="vnc" />
</> </>
); );
@@ -188,6 +218,7 @@ function TelnetSection(props: HostEditorSectionProps) {
setGuacField={setGuacField} setGuacField={setGuacField}
credentials={props.credentials as never} credentials={props.credentials as never}
/> />
<ToolbarCard form={form} setField={setField} />
<SectionNotes protocol="telnet" /> <SectionNotes protocol="telnet" />
</> </>
); );
@@ -16,6 +16,7 @@ export interface RemoteDesktopForm {
vncPort: number; vncPort: number;
telnetPort: number; telnetPort: number;
guacamoleConfig: Record<string, any>; guacamoleConfig: Record<string, any>;
enableToolbar: boolean;
rdpAuthType: "direct" | "credential" | "none"; rdpAuthType: "direct" | "credential" | "none";
rdpCredentialId: string; rdpCredentialId: string;
rdpUser: string; rdpUser: string;
@@ -42,6 +43,7 @@ const SETTING_FOR_FIELD: Partial<Record<keyof RemoteDesktopForm, string>> = {
telnetPort: "telnetPort", telnetPort: "telnetPort",
security: "rdpSecurity", security: "rdpSecurity",
ignoreCert: "rdpIgnoreCert", ignoreCert: "rdpIgnoreCert",
enableToolbar: "enableToolbar",
}; };
type PluginSettingsBag = Record<string, Record<string, unknown>>; type PluginSettingsBag = Record<string, Record<string, unknown>>;
@@ -72,6 +74,7 @@ export function remoteDesktopForm(props: HostEditorSectionProps): {
security: options.rdpSecurity, security: options.rdpSecurity,
ignoreCert: options.rdpIgnoreCert, ignoreCert: options.rdpIgnoreCert,
guacamoleConfig: options.guacamoleConfig as Record<string, any>, guacamoleConfig: options.guacamoleConfig as Record<string, any>,
enableToolbar: options.enableToolbar,
}; };
const setField: RemoteFormSetField = (key, value) => { const setField: RemoteFormSetField = (key, value) => {
+2
View File
@@ -59,6 +59,8 @@
} }
], ],
"host": { "host": {
"editorGroup": "ssh",
"editorOrder": 85,
"enableKey": "enableSessionRecording", "enableKey": "enableSessionRecording",
"enableLabelKey": "settings.host.enable.label", "enableLabelKey": "settings.host.enable.label",
"fields": [] "fields": []
+2
View File
@@ -84,6 +84,8 @@
} }
], ],
"host": { "host": {
"editorGroup": "ssh",
"editorOrder": 80,
"fields": [ "fields": [
{ {
"key": "allowSessionSharing", "key": "allowSessionSharing",
+1 -1
View File
@@ -105,7 +105,7 @@
"clearAll": "Clear All", "clearAll": "Clear All",
"noHistoryEntries": "No history entries", "noHistoryEntries": "No history entries",
"trackingDisabled": "History tracking is disabled", "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_one": "{{count}} command",
"commandCount_other": "{{count}} commands" "commandCount_other": "{{count}} commands"
}, },
+2
View File
@@ -216,6 +216,8 @@
} }
], ],
"host": { "host": {
"editorGroup": "ssh",
"editorOrder": 12,
"enableKey": "enableTerminal", "enableKey": "enableTerminal",
"enableLabelKey": "settings.host.enableTerminal.label", "enableLabelKey": "settings.host.enableTerminal.label",
"enableDefault": true, "enableDefault": true,
@@ -109,7 +109,7 @@ export function TouchInputSettings({
{t("touchInput.advanced")} {t("touchInput.advanced")}
</button> </button>
{advancedOpen && ( {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 }) => { {fields.map(({ key, label, unit }) => {
const bounds = TOUCH_INPUT_NUMERIC_BOUNDS[key]; const bounds = TOUCH_INPUT_NUMERIC_BOUNDS[key];
return ( return (
@@ -114,7 +114,7 @@ export function TerminalTabContent({
} }
isQuickConnect={String(host.id).startsWith("quick-connect-")} isQuickConnect={String(host.id).startsWith("quick-connect-")}
onSaveQuickConnect={ onSaveQuickConnect={
shell.saveQuickConnect shell.saveQuickConnect && hostRecord?.quickConnectSavable
? () => shell.saveQuickConnect!(tabRecord, hostRecord!) ? () => shell.saveQuickConnect!(tabRecord, hostRecord!)
: undefined : undefined
} }
+1 -1
View File
@@ -4,7 +4,7 @@
}, },
"editor": { "editor": {
"label": "Step CA", "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" "docs": "View docs"
}, },
"dialog": { "dialog": {
@@ -32,7 +32,7 @@ export function RedirectUriSetting({ values }: SettingsComponentProps) {
<Input <Input
value={uri} value={uri}
readOnly readOnly
className="rounded-none font-mono text-xs" className="min-w-0 flex-1 rounded-none font-mono text-xs"
/> />
<Button <Button
type="button" type="button"
+5 -1
View File
@@ -42,7 +42,11 @@
"tailscaleLoadingDevices": "Loading devices...", "tailscaleLoadingDevices": "Loading devices...",
"tailscaleDeviceLoadFailed": "Failed to load Tailscale devices. Check the API URL and network connection.", "tailscaleDeviceLoadFailed": "Failed to load Tailscale devices. Check the API URL and network connection.",
"tailscaleNoDevices": "No devices found in your tailnet.", "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": { "terminal": {
"tailscaleCheckRequired": "Tailscale Authentication Required", "tailscaleCheckRequired": "Tailscale Authentication Required",
+1 -1
View File
@@ -76,7 +76,7 @@ export function startTailscaleService(
if (!context) { if (!context) {
return res return res
.status(503) .status(503)
.json({ error: "Plugin is not running", devices: [] }); .json({ error: "Tailscale is not available", devices: [] });
} }
const apiKey = (await context.settings.get<string>("apiKey")) ?? ""; const apiKey = (await context.settings.get<string>("apiKey")) ?? "";
if (!apiKey) { if (!apiKey) {
@@ -1,6 +1,10 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend"; import {
import { Loader2, RefreshCw, Terminal } from "lucide-react"; 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 { Button } from "@termix/plugin-sdk/ui";
import { Input } from "@termix/plugin-sdk/ui"; import { Input } from "@termix/plugin-sdk/ui";
import type { PluginHostRecord as Host } from "@termix/plugin-sdk/frontend"; import type { PluginHostRecord as Host } from "@termix/plugin-sdk/frontend";
@@ -18,6 +22,8 @@ interface TailscaleDevice {
interface TailscaleDevicesPanelProps { interface TailscaleDevicesPanelProps {
onConnect: (host: Host, type: "terminal") => void; onConnect: (host: Host, type: "terminal") => void;
/** Absent when the shell cannot open the host editor. */
onAddHost?: (draft: HostDraft) => void;
} }
function deviceIp(device: TailscaleDevice): string { function deviceIp(device: TailscaleDevice): string {
@@ -30,8 +36,10 @@ function deviceIp(device: TailscaleDevice): string {
export function TailscaleDevicesPanel({ export function TailscaleDevicesPanel({
onConnect, onConnect,
onAddHost,
}: TailscaleDevicesPanelProps) { }: TailscaleDevicesPanelProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const toast = useToast();
const [devices, setDevices] = useState<TailscaleDevice[]>([]); const [devices, setDevices] = useState<TailscaleDevice[]>([]);
const [hasApiKey, setHasApiKey] = useState(true); const [hasApiKey, setHasApiKey] = useState(true);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -52,10 +60,33 @@ export function TailscaleDevicesPanel({
useEffect(load, []); 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) { function connect(device: TailscaleDevice) {
const ip = deviceIp(device); const ip = deviceIp(device);
if (!ip) return; if (!ip) return;
const username = usernames[device.id]?.trim() || "root"; const username = usernameFor(device);
const host = createQuickConnectHost({ const host = createQuickConnectHost({
ip, ip,
port: 22, port: 22,
@@ -118,14 +149,33 @@ export function TailscaleDevicesPanel({
key={device.id} key={device.id}
className="flex flex-col gap-1.5 border border-border p-2" className="flex flex-col gap-1.5 border border-border p-2"
> >
<div className="flex flex-col gap-0.5"> <div className="flex items-start justify-between gap-2">
<span className="text-xs font-semibold"> <span className="text-xs font-semibold min-w-0 break-all">
{device.hostname || device.name} {device.hostname || device.name}
</span> </span>
<span className="text-[10px] text-muted-foreground"> {device.os && (
{ip} {device.os ? `· ${device.os}` : ""} <span className="shrink-0 border border-border px-1 text-[10px] text-muted-foreground">
</span> {device.os}
</span>
)}
</div> </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 <Input
placeholder={t("newUi.sidebar.quickConnect.usernameLabel")} placeholder={t("newUi.sidebar.quickConnect.usernameLabel")}
value={usernames[device.id] ?? "root"} value={usernames[device.id] ?? "root"}
@@ -140,15 +190,28 @@ export function TailscaleDevicesPanel({
}} }}
className="h-7 text-xs" className="h-7 text-xs"
/> />
<Button <div className="flex gap-1.5">
onClick={() => connect(device)} <Button
disabled={!ip} onClick={() => connect(device)}
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" disabled={!ip}
variant="outline" 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")} <Terminal className="size-3.5" />
</Button> {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> </div>
); );
})} })}
@@ -52,7 +52,7 @@ export function TailscaleDevicesStatus({ running }: SettingsComponentProps) {
const label = (key: string) => t(key); const label = (key: string) => t(key);
return ( 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"> <div className="flex flex-col gap-0.5 min-w-0 flex-1">
<span className="text-sm font-medium leading-snug"> <span className="text-sm font-medium leading-snug">
{label("settings.devices.label")} {label("settings.devices.label")}
+5
View File
@@ -18,6 +18,11 @@ function Panel({ shell }: PanelProps) {
type, type,
) )
} }
onAddHost={
shell.openHostEditor
? (draft) => shell.openHostEditor!(draft)
: undefined
}
/> />
); );
} }
@@ -74,4 +74,75 @@ describe("TailscaleDevicesPanel", () => {
expect(host.authType).toBe("tailscale"); expect(host.authType).toBe("tailscale");
expect(host.username).toBe("root"); 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 <Input
value={uri} value={uri}
readOnly readOnly
className="rounded-none font-mono text-xs" className="min-w-0 flex-1 rounded-none font-mono text-xs"
/> />
<Button <Button
type="button" type="button"
+2
View File
@@ -38,6 +38,8 @@
], ],
"settings": { "settings": {
"host": { "host": {
"editorGroup": "top",
"editorOrder": 90,
"fields": [ "fields": [
{ {
"key": "macAddress", "key": "macAddress",
+2
View File
@@ -21,6 +21,8 @@
}, },
"settings": { "settings": {
"host": { "host": {
"editorGroup": "ssh",
"editorOrder": 95,
"fields": [ "fields": [
{ {
"key": "useWarpgate", "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. */ /** 503 for an installed plugin that is off, 404 for an unknown id. */
function notServing(res: Response, pluginId: string): void { function notServing(res: Response, pluginId: string): void {
if (isPluginInstalled(pluginId)) { 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; return;
} }
databaseLogger.warn("Plugin API request for unregistered plugin", { databaseLogger.warn("Plugin API request for unregistered plugin", {
operation: "plugin_api_dispatch", operation: "plugin_api_dispatch",
pluginId, 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(); next();
return; 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 // 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 { describe, expect, it, vi } from "vitest";
import { createServer } from "http";
import type { AddressInfo } from "net";
import type { LookupAddress, LookupOptions } from "dns"; import type { LookupAddress, LookupOptions } from "dns";
import { import {
createDnsLookupHook, createDnsLookupHook,
isBlockedAddress, isBlockedAddress,
readResponseTextLimited, readResponseTextLimited,
safeOutboundFetch,
} from "../../utils/safe-outbound-fetch.js"; } from "../../utils/safe-outbound-fetch.js";
describe("isBlockedAddress", () => { 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 { try {
return await undiciFetch(url.toString(), { const response = await undiciFetch(url.toString(), {
...options, ...options,
dispatcher, dispatcher,
redirect: "error", redirect: "error",
}); });
} finally { // close() waits for the body, which the caller reads after we return.
await dispatcher.close(); 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; keyType?: string;
notes?: string; notes?: string;
pin?: boolean; pin?: boolean;
/** Quick connect only: core can save this host as-is. */
quickConnectSavable?: boolean;
sortOrder?: number | null; sortOrder?: number | null;
/** Stable identity across a desktop/server sync pair. */ /** Stable identity across a desktop/server sync pair. */
@@ -217,14 +219,11 @@ export type AdminSection =
| "sessions" | "sessions"
| "roles" | "roles"
| "host-defaults" | "host-defaults"
| "image-storage"
| "branding" | "branding"
| "database" | "database"
| "api-keys" | "api-keys"
| "audit-log" | "audit-log"
| "ssl" | "ssl";
| "plugins"
| "touch-input";
export type ThemeId = export type ThemeId =
| "dark" | "dark"
| "light" | "light"
+29 -13
View File
@@ -86,11 +86,14 @@ const MacrosPanel = lazy(() =>
import("@/sidebar/MacrosPanel").then((m) => ({ default: m.MacrosPanel })), import("@/sidebar/MacrosPanel").then((m) => ({ default: m.MacrosPanel })),
); );
// One screen behind both rail entries. It lazy-loads the profile and admin const UserProfilePanel = lazy(() =>
// panels itself, so the shell no longer imports either directly. import("@/sidebar/UserProfilePanel").then((m) => ({
const SettingsScreen = lazy(() => default: m.UserProfilePanel,
import("@/settings/SettingsScreen").then((m) => ({ })),
default: m.SettingsScreen, );
const AdminSettingsPanel = lazy(() =>
import("@/sidebar/AdminSettingsPanel").then((m) => ({
default: m.AdminSettingsPanel,
})), })),
); );
const CredentialsPanel = lazy(() => const CredentialsPanel = lazy(() =>
@@ -2269,6 +2272,17 @@ export function AppShell({
setRailView((prev) => (prev === id ? "hosts" : prev)); setRailView((prev) => (prev === id ? "hosts" : prev));
setRightRailView((prev) => (prev === id ? null : 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, saveQuickConnect: saveQuickConnectHost,
}; };
@@ -2480,14 +2494,9 @@ export function AppShell({
</div> </div>
)} )}
{(railView === "user-profile" || {railView === "user-profile" && (
(railView === "admin-settings" && showAdminUI)) && ( <div className="flex-1 min-h-0 overflow-y-auto">
<div className="flex flex-col flex-1 min-h-0"> <UserProfilePanel
<SettingsScreen
initialSection={
railView === "admin-settings" ? "admin" : "profile"
}
isAdmin={showAdminUI}
username={username} username={username}
onLogout={onLogout} onLogout={onLogout}
userPrefs={userPrefs} userPrefs={userPrefs}
@@ -2499,6 +2508,13 @@ export function AppShell({
onRemoteSyncReconnectHandled={() => onRemoteSyncReconnectHandled={() =>
setRemoteSyncReconnectRequested(false) setRemoteSyncReconnectRequested(false)
} }
/>
</div>
)}
{railView === "admin-settings" && showAdminUI && (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<AdminSettingsPanel
onEditingChange={setSidebarEditing} onEditingChange={setSidebarEditing}
onOpenHostTab={(host) => { onOpenHostTab={(host) => {
connectHost(host); connectHost(host);
+2 -27
View File
@@ -42,6 +42,8 @@ export interface PluginHostSettingsContribution {
enableKey?: string; enableKey?: string;
enableLabelKey?: string; enableLabelKey?: string;
enableDefault?: boolean; enableDefault?: boolean;
editorGroup?: "top" | "ssh";
editorOrder?: number;
fields: PluginSettingsField[]; fields: PluginSettingsField[];
} }
@@ -246,30 +248,3 @@ export async function getPlugins(): Promise<PluginSummary[]> {
const response = await rbacApi.get("/plugins"); const response = await rbacApi.get("/plugins");
return Array.isArray(response.data) ? response.data : []; 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", "filterAuthAgent": "SSH Agent",
"filterAuthVault": "Vault", "filterAuthVault": "Vault",
"filterAuthTailscale": "Tailscale", "filterAuthTailscale": "Tailscale",
"authTypeNeedsPlugin": "This host uses {{type}}, which needs the {{plugin}} plugin. Turn it on to connect.", "authTypeNeedsPlugin": "This host uses {{type}}, which needs {{plugin}}. It is turned off on this server.",
"authTypeUnknown": "This host uses {{type}}, which no enabled plugin provides.", "authTypeUnknown": "This host uses {{type}}, which is not available on this server.",
"authFieldsAfterSave": "Save the host first, then fill in these fields.", "authFieldsAfterSave": "Save the host first, then fill in these fields.",
"enablePluginFeature": "Enable {{name}}", "enablePluginFeature": "Enable {{name}}",
"disablePluginFeature": "Disable {{name}}", "disablePluginFeature": "Disable {{name}}",
@@ -1099,7 +1099,7 @@
"navigate": "Navigate", "navigate": "Navigate",
"select": "Select", "select": "Select",
"toggleWith": "Toggle with", "toggleWith": "Toggle with",
"pluginActions": "From plugins" "pluginActions": "More actions"
}, },
"splitScreen": { "splitScreen": {
"paneEmpty": "Pane {{index}} - empty", "paneEmpty": "Pane {{index}} - empty",
@@ -1527,7 +1527,7 @@
"brandingLogoInvalid": "Invalid image. Use a PNG, JPEG or WEBP file up to 750 KB.", "brandingLogoInvalid": "Invalid image. Use a PNG, JPEG or WEBP file up to 750 KB.",
"brandingSaved": "Branding settings saved", "brandingSaved": "Branding settings saved",
"brandingSaveFailed": "Failed to save branding settings", "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", "sslDocsLink": "View SSL docs",
"sslCertStatus": "Certificate Status", "sslCertStatus": "Certificate Status",
"sslCertStatusNone": "No certificate", "sslCertStatusNone": "No certificate",
@@ -1811,7 +1811,7 @@
"saveError": "Failed to save role permissions", "saveError": "Failed to save role permissions",
"save": "Save", "save": "Save",
"saving": "Saving...", "saving": "Saving...",
"pluginDisabled": "Plugin disabled", "pluginDisabled": "Feature disabled",
"groups": { "groups": {
"hosts": "Hosts", "hosts": "Hosts",
"credentials": "Credentials", "credentials": "Credentials",
@@ -1821,30 +1821,15 @@
"notificationPrivateEndpoints": "Allowed private notification hosts", "notificationPrivateEndpoints": "Allowed private notification hosts",
"notificationPrivateEndpointsDesc": "Exact private hosts that notification channels may contact. Separate them with commas.", "notificationPrivateEndpointsDesc": "Exact private hosts that notification channels may contact. Separate them with commas.",
"updateNotificationEndpointsFailed": "Failed to update notification endpoint allowlist", "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", "secondFactorsSectionTitle": "Second factors",
"secondFactorsNone": "No second factors enrolled", "secondFactorsNone": "No second factors enrolled",
"secondFactorUnavailable": "The {{plugin}} plugin is off", "secondFactorUnavailable": "Unavailable",
"resetSecondFactors": "Reset second factors", "resetSecondFactors": "Reset second factors",
"resetSecondFactorsConfirm": "Remove every second factor for {{username}}? They can sign in with their password or SSO and enrol again.", "resetSecondFactorsConfirm": "Remove every second factor for {{username}}? They can sign in with their password or SSO and enrol again.",
"secondFactorsResetSuccess": "Second factors reset", "secondFactorsResetSuccess": "Second factors reset",
"secondFactorsResetFailed": "Failed to reset second factors", "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.", "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.", "sslSelfSigned": "Self-signed certificate made by Termix. Browsers will warn about it.",
"sslRenewedBy": "Renewed automatically by {{plugin}}", "sslRenewedBy": "Renewed automatically by {{plugin}}",
"sslIssuer": "Issued by {{issuer}}", "sslIssuer": "Issued by {{issuer}}",
@@ -2506,36 +2491,26 @@
} }
}, },
"runtime": { "runtime": {
"needsPlugin": "This needs the {{name}} plugin", "needsPlugin": "{{name}} is not available",
"needsUnknownPlugin": "This needs a plugin that is not installed", "needsUnknownPlugin": "This feature is not available",
"disabledHint": "An admin can turn it on in Settings under Plugins.", "disabledHint": "It is turned off on this server.",
"missingHint": "It was added by a plugin that has since been removed.", "missingHint": "The feature it came from has been removed.",
"failed": "The {{name}} plugin could not start", "failed": "{{name}} could not start",
"failedHint": "An admin can see why in Settings under Plugins.", "failedHint": "An admin can check the server logs for details.",
"loading": "Loading {{name}}", "loading": "Loading {{name}}",
"crashed": "Something went wrong in the {{name}} plugin", "crashed": "Something went wrong in {{name}}",
"retry": "Try again" "retry": "Try again"
} }
}, },
"settings": { "settings": {
"title": "Settings",
"bandYou": "You",
"bandPlugins": "Plugins",
"bandInstance": "Instance",
"account": "Account",
"secretSet": "Set", "secretSet": "Set",
"secretNotSet": "Not set", "secretNotSet": "Not set",
"secretReplace": "Replace", "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", "pluginSettingsSaved": "Settings saved",
"pluginSettingsInvalid": "Some settings could not be saved. Check the highlighted fields.", "pluginSettingsInvalid": "Some settings could not be saved. Check the highlighted fields.",
"pluginSettingsSaveFailed": "Failed to save settings", "pluginSettingsSaveFailed": "Failed to save settings",
"pluginSettingsLoadFailed": "Failed to load settings", "pluginSettingsLoadFailed": "Failed to load settings",
"pluginsGroupLabel": "Plugins", "featureUnavailable": "{{name}} is unavailable right now. These settings are saved but will not take effect."
"hostPluginNotRunning": "{{name}} is installed but not running. These settings are saved but will not take effect."
}, },
"managerCard": { "managerCard": {
"refresh": "Refresh", "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 { useTranslation } from "react-i18next";
import { usePluginStore } from "./plugin-store"; import { usePluginStore } from "./plugin-store";
import { unregisteredViewStatus, type ViewKind } from "./view-ownership"; import { unregisteredViewStatus, type ViewKind } from "./view-ownership";
@@ -55,7 +55,7 @@ export function PluginViewPlaceholder({
data-status={status} data-status={status}
className={`flex h-full w-full flex-col items-center justify-center gap-2 text-center ${compact ? "p-3" : "p-6"}`} 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"> <span className="text-sm font-semibold text-muted-foreground">
{title} {title}
</span> </span>
+4 -3
View File
@@ -16,7 +16,7 @@ import {
setPluginSummaries, setPluginSummaries,
} from "./plugin-store"; } from "./plugin-store";
import { workspaceFrontends, workspaceLocales } from "./workspace-plugins"; 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. * 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> { async function reconcile(summaries: PluginSummary[]): Promise<void> {
setPluginSummaries(summaries); setPluginSummaries(summaries);
// The host editor's Plugins tab exists only while something fills it.
syncHostPluginsTab(summaries);
await loadNamespaces(summaries); await loadNamespaces(summaries);
const { order, blocked } = orderForActivation(summaries); const { order, blocked } = orderForActivation(summaries);
@@ -301,6 +299,9 @@ async function reconcile(summaries: PluginSummary[]): Promise<void> {
} }
await activateConcurrently(order); 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), renameTab: (...args) => callbacks?.renameTab(...args),
openRailView: (...args) => callbacks?.openRailView(...args), openRailView: (...args) => callbacks?.openRailView(...args),
closeRailView: (...args) => callbacks?.closeRailView(...args), closeRailView: (...args) => callbacks?.closeRailView(...args),
openHostEditor: (...args) => callbacks?.openHostEditor?.(...args),
saveQuickConnect: (...args) => saveQuickConnect: (...args) =>
callbacks?.saveQuickConnect?.(...args) ?? Promise.resolve(), callbacks?.saveQuickConnect?.(...args) ?? Promise.resolve(),
}; };
+1
View File
@@ -80,6 +80,7 @@ function recordingShell(calls: ShellCall[]): TabShellCallbacks {
renameTab: record("renameTab"), renameTab: record("renameTab"),
openRailView: record("openRailView"), openRailView: record("openRailView"),
closeRailView: record("closeRailView"), closeRailView: record("closeRailView"),
openHostEditor: record("openHostEditor"),
saveQuickConnect: async (...args) => { saveQuickConnect: async (...args) => {
calls.push({ method: "saveQuickConnect", 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. * Manifest-declared settings shown as ordinary sections in the profile and
* * admin panels, one per feature, named after it.
* 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.
*/ */
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
@@ -13,9 +10,11 @@ import { Loader2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { SectionCard } from "@/components/section-card"; import { SectionCard } from "@/components/section-card";
import { AccordionSection } from "@/sidebar/AdminSettingsShared";
import { pluginKey } from "@/lib/plugin-i18n"; import { pluginKey } from "@/lib/plugin-i18n";
import { PluginIcon } from "@/lib/plugin-icon"; import { PluginIcon } from "@/lib/plugin-icon";
import { import {
getPlugins,
getPluginAdminSettings, getPluginAdminSettings,
getPluginUserSettings, getPluginUserSettings,
updatePluginAdminSettings, updatePluginAdminSettings,
@@ -26,83 +25,82 @@ import {
type PluginSummary, type PluginSummary,
} from "@/api/plugins-api"; } from "@/api/plugins-api";
import { SettingsFieldRow } from "./SettingsFields"; import { SettingsFieldRow } from "./SettingsFields";
import { isFieldActive } from "./settings-fields-util"; import { hasVisibleFields, isFieldActive } from "./settings-fields-util";
interface PluginSettingsPageProps { export type FeatureSettingsScope = "admin" | "user";
plugin: PluginSummary;
isAdmin: boolean; 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({ useEffect(() => {
plugin, let cancelled = false;
isAdmin, void getPlugins()
}: PluginSettingsPageProps) { .then((loaded) => {
const { t } = useTranslation(); if (!cancelled) setPlugins(loaded);
const settings = plugin.contributes?.settings; })
const running = plugin.enabled && plugin.state !== "failed"; .catch(() => {});
return () => {
cancelled = true;
};
}, []);
// Admin fields are not merely disabled for a non-admin: the values behind return useMemo(
// them are install-wide configuration they have no business reading. () =>
const scopes = useMemo(() => { plugins
const entries: { scope: Scope; fields: PluginSettingsField[] }[] = []; .filter(
if (isAdmin && (settings?.admin?.length ?? 0) > 0) { (plugin) =>
entries.push({ scope: "admin", fields: settings!.admin! }); plugin.enabled &&
} hasVisibleFields(plugin.contributes?.settings?.[scope] ?? []),
if ((settings?.user?.length ?? 0) > 0) { )
entries.push({ scope: "user", fields: settings!.user! }); .sort((a, b) => a.name.localeCompare(b.name)),
} [plugins, scope],
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>
); );
} }
/** export function FeatureSettingsSection({
* One scope's form. plugin,
* scope,
* Admin and user settings are separate requests with separate permissions, so open,
* they save separately rather than sharing one dirty state. onToggle,
*/ }: {
function PluginScopeForm({ 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, plugin,
scope, scope,
fields, fields,
running,
}: { }: {
plugin: PluginSummary; plugin: PluginSummary;
scope: Scope; scope: FeatureSettingsScope;
fields: PluginSettingsField[]; fields: PluginSettingsField[];
running: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [values, setValues] = useState<Record<string, unknown>>({}); const [values, setValues] = useState<Record<string, unknown>>({});
@@ -110,6 +108,7 @@ function PluginScopeForm({
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const running = plugin.enabled && plugin.state !== "failed";
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -166,13 +165,9 @@ function PluginScopeForm({
} }
}; };
// Fields keep their declared order within a group, and groups appear in the // Groups appear in the order of their first field, so the manifest sets the layout.
// order their first field does, so a manifest controls the whole layout.
const groups = useMemo(() => { const groups = useMemo(() => {
const ordered: { const ordered: { key?: string; fields: PluginSettingsField[] }[] = [];
key: string | undefined;
fields: PluginSettingsField[];
}[] = [];
for (const field of fields) { for (const field of fields) {
const existing = ordered.find((group) => group.key === field.group); const existing = ordered.find((group) => group.key === field.group);
if (existing) existing.fields.push(field); if (existing) existing.fields.push(field);
@@ -183,46 +178,53 @@ function PluginScopeForm({
if (loading) { if (loading) {
return ( 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" /> <Loader2 className="size-3.5 animate-spin" />
{t("common.loading")} {t("common.loading")}
</div> </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" />; const icon = <PluginIcon name={plugin.icon} className="size-3.5" />;
return ( return (
<> <div className="flex flex-col gap-2 pt-2">
{groups.map((group, index) => ( {!running && (
<SectionCard <div className="border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-500">
key={group.key ?? `group-${index}`} {t("settings.featureUnavailable", { name: plugin.name })}
title={ </div>
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 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 <Button
size="sm" size="sm"
className="h-7 text-xs" className="h-7 text-xs"
@@ -233,6 +235,6 @@ function PluginScopeForm({
{t("common.save")} {t("common.save")}
</Button> </Button>
</div> </div>
</> </div>
); );
} }
+1 -1
View File
@@ -139,7 +139,7 @@ function HostPluginSection({
> >
{!running && ( {!running && (
<div className="my-3 border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-500"> <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> </div>
)} )}
+59 -28
View File
@@ -80,20 +80,49 @@ export function SettingsFieldRow({
<span className="text-destructive">{error}</span> <span className="text-destructive">{error}</span>
) : undefined; ) : 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 ( return (
<SettingRow <div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0 min-w-0">
label={label(field.labelKey)} <div className="flex items-center gap-1.5 flex-wrap">
description={description} <span className="text-sm font-medium leading-snug">
badge={error ? t("common.error") : undefined} {label(field.labelKey)}
> </span>
<FieldControl {error && (
pluginId={pluginId} <span className="text-[10px] font-bold text-yellow-500 border border-yellow-500/40 px-1 shrink-0">
field={field} {t("common.error")}
value={value} </span>
setValue={setValue} )}
readOnly={readOnly} </div>
/> {description && (
</SettingRow> <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 ( return (
<Input <Input
type="number" type="number"
className="h-7 w-40 text-sm" className="h-8 w-full max-w-40 text-sm"
disabled={readOnly} disabled={readOnly}
min={field.min} min={field.min}
max={field.max} max={field.max}
@@ -152,7 +181,7 @@ function FieldControl({
value={typeof value === "string" ? value : ""} value={typeof value === "string" ? value : ""}
onValueChange={(next) => setValue(field.key, next)} 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} /> <SelectValue placeholder={placeholder} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -168,13 +197,12 @@ function FieldControl({
case "multiselect": { case "multiselect": {
const selected = Array.isArray(value) ? (value as string[]) : []; const selected = Array.isArray(value) ? (value as string[]) : [];
return ( return (
<div className="flex flex-col gap-1.5 items-end"> <div className="flex flex-col gap-1.5">
{(field.options ?? []).map((option) => ( {(field.options ?? []).map((option) => (
<label <label
key={option.value} key={option.value}
className="flex items-center gap-2 text-xs text-muted-foreground" className="flex items-center gap-2 text-xs text-muted-foreground"
> >
<span>{label(option.labelKey)}</span>
<Checkbox <Checkbox
disabled={readOnly} disabled={readOnly}
checked={selected.includes(option.value)} checked={selected.includes(option.value)}
@@ -187,6 +215,7 @@ function FieldControl({
) )
} }
/> />
<span>{label(option.labelKey)}</span>
</label> </label>
))} ))}
</div> </div>
@@ -208,7 +237,7 @@ function FieldControl({
case "json": case "json":
return ( return (
<Textarea <Textarea
className="w-64 text-sm font-mono min-h-16" className="w-full text-sm font-mono min-h-16"
disabled={readOnly} disabled={readOnly}
placeholder={placeholder} placeholder={placeholder}
value={ value={
@@ -225,7 +254,7 @@ function FieldControl({
default: default:
return ( return (
<Input <Input
className="h-7 w-52 text-sm" className="h-8 w-full text-sm"
disabled={readOnly} disabled={readOnly}
placeholder={placeholder} placeholder={placeholder}
value={typeof value === "string" ? value : ""} value={typeof value === "string" ? value : ""}
@@ -259,7 +288,7 @@ function SecretControl({
if (redacted) { if (redacted) {
return ( return (
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{stored ? t("settings.secretSet") : t("settings.secretNotSet")} {stored ? t("settings.secretSet") : t("settings.secretNotSet")}
</span> </span>
@@ -277,14 +306,16 @@ function SecretControl({
} }
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 min-w-0">
<PasswordInput <div className="min-w-0 flex-1">
className="h-7 w-52 text-sm" <PasswordInput
disabled={readOnly} className="h-8 w-full text-sm"
placeholder={placeholder} disabled={readOnly}
value={typeof value === "string" ? value : ""} placeholder={placeholder}
onChange={(event) => setValue(field.key, event.target.value)} value={typeof value === "string" ? value : ""}
/> onChange={(event) => setValue(field.key, event.target.value)}
/>
</div>
<Button <Button
variant="ghost" variant="ghost"
size="sm" 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 { ComponentType, Ref } from "react";
import type { HostDraft } from "@termix/plugin-sdk/frontend";
import type { Host, Tab } from "@/types/ui-types"; import type { Host, Tab } from "@/types/ui-types";
import { createRegistry } from "@/lib/registry"; import { createRegistry } from "@/lib/registry";
import { viewOwner } from "@/plugin-host/view-ownership"; import { viewOwner } from "@/plugin-host/view-ownership";
@@ -27,6 +28,8 @@ export interface TabShellCallbacks {
openRailView: (id: string) => void; openRailView: (id: string) => void;
/** Closes a rail view wherever it is shown, e.g. when its feature goes away. */ /** Closes a rail view wherever it is shown, e.g. when its feature goes away. */
closeRailView: (id: string) => void; 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. */ /** Saves a quick-connect tab's host. Core terminal only. */
saveQuickConnect?: (tab: Tab, host: Host) => Promise<void>; 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> </span>
{!factor.available && ( {!factor.available && (
<span className="text-[10px] text-destructive"> <span className="text-[10px] text-destructive">
{t("admin.secondFactorUnavailable", { {t("admin.secondFactorUnavailable")}
plugin: factor.pluginId,
})}
</span> </span>
)} )}
</div> </div>
+20 -9
View File
@@ -60,7 +60,12 @@ import {
type UserRole, type UserRole,
} from "@/main-axios"; } from "@/main-axios";
import { type AdminSection, type Host } from "@/types/ui-types"; import { type AdminSection, type Host } from "@/types/ui-types";
import { AdminPluginsSection } from "./AdminPluginsSection"; import {
FeatureSettingsSection,
featureSectionId,
useFeatureSettings,
type FeatureSectionId,
} from "@/settings/FeatureSettingsSections";
import { import {
AdminRolesSection, AdminRolesSection,
AdminSessionsSection, AdminSessionsSection,
@@ -109,9 +114,10 @@ export function AdminSettingsPanel({
onOpenHostTab?: (host: Host) => void; onOpenHostTab?: (host: Host) => void;
} = {}) { } = {}) {
const { t } = useTranslation(); const { t } = useTranslation();
const [openSections, setOpenSections] = useState<Set<AdminSection>>( const [openSections, setOpenSections] = useState<
() => new Set(["general"]), Set<AdminSection | FeatureSectionId>
); >(() => new Set(["general"]));
const featureSettings = useFeatureSettings("admin");
const [manageUser, setManageUser] = useState<AdminUser | null>(null); const [manageUser, setManageUser] = useState<AdminUser | null>(null);
const [allowRegistration, setAllowRegistration] = useState(true); const [allowRegistration, setAllowRegistration] = useState(true);
const [allowPasswordLogin, setAllowPasswordLogin] = useState(true); const [allowPasswordLogin, setAllowPasswordLogin] = useState(true);
@@ -358,7 +364,7 @@ export function AdminSettingsPanel({
.catch(() => {}); .catch(() => {});
} }
function toggle(id: AdminSection) { function toggle(id: AdminSection | FeatureSectionId) {
setOpenSections((prev) => { setOpenSections((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(id)) next.delete(id); if (next.has(id)) next.delete(id);
@@ -970,10 +976,15 @@ export function AdminSettingsPanel({
users={users} users={users}
/> />
<AdminPluginsSection {featureSettings.map((plugin) => (
open={openSections.has("plugins")} <FeatureSettingsSection
onToggle={() => toggle("plugins")} key={plugin.id}
/> plugin={plugin}
scope="admin"
open={openSections.has(featureSectionId(plugin.id))}
onToggle={() => toggle(featureSectionId(plugin.id))}
/>
))}
<AdminCreateUserDialog <AdminCreateUserDialog
open={createUserOpen} open={createUserOpen}
+9 -1
View File
@@ -60,6 +60,7 @@ import type { SSHHost } from "@/types";
import { useTabsSafe } from "@/shell/TabContext"; import { useTabsSafe } from "@/shell/TabContext";
import { updatePluginHostSettings } from "@/api/plugins-api"; import { updatePluginHostSettings } from "@/api/plugins-api";
import { import {
applyHostDraft,
buildHostEditorPayload, buildHostEditorPayload,
createHostEditorForm, createHostEditorForm,
mapSnippetResponse, mapSnippetResponse,
@@ -89,6 +90,7 @@ import { useSshAuthEditors } from "@/plugin-host/auth-registry";
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders"; import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
import { SshAuthProviderFields } from "./SshAuthProviderFields"; import { SshAuthProviderFields } from "./SshAuthProviderFields";
import type { PluginSettingsField } from "@termix/plugin-sdk/manifest"; import type { PluginSettingsField } from "@termix/plugin-sdk/manifest";
import type { HostDraft } from "@termix/plugin-sdk/frontend";
import { PluginComponent } from "@/plugin-host/component-registry"; import { PluginComponent } from "@/plugin-host/component-registry";
import { import {
toCredentialOption, toCredentialOption,
@@ -99,6 +101,7 @@ const CUSTOM_FONT_OPTION = "__custom__";
export function HostEditor({ export function HostEditor({
host, host,
draft,
activeTab, activeTab,
onBack, onBack,
onSave, onSave,
@@ -113,6 +116,8 @@ export function HostEditor({
onEditCredential, onEditCredential,
}: { }: {
host: Host | null; host: Host | null;
/** Fields a plugin filled in for a new host. */
draft?: HostDraft;
activeTab: string; activeTab: string;
/** Collapses the General tab to the fields needed to reach a host. */ /** Collapses the General tab to the fields needed to reach a host. */
simpleMode?: boolean; simpleMode?: boolean;
@@ -136,7 +141,10 @@ export function HostEditor({
const { setPreviewTerminalTheme } = useTabsSafe(); const { setPreviewTerminalTheme } = useTabsSafe();
const connectionDefaults = useConnectionDefaults(); const connectionDefaults = useConnectionDefaults();
const [form, setForm] = useState(() => const [form, setForm] = useState(() =>
createHostEditorForm(host, undefined, connectionDefaults), applyHostDraft(
createHostEditorForm(host, undefined, connectionDefaults),
host ? undefined : draft,
),
); );
const [isCustomFont, setIsCustomFont] = useState( const [isCustomFont, setIsCustomFont] = useState(
() => !TERMINAL_FONTS.some((f) => f.value === form.fontFamily), () => !TERMINAL_FONTS.some((f) => f.value === form.fontFamily),
+19
View File
@@ -1,6 +1,7 @@
import { TERMINAL_THEMES } from "@/lib/terminal-themes"; import { TERMINAL_THEMES } from "@/lib/terminal-themes";
import type { Host } from "@/types/ui-types"; import type { Host } from "@/types/ui-types";
import type { SSHHostData } from "@/types"; import type { SSHHostData } from "@/types";
import type { HostDraft } from "@termix/plugin-sdk/frontend";
import type { HostDefaults } from "@/api/settings-api"; import type { HostDefaults } from "@/api/settings-api";
import type { TerminalDefaults } from "@/lib/connection-defaults"; import type { TerminalDefaults } from "@/lib/connection-defaults";
import { 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( export function createHostEditorForm(
host: Host | null, host: Host | null,
defaults?: HostDefaults, defaults?: HostDefaults,
+15 -2
View File
@@ -11,6 +11,7 @@ import React, {
type MutableRefObject, type MutableRefObject,
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { HostDraft } from "@termix/plugin-sdk/frontend";
import { CredentialShareModal } from "./CredentialShareModal"; import { CredentialShareModal } from "./CredentialShareModal";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
@@ -82,6 +83,12 @@ export function HostManager({
useHostEditorSections(); useHostEditorSections();
const { t } = useTranslation(); const { t } = useTranslation();
const [editingHost, setEditingHost] = useState<Host | "new" | null>(null); 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>( const [shareCredential, setShareCredential] = useState<Credential | null>(
null, null,
); );
@@ -189,6 +196,7 @@ export function HostManager({
pendingAction.current = null; pendingAction.current = null;
if (action === "add-host") { if (action === "add-host") {
setEditingHost("new"); setEditingHost("new");
setHostDraft(null);
setEditingCredential(null); setEditingCredential(null);
setEditingProtocols(hostProtocolFlags(null)); setEditingProtocols(hostProtocolFlags(null));
setActiveHostTab("general"); setActiveHostTab("general");
@@ -202,8 +210,10 @@ export function HostManager({
useEffect(() => { useEffect(() => {
if (!active) return; if (!active) return;
const handleAddHost = () => { const handleAddHost = (e: Event) => {
const draft = (e as CustomEvent<HostDraft | undefined>).detail;
setEditingHost("new"); setEditingHost("new");
setHostDraft(draft ? { key: Date.now(), draft } : null);
setEditingCredential(null); setEditingCredential(null);
setEditingProtocols(hostProtocolFlags(null)); setEditingProtocols(hostProtocolFlags(null));
setActiveHostTab("general"); setActiveHostTab("general");
@@ -518,9 +528,12 @@ export function HostManager({
{isHost ? ( {isHost ? (
<HostEditor <HostEditor
key={ 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)} host={editingHost === "new" ? null : (editingHost as Host)}
draft={editingHost === "new" ? hostDraft?.draft : undefined}
activeTab={effectiveHostTab} activeTab={effectiveHostTab}
simpleMode={collapseAdvanced} simpleMode={collapseAdvanced}
onBack={requestCloseHostEditor} onBack={requestCloseHostEditor}
+3 -1
View File
@@ -43,6 +43,8 @@ export interface HostEditorSectionDef {
pluginId?: string; pluginId?: string;
group: "top" | "ssh"; group: "top" | "ssh";
labelKey: string; labelKey: string;
/** Already-translated label, used instead of labelKey when set. */
label?: string;
icon?: ComponentType<{ className?: string }>; icon?: ComponentType<{ className?: string }>;
/** Position among the group's tabs, core ones included. */ /** Position among the group's tabs, core ones included. */
order?: number; order?: number;
@@ -96,7 +98,7 @@ function mergeByOrder(
const Icon = section.icon; const Icon = section.icon;
return { return {
id: section.id, id: section.id,
label: t(section.labelKey), label: section.label ?? t(section.labelKey),
icon: Icon ? <Icon className="size-3" /> : null, icon: Icon ? <Icon className="size-3" /> : null,
order: section.order ?? 1000, 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 { Select2 } from "@/components/select2";
import { clearLocalAdaptivePreferences } from "@/lib/local-adaptive-preferences"; import { clearLocalAdaptivePreferences } from "@/lib/local-adaptive-preferences";
import { ConnectionDefaultsSettings } from "./ConnectionDefaultsSettings"; import { ConnectionDefaultsSettings } from "./ConnectionDefaultsSettings";
import {
FeatureSettingsSection,
featureSectionId,
useFeatureSettings,
type FeatureSectionId,
} from "@/settings/FeatureSettingsSections";
type UserProfileSection = type UserProfileSection =
"account" | "interface" | "appearance" | "security" | "api-keys" | "data"; | "account"
| "interface"
| "appearance"
| "security"
| "api-keys"
| "data"
| FeatureSectionId;
const THEMES: { id: ThemeId; preview: string }[] = [ const THEMES: { id: ThemeId; preview: string }[] = [
{ id: "system", preview: "auto" }, { id: "system", preview: "auto" },
@@ -557,6 +569,7 @@ export function UserProfilePanel({
const [openSections, setOpenSections] = useState<Set<UserProfileSection>>( const [openSections, setOpenSections] = useState<Set<UserProfileSection>>(
() => new Set(["account"]), () => new Set(["account"]),
); );
const featureSettings = useFeatureSettings("user");
// User info // User info
const [userId, setUserId] = useState(""); const [userId, setUserId] = useState("");
@@ -2201,6 +2214,16 @@ export function UserProfilePanel({
</div> </div>
</AccordionSection> </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 */} {/* Delete account dialog */}
<Dialog <Dialog
open={showDeleteConfirm} open={showDeleteConfirm}
+13
View File
@@ -15,6 +15,16 @@ type QuickConnectInput = Pick<
const QUICK_CONNECT_ID_PREFIX = "quick-connect-"; 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 { export function isQuickConnectHost(host: Pick<Host, "id">): boolean {
return host.id.startsWith(QUICK_CONNECT_ID_PREFIX); return host.id.startsWith(QUICK_CONNECT_ID_PREFIX);
} }
@@ -33,6 +43,7 @@ export function createQuickConnectHost(input: QuickConnectInput): Host {
...login, ...login,
port: input.port, port: input.port,
enableSsh: false, enableSsh: false,
quickConnectSavable: false,
domain: input.domain, domain: input.domain,
pluginSettings: { pluginSettings: {
[protocol.pluginId]: { [protocol.pluginId]: {
@@ -62,6 +73,8 @@ export function createQuickConnectHost(input: QuickConnectInput): Host {
quickActions: [], quickActions: [],
enableSsh: true, enableSsh: true,
sshPort: input.port, sshPort: input.port,
quickConnectSavable:
!input.authFields && SAVABLE_AUTH_TYPES.has(input.authType),
...input.authFields, ...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. * Generated host editor tabs, one per feature with manifest host settings.
*
* 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.
*/ */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { PluginSummary } from "@/api/plugins-api"; import type { PluginSummary } from "@/api/plugins-api";
import { import {
HOST_PLUGINS_TAB_ID, hostFeatureTabId,
syncHostPluginsTab, syncHostFeatureTabs,
} from "@/settings/host-plugins-tab"; } from "@/settings/host-feature-tabs";
import { import {
getHostEditorSection, getHostEditorSection,
unregisterHostEditorSection, registerHostEditorSection,
resetHostEditorSections,
} from "@/sidebar/HostManagerTabs"; } from "@/sidebar/HostManagerTabs";
import { HostPluginSections } from "@/settings/HostPluginSections"; import { HostPluginSections } from "@/settings/HostPluginSections";
@@ -47,18 +44,62 @@ function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
} }
beforeEach(() => { beforeEach(() => {
unregisterHostEditorSection(HOST_PLUGINS_TAB_ID); resetHostEditorSections();
}); });
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
unregisterHostEditorSection(HOST_PLUGINS_TAB_ID); resetHostEditorSections();
}); });
describe("syncHostPluginsTab", () => { describe("syncHostFeatureTabs", () => {
it("registers the tab when a plugin contributes host settings", () => { it("registers one tab per feature, labelled with its name", () => {
expect(syncHostPluginsTab([plugin()])).toBe(true); const other = plugin({ id: "warpgate", name: "Warpgate" });
expect(getHostEditorSection(HOST_PLUGINS_TAB_ID)).toBeDefined(); 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", () => { it("registers nothing for a plugin whose host fields are all hidden", () => {
@@ -73,12 +114,7 @@ describe("syncHostPluginsTab", () => {
}, },
}, },
} as Partial<PluginSummary>); } as Partial<PluginSummary>);
expect(syncHostPluginsTab([own])).toBe(false); expect(syncHostFeatureTabs([own])).toEqual([]);
});
it("registers nothing when no plugin contributes", () => {
expect(syncHostPluginsTab([])).toBe(false);
expect(getHostEditorSection(HOST_PLUGINS_TAB_ID)).toBeUndefined();
}); });
it("ignores a plugin with no host settings", () => { it("ignores a plugin with no host settings", () => {
@@ -86,21 +122,20 @@ describe("syncHostPluginsTab", () => {
id: "ai", id: "ai",
contributes: { settings: { admin: [] } }, contributes: { settings: { admin: [] } },
}); });
expect(syncHostFeatureTabs([other])).toEqual([]);
expect(syncHostPluginsTab([other])).toBe(false);
}); });
it("ignores a disabled plugin", () => { 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", () => { it("removes a tab once its feature goes", () => {
syncHostPluginsTab([plugin()]); syncHostFeatureTabs([plugin()]);
expect(getHostEditorSection(HOST_PLUGINS_TAB_ID)).toBeDefined(); 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", () => { it("registers for a plugin declaring only an enable switch", () => {
@@ -111,8 +146,7 @@ describe("syncHostPluginsTab", () => {
}, },
}, },
}); });
expect(syncHostFeatureTabs([bare])).toEqual(["feature:docker"]);
expect(syncHostPluginsTab([bare])).toBe(true);
}); });
}); });
@@ -184,7 +218,7 @@ describe("HostPluginSections", () => {
expect(setValue).toHaveBeenCalledWith("docker", "enableDocker", true); expect(setValue).toHaveBeenCalledWith("docker", "enableDocker", true);
}); });
it("warns when the plugin is installed but not running", () => { it("warns when the feature is unavailable", () => {
render( render(
<HostPluginSections <HostPluginSections
plugins={[plugin({ state: "failed" })]} 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", () => { it("renders nothing at all with no contributing plugins", () => {
@@ -44,7 +44,7 @@ describe("AdminSecondFactorsSection", () => {
/>, />,
); );
expect( expect(
await screen.findByText("admin.secondFactorUnavailable:yubi"), await screen.findByText("admin.secondFactorUnavailable"),
).toBeTruthy(); ).toBeTruthy();
// A plugin's label resolves in its own namespace. // A plugin's label resolves in its own namespace.
expect(screen.getByText("totp:factor")).toBeTruthy(); expect(screen.getByText("totp:factor")).toBeTruthy();
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { registerHostProtocol } from "../../sidebar/host-protocols"; import { registerHostProtocol } from "../../sidebar/host-protocols";
import { import {
applyHostDraft,
createHostEditorForm, createHostEditorForm,
buildHostEditorPayload, buildHostEditorPayload,
omitOwnerSshAuthFromSharedEdit, omitOwnerSshAuthFromSharedEdit,
@@ -559,3 +560,27 @@ describe("plugin protocols", () => {
expect(payload.enableSsh).toBe(false); 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 } }, 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", name: "frontend",
environment: "jsdom", environment: "jsdom",
include: ["src/ui/**/*.test.{ts,tsx}"], 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/] } },
}, },
}, },
{ {