From fbe736bc209c7cd5080e631a1cab8ec3f9b3c92f Mon Sep 17 00:00:00 2001 From: LukeGus Date: Fri, 25 Sep 2026 13:06:33 -0500 Subject: [PATCH] feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning --- packages/plugin-sdk/ARCHITECTURE.md | 45 ++- packages/plugin-sdk/cli/commands/build.mjs | 7 + .../plugin-sdk/schema/manifest.schema.json | 8 + packages/plugin-sdk/src/frontend.ts | 14 + packages/plugin-sdk/src/manifest.ts | 27 +- plugins/ai/locales/en.json | 8 +- plugins/ai/manifest.json | 2 + plugins/automations/locales/en.json | 2 +- plugins/docker/manifest.json | 2 + plugins/file-manager/manifest.json | 2 + plugins/host-metrics/locales/en.json | 2 +- .../src/frontend/RedirectUriSetting.tsx | 2 +- plugins/remote-desktop/locales/en.json | 1 + plugins/remote-desktop/manifest.json | 3 +- plugins/remote-desktop/src/frontend/index.tsx | 33 ++- .../src/frontend/remote-form.ts | 3 + plugins/session-recording/manifest.json | 2 + plugins/session-sharing/manifest.json | 2 + plugins/ssh-terminal/locales/en.json | 2 +- plugins/ssh-terminal/manifest.json | 2 + .../frontend/settings/TouchInputSettings.tsx | 2 +- .../frontend/terminal/TerminalTabContent.tsx | 2 +- plugins/step-ca/locales/en.json | 2 +- .../src/frontend/RedirectUriSetting.tsx | 2 +- plugins/tailscale/locales/en.json | 6 +- plugins/tailscale/src/backend/routes.ts | 2 +- .../src/frontend/TailscaleDevicesPanel.tsx | 97 +++++-- .../src/frontend/TailscaleDevicesStatus.tsx | 2 +- plugins/tailscale/src/frontend/index.tsx | 5 + .../frontend/TailscaleDevicesPanel.test.tsx | 71 +++++ .../vault/src/frontend/RedirectUriSetting.tsx | 2 +- plugins/wake-on-lan/manifest.json | 2 + plugins/warpgate/manifest.json | 2 + scripts/plugin-build.test.ts | 42 +++ .../database/routes/plugin-api-routes.ts | 4 +- src/backend/plugins/http.ts | 2 +- .../tests/utils/safe-outbound-fetch.test.ts | 24 ++ src/backend/utils/safe-outbound-fetch.ts | 10 +- src/types/ui-types.ts | 7 +- src/ui/AppShell.tsx | 42 ++- src/ui/api/plugins-api.ts | 29 +- src/ui/locales/en.json | 55 +--- src/ui/plugin-host/PluginViewPlaceholder.tsx | 4 +- src/ui/plugin-host/loader.ts | 7 +- src/ui/plugin-host/shell-bridge.ts | 1 + src/ui/plugin-host/testing-host.tsx | 1 + ...gsPage.tsx => FeatureSettingsSections.tsx} | 214 +++++++------- src/ui/settings/HostPluginSections.tsx | 2 +- src/ui/settings/SettingsFields.tsx | 87 ++++-- src/ui/settings/SettingsScreen.tsx | 273 ------------------ src/ui/settings/host-feature-tabs.tsx | 90 ++++++ src/ui/settings/host-plugins-tab.tsx | 73 ----- src/ui/shell/tab-registry.ts | 3 + src/ui/sidebar/AdminPluginsSection.tsx | 125 -------- src/ui/sidebar/AdminSecondFactorsSection.tsx | 4 +- src/ui/sidebar/AdminSettingsPanel.tsx | 29 +- src/ui/sidebar/HostEditor.tsx | 10 +- src/ui/sidebar/HostEditorData.ts | 19 ++ src/ui/sidebar/HostManager.tsx | 17 +- src/ui/sidebar/HostManagerTabs.tsx | 4 +- src/ui/sidebar/PluginPermissionsDialog.tsx | 158 ---------- src/ui/sidebar/UserProfilePanel.tsx | 25 +- src/ui/sidebar/quick-connect-host.ts | 13 + .../settings/FeatureSettingsSections.test.tsx | 185 ++++++++++++ src/ui/tests/settings/SettingsScreen.test.tsx | 182 ------------ ...ab.test.tsx => host-feature-tabs.test.tsx} | 100 ++++--- .../AdminSecondFactorsSection.test.tsx | 2 +- src/ui/tests/sidebar/HostEditorData.test.ts | 25 ++ .../tests/sidebar/quick-connect-host.test.ts | 19 ++ vitest.config.ts | 3 + 70 files changed, 1117 insertions(+), 1139 deletions(-) create mode 100644 scripts/plugin-build.test.ts rename src/ui/settings/{PluginSettingsPage.tsx => FeatureSettingsSections.tsx} (53%) delete mode 100644 src/ui/settings/SettingsScreen.tsx create mode 100644 src/ui/settings/host-feature-tabs.tsx delete mode 100644 src/ui/settings/host-plugins-tab.tsx delete mode 100644 src/ui/sidebar/AdminPluginsSection.tsx delete mode 100644 src/ui/sidebar/PluginPermissionsDialog.tsx create mode 100644 src/ui/tests/settings/FeatureSettingsSections.test.tsx delete mode 100644 src/ui/tests/settings/SettingsScreen.test.tsx rename src/ui/tests/settings/{host-plugins-tab.test.tsx => host-feature-tabs.test.tsx} (66%) diff --git a/packages/plugin-sdk/ARCHITECTURE.md b/packages/plugin-sdk/ARCHITECTURE.md index 223876b06..5d640fa78 100644 --- a/packages/plugin-sdk/ARCHITECTURE.md +++ b/packages/plugin-sdk/ARCHITECTURE.md @@ -254,8 +254,8 @@ handler)` for a socket. Core runs the same middleware in front of every plugin: 1. **Auth**, unless the path is listed in `options.public`. A public path is audited when the router is registered and logged on every request, because "this plugin opened a hole in auth" should be findable later. **D2** lists - a running plugin's public HTTP paths and public socket paths in the admin - plugin dialog (`publicRoutes` on `GET /plugins`), and audits a public + a running plugin's public HTTP paths and public socket paths as + `publicRoutes` on `GET /plugins` (admins only), and audits a public socket when it is registered. It matches the full path, with `:param` segments allowed, so declaring `/webhook/:token` public cannot open `/webhook/:token/anything`. A trailing `/*` is the one @@ -360,8 +360,22 @@ the plugin's own permissions exactly as `ctx.rbac` does. `hidden: true` (**B14**) keeps a field out of the generic form: it is stored, validated and encrypted like any other, but the plugin edits it in its own UI. Remote desktop's host options are hidden fields, edited in its RDP, VNC -and Telnet host editor tabs, so they do not appear a second time in the -Plugins tab. +and Telnet host editor tabs. + +**Where fields render.** In 2.9.0 the plugin system is invisible to users: +there is no plugin list, enable switch or capability dialog, and nothing in +the UI says "plugin". That UI arrives with the store in 3.0.0. Settings show +up as features instead: + +- `user` fields render as an accordion section named after the plugin in the + User Profile panel, and `admin` fields do the same in the Admin Settings + panel (`src/ui/settings/FeatureSettingsSections.tsx`). `group` becomes a + card inside that section. +- `host` fields get a host editor tab of their own, named after the plugin + (`src/ui/settings/host-feature-tabs.tsx`). `editorGroup` (`"top"` or + `"ssh"`, default `"top"`) and `editorOrder` place it among the other tabs. + A plugin that registers its own section with `registerHostEditorSection` + gets no generated tab, so it must draw any non-hidden host field itself. `type: "custom"` names a component the frontend registered, for the few things a schema cannot express (a device browser, a provider list). It is deliberately @@ -419,8 +433,8 @@ allowlist (`CORE_SETTINGS_ALLOWLIST`) rather than the whole settings table. **D0** added three pieces: -- `onValidate(scope, validator)` checks a save from the settings screen or the - host editor before anything is written. The validator gets every value in +- `onValidate(scope, validator)` checks a save from the profile or admin + panel or the host editor before anything is written. The validator gets every value in the save (and the host id for host scope) and returns field key to message for what is wrong. ssh-terminal checks that its image paths are absolute, step-ca that its CA URL is https and its fingerprint a SHA-256 digest. @@ -645,6 +659,13 @@ answers undefined while its plugin is off. shared session") and replaces `ShellApi.openTerminalTab`, which the shell no longer has. +`ShellApi.openHostEditor(draft?)` opens the host editor for a new host with +a `HostDraft` (name, ip, port, username, authType) filled in, which the user +reviews and saves (tailscale's "Add host" on a device). `saveQuickConnect` +saves only what core's own quick connect payload carries, so the terminal +offers it only on a host with `quickConnectSavable`, which core sets for the +password, key, credential, none and agent auth types. + **Components by id.** `app.registerComponent(id, component)` offers a component to other plugins and core, which render it with `PluginComponent` from `@termix/plugin-sdk/ui` (a fallback while the owner is off) or @@ -766,7 +787,7 @@ mode are still preset-seeded localStorage keys core writes, as in 2.8. origin, as `app.apiFor` but usable in a component. The terminal toolbar's metrics bars use it for a host on the desktop app's remote server, and tunnels merges the remote server's statuses through `app.apiFor("remote")`. -- `app.onSettingsChanged(listener)`: fires after the settings screen or host +- `app.onSettingsChanged(listener)`: fires after the profile panel, admin panel or host editor saved one of the plugin's settings, instead of polling on focus. - A `tab.menu` slot: entries in a tab's right-click menu, invoked with the tab's surface handle; `when` sees `{ tab, handle }`. Session sharing adds its @@ -1258,8 +1279,8 @@ base types and branches on no plugin type name. in it. Its public routes are `/plugin-api/sso/start` (sends the browser to the provider), `/callback` (GET and POST), `/backchannel-logout` and `/config`; provider management is `/plugin-api/sso/providers`, gated on - `sso.manage`, and renders as the custom `providers` field on the plugin's - admin settings page. Login state (nonce, PKCE verifier, return address) + `sso.manage`, and renders as the custom `providers` field in the plugin's + Admin Settings section. Login state (nonce, PKCE verifier, return address) lives in `ctx.kv` for ten minutes. - The redirect URI is `/plugin-api/sso/callback`. Providers that existed before 2.9 keep sending `/users/oidc/callback`, the URI their @@ -1937,6 +1958,8 @@ not do. }, ], "host": { + "editorGroup": "ssh", // host editor strip for the generated tab, default "top" + "editorOrder": 30, // position among that strip's tabs "enableKey": "enableExample", // boolean rendered first, gating the rest "enableLabelKey": "k", "fields": [ @@ -2696,8 +2719,8 @@ None of the checks in this document stop that. They are not a sandbox. `system:tls`, `ssh:connect`, `process:spawn`, `users:write`, `auth:provide`, `device:serial`) means the plugin can reach your hosts, your users or the server itself. Ask why it needs each one. -- **Read the public routes.** Admin > Plugins > Permissions lists every path - it serves without login. Each one is attack surface on your server. +- **Read the public routes.** `publicRoutes` on `GET /plugins` lists every + path it serves without login. Each one is attack surface on your server. - **Check its dependencies.** A plugin's npm dependencies run with the same reach as the plugin. - **Watch the audit log after installing.** Filter on `plugin:` and look diff --git a/packages/plugin-sdk/cli/commands/build.mjs b/packages/plugin-sdk/cli/commands/build.mjs index fe9e20de6..d7ac52d68 100644 --- a/packages/plugin-sdk/cli/commands/build.mjs +++ b/packages/plugin-sdk/cli/commands/build.mjs @@ -54,6 +54,13 @@ export async function build({ cwd }) { }, }); + // Without this Node finds the host's typeless package.json, tries backend.js + // as CommonJS first and warns before reparsing it as ESM. + fs.writeFileSync( + path.join(outDir, "package.json"), + `${JSON.stringify({ type: "module" }, null, 2)}\n`, + ); + const frontendEntry = resolveEntry(cwd, FRONTEND_ENTRIES); if (frontendEntry) { await esbuild.build({ diff --git a/packages/plugin-sdk/schema/manifest.schema.json b/packages/plugin-sdk/schema/manifest.schema.json index fe41d523c..ad72eddec 100644 --- a/packages/plugin-sdk/schema/manifest.schema.json +++ b/packages/plugin-sdk/schema/manifest.schema.json @@ -793,6 +793,14 @@ "enableDefault": { "type": "boolean", "description": "What the enable switch reads before a host saves it. Off by default." + }, + "editorGroup": { + "enum": ["top", "ssh"], + "description": "Host editor strip the generated tab sits in. Defaults to top." + }, + "editorOrder": { + "type": "number", + "description": "Position among that strip's tabs, core ones included." } } } diff --git a/packages/plugin-sdk/src/frontend.ts b/packages/plugin-sdk/src/frontend.ts index f96553956..4a83ffa42 100644 --- a/packages/plugin-sdk/src/frontend.ts +++ b/packages/plugin-sdk/src/frontend.ts @@ -74,6 +74,15 @@ export interface ShellLayout { } /** What a plugin may ask of the shell. */ +/** Fields a plugin can fill in when it opens the host editor. */ +export interface HostDraft { + name?: string; + ip?: string; + port?: number; + username?: string; + authType?: string; +} + export interface ShellApi { openTab: ( host: PluginHostRecord | null, @@ -88,6 +97,11 @@ export interface ShellApi { tab: PluginTabRecord, host: PluginHostRecord, ) => Promise; + /** + * Opens the host editor for a new host with these fields filled in. The + * user reviews and saves it. Absent in some shells. + */ + openHostEditor?: (draft?: HostDraft) => void; /** Opens a rail view in the left sidebar. */ openRailView: (id: string) => void; /** Closes a rail view wherever it is shown. */ diff --git a/packages/plugin-sdk/src/manifest.ts b/packages/plugin-sdk/src/manifest.ts index 2349923d7..55ae53395 100644 --- a/packages/plugin-sdk/src/manifest.ts +++ b/packages/plugin-sdk/src/manifest.ts @@ -271,6 +271,10 @@ export interface PluginHostSettingsContribution { enableLabelKey?: string; /** What the enable switch reads before a host saves it. Off by default. */ enableDefault?: boolean; + /** Host editor strip the generated tab sits in. Defaults to "top". */ + editorGroup?: "top" | "ssh"; + /** Position among that strip's tabs, core ones included. */ + editorOrder?: number; fields: PluginSettingsField[]; } @@ -1086,7 +1090,14 @@ function validateSettings(settings: unknown, errors: string[]): void { } rejectUnknown( host, - ["enableKey", "enableLabelKey", "enableDefault", "fields"], + [ + "enableKey", + "enableLabelKey", + "enableDefault", + "editorGroup", + "editorOrder", + "fields", + ], `"${at}"`, errors, ); @@ -1104,6 +1115,20 @@ function validateSettings(settings: unknown, errors: string[]): void { if ("enableDefault" in host && typeof host.enableDefault !== "boolean") { errors.push(`${at}.enableDefault must be a boolean`); } + if ( + "editorGroup" in host && + host.editorGroup !== "top" && + host.editorGroup !== "ssh" + ) { + errors.push(`${at}.editorGroup must be "top" or "ssh"`); + } + if ( + "editorOrder" in host && + (typeof host.editorOrder !== "number" || + !Number.isFinite(host.editorOrder)) + ) { + errors.push(`${at}.editorOrder must be a number`); + } if (!Array.isArray(host.fields)) { errors.push(`${at}.fields must be an array`); diff --git a/plugins/ai/locales/en.json b/plugins/ai/locales/en.json index e146f2349..de591e668 100644 --- a/plugins/ai/locales/en.json +++ b/plugins/ai/locales/en.json @@ -13,12 +13,12 @@ "description": "Let the assistant carry out the changes it suggests." }, "services.use": { - "title": "Use AI from other plugins", - "description": "Other plugins may call the assistant on your behalf." + "title": "Use AI from other features", + "description": "Other features may call the assistant on your behalf." }, "secrets.share": { "title": "Share the API key", - "description": "Other plugins may use the stored AI API key without seeing it." + "description": "Other features may use the stored AI API key without seeing it." } }, "settings": { @@ -98,7 +98,7 @@ "runInTerminal": "Run", "terminalContextPrefill": "Here is my terminal output:\n\n{{output}}\n\n", "secrets": { - "shareApiKey": "Let other plugins use your AI provider API key for their own requests. The key is never copied, and you can revoke this at any time." + "shareApiKey": "Let other features use your AI provider API key for their own requests. The key is never copied, and you can revoke this at any time." } }, "nav": { diff --git a/plugins/ai/manifest.json b/plugins/ai/manifest.json index 8e136aae2..deea21f23 100644 --- a/plugins/ai/manifest.json +++ b/plugins/ai/manifest.json @@ -173,6 +173,8 @@ } ], "host": { + "editorGroup": "ssh", + "editorOrder": 90, "fields": [ { "key": "enableAiAssistant", diff --git a/plugins/automations/locales/en.json b/plugins/automations/locales/en.json index 0c3bf4b79..6c637ff2a 100644 --- a/plugins/automations/locales/en.json +++ b/plugins/automations/locales/en.json @@ -159,7 +159,7 @@ "continue": "Carry on" }, "needsPlugin": "Needs {{plugin}}", - "needsPluginHint": "This automation is skipped until the plugin it needs is turned on again.", + "needsPluginHint": "This automation is skipped until the feature it needs is available again.", "offBadge": "Off", "docsLink": "View docs", "ago": { diff --git a/plugins/docker/manifest.json b/plugins/docker/manifest.json index 2a3ac916c..b6f28b353 100644 --- a/plugins/docker/manifest.json +++ b/plugins/docker/manifest.json @@ -53,6 +53,8 @@ ], "settings": { "host": { + "editorGroup": "ssh", + "editorOrder": 30, "enableKey": "enableDocker", "enableLabelKey": "settings.host.enable.label", "fields": [ diff --git a/plugins/file-manager/manifest.json b/plugins/file-manager/manifest.json index 1cdef990f..40e5c66e3 100644 --- a/plugins/file-manager/manifest.json +++ b/plugins/file-manager/manifest.json @@ -71,6 +71,8 @@ ], "settings": { "host": { + "editorGroup": "ssh", + "editorOrder": 60, "enableKey": "enableFileManager", "enableLabelKey": "settings.host.enable.label", "enableDefault": true, diff --git a/plugins/host-metrics/locales/en.json b/plugins/host-metrics/locales/en.json index 59a616e28..b76e474a4 100644 --- a/plugins/host-metrics/locales/en.json +++ b/plugins/host-metrics/locales/en.json @@ -183,7 +183,7 @@ "wgHandshakeNever": "Never", "wgTransfer": "Transfer" }, - "snippetsUnavailable": "The Snippets plugin is off, so quick actions cannot run." + "snippetsUnavailable": "Snippets are turned off, so quick actions cannot run." }, "metricsHistory": { "title": "Metrics History", diff --git a/plugins/opkssh/src/frontend/RedirectUriSetting.tsx b/plugins/opkssh/src/frontend/RedirectUriSetting.tsx index 73ffbc014..c96a3804e 100644 --- a/plugins/opkssh/src/frontend/RedirectUriSetting.tsx +++ b/plugins/opkssh/src/frontend/RedirectUriSetting.tsx @@ -32,7 +32,7 @@ export function RedirectUriSetting({ values }: SettingsComponentProps) { {advancedOpen && ( -
+
{fields.map(({ key, label, unit }) => { const bounds = TOUCH_INPUT_NUMERIC_BOUNDS[key]; return ( diff --git a/plugins/ssh-terminal/src/frontend/terminal/TerminalTabContent.tsx b/plugins/ssh-terminal/src/frontend/terminal/TerminalTabContent.tsx index 4d1873e2e..84b54329b 100644 --- a/plugins/ssh-terminal/src/frontend/terminal/TerminalTabContent.tsx +++ b/plugins/ssh-terminal/src/frontend/terminal/TerminalTabContent.tsx @@ -114,7 +114,7 @@ export function TerminalTabContent({ } isQuickConnect={String(host.id).startsWith("quick-connect-")} onSaveQuickConnect={ - shell.saveQuickConnect + shell.saveQuickConnect && hostRecord?.quickConnectSavable ? () => shell.saveQuickConnect!(tabRecord, hostRecord!) : undefined } diff --git a/plugins/step-ca/locales/en.json b/plugins/step-ca/locales/en.json index 61c6d3993..30af15734 100644 --- a/plugins/step-ca/locales/en.json +++ b/plugins/step-ca/locales/en.json @@ -4,7 +4,7 @@ }, "editor": { "label": "Step CA", - "description": "Sign in through your identity provider and let your Step CA issue a short-lived SSH certificate for this host. The CA URL, root fingerprint and OIDC provisioner are set by an administrator in the Step CA plugin settings.", + "description": "Sign in through your identity provider and let your Step CA issue a short-lived SSH certificate for this host. The CA URL, root fingerprint and OIDC provisioner are set by an administrator in the Step CA section of Admin Settings.", "docs": "View docs" }, "dialog": { diff --git a/plugins/step-ca/src/frontend/RedirectUriSetting.tsx b/plugins/step-ca/src/frontend/RedirectUriSetting.tsx index 5ff1a7ddf..c8b7ce053 100644 --- a/plugins/step-ca/src/frontend/RedirectUriSetting.tsx +++ b/plugins/step-ca/src/frontend/RedirectUriSetting.tsx @@ -32,7 +32,7 @@ export function RedirectUriSetting({ values }: SettingsComponentProps) { +
+ )} - +
+ + {onAddHost && ( + + )} +
); })} diff --git a/plugins/tailscale/src/frontend/TailscaleDevicesStatus.tsx b/plugins/tailscale/src/frontend/TailscaleDevicesStatus.tsx index 338717f7d..adc245466 100644 --- a/plugins/tailscale/src/frontend/TailscaleDevicesStatus.tsx +++ b/plugins/tailscale/src/frontend/TailscaleDevicesStatus.tsx @@ -52,7 +52,7 @@ export function TailscaleDevicesStatus({ running }: SettingsComponentProps) { const label = (key: string) => t(key); return ( -
+
{label("settings.devices.label")} diff --git a/plugins/tailscale/src/frontend/index.tsx b/plugins/tailscale/src/frontend/index.tsx index f34bddb84..602d392ef 100644 --- a/plugins/tailscale/src/frontend/index.tsx +++ b/plugins/tailscale/src/frontend/index.tsx @@ -18,6 +18,11 @@ function Panel({ shell }: PanelProps) { type, ) } + onAddHost={ + shell.openHostEditor + ? (draft) => shell.openHostEditor!(draft) + : undefined + } /> ); } diff --git a/plugins/tailscale/tests/frontend/TailscaleDevicesPanel.test.tsx b/plugins/tailscale/tests/frontend/TailscaleDevicesPanel.test.tsx index 2836b1791..8771ffea0 100644 --- a/plugins/tailscale/tests/frontend/TailscaleDevicesPanel.test.tsx +++ b/plugins/tailscale/tests/frontend/TailscaleDevicesPanel.test.tsx @@ -74,4 +74,75 @@ describe("TailscaleDevicesPanel", () => { expect(host.authType).toBe("tailscale"); expect(host.username).toBe("root"); }); + + const device = { + id: "d1", + name: "box.tailnet.ts.net", + hostname: "box", + addresses: ["100.64.0.1", "fd7a::1"], + os: "linux", + lastSeen: "2026-01-01T00:00:00Z", + }; + + it("opens the host editor with the device filled in", async () => { + const user = userEvent.setup(); + getTailscaleDevicesMock.mockResolvedValue({ + hasApiKey: true, + devices: [device], + }); + + const onAddHost = vi.fn(); + render(); + + 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(); + + 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(); + + 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"); + }); }); diff --git a/plugins/vault/src/frontend/RedirectUriSetting.tsx b/plugins/vault/src/frontend/RedirectUriSetting.tsx index 65b9a0e8b..6ad42faa6 100644 --- a/plugins/vault/src/frontend/RedirectUriSetting.tsx +++ b/plugins/vault/src/frontend/RedirectUriSetting.tsx @@ -32,7 +32,7 @@ export function RedirectUriSetting({ values }: SettingsComponentProps) {
)} - {(railView === "user-profile" || - (railView === "admin-settings" && showAdminUI)) && ( -
- + setRemoteSyncReconnectRequested(false) } + /> +
+ )} + + {railView === "admin-settings" && showAdminUI && ( +
+ { connectHost(host); diff --git a/src/ui/api/plugins-api.ts b/src/ui/api/plugins-api.ts index 11dcdaee1..9f34a410b 100644 --- a/src/ui/api/plugins-api.ts +++ b/src/ui/api/plugins-api.ts @@ -42,6 +42,8 @@ export interface PluginHostSettingsContribution { enableKey?: string; enableLabelKey?: string; enableDefault?: boolean; + editorGroup?: "top" | "ssh"; + editorOrder?: number; fields: PluginSettingsField[]; } @@ -246,30 +248,3 @@ export async function getPlugins(): Promise { const response = await rbacApi.get("/plugins"); return Array.isArray(response.data) ? response.data : []; } - -export async function setPluginEnabled( - pluginId: string, - enabled: boolean, -): Promise { - await rbacApi.patch(`/plugins/${encodeURIComponent(pluginId)}/state`, { - enabled, - }); -} - -export async function grantPluginCapability( - pluginId: string, - capability: string, -): Promise { - await rbacApi.post(`/plugins/${encodeURIComponent(pluginId)}/grants`, { - capability, - }); -} - -export async function revokePluginCapability( - pluginId: string, - capability: string, -): Promise { - await rbacApi.delete( - `/plugins/${encodeURIComponent(pluginId)}/grants/${encodeURIComponent(capability)}`, - ); -} diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index d7f70d58a..5dace99a4 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -1067,8 +1067,8 @@ "filterAuthAgent": "SSH Agent", "filterAuthVault": "Vault", "filterAuthTailscale": "Tailscale", - "authTypeNeedsPlugin": "This host uses {{type}}, which needs the {{plugin}} plugin. Turn it on to connect.", - "authTypeUnknown": "This host uses {{type}}, which no enabled plugin provides.", + "authTypeNeedsPlugin": "This host uses {{type}}, which needs {{plugin}}. It is turned off on this server.", + "authTypeUnknown": "This host uses {{type}}, which is not available on this server.", "authFieldsAfterSave": "Save the host first, then fill in these fields.", "enablePluginFeature": "Enable {{name}}", "disablePluginFeature": "Disable {{name}}", @@ -1099,7 +1099,7 @@ "navigate": "Navigate", "select": "Select", "toggleWith": "Toggle with", - "pluginActions": "From plugins" + "pluginActions": "More actions" }, "splitScreen": { "paneEmpty": "Pane {{index}} - empty", @@ -1527,7 +1527,7 @@ "brandingLogoInvalid": "Invalid image. Use a PNG, JPEG or WEBP file up to 750 KB.", "brandingSaved": "Branding settings saved", "brandingSaveFailed": "Failed to save branding settings", - "sslDescription": "The certificate Termix serves over HTTPS. Upload your own here, or let the ACME Certificates plugin get and renew one from Let's Encrypt.", + "sslDescription": "The certificate Termix serves over HTTPS. Upload your own here, or get and renew one from Let's Encrypt in the ACME Certificates section.", "sslDocsLink": "View SSL docs", "sslCertStatus": "Certificate Status", "sslCertStatusNone": "No certificate", @@ -1811,7 +1811,7 @@ "saveError": "Failed to save role permissions", "save": "Save", "saving": "Saving...", - "pluginDisabled": "Plugin disabled", + "pluginDisabled": "Feature disabled", "groups": { "hosts": "Hosts", "credentials": "Credentials", @@ -1821,30 +1821,15 @@ "notificationPrivateEndpoints": "Allowed private notification hosts", "notificationPrivateEndpointsDesc": "Exact private hosts that notification channels may contact. Separate them with commas.", "updateNotificationEndpointsFailed": "Failed to update notification endpoint allowlist", - "plugins": "Plugins", - "pluginsNone": "No plugins installed.", - "pluginsLoadFailed": "Failed to load plugins", - "pluginToggleFailed": "Failed to change the plugin", - "pluginBuiltIn": "BUILT IN", - "pluginEnabled": "{{name}} enabled", - "pluginDisabled": "{{name}} disabled", - "pluginPermissions": "Permissions", - "pluginPermissionsNone": "This plugin does not declare any capabilities that need to be granted.", - "pluginPublicRoutes": "Public routes", - "pluginPublicRoutesDescription": "Anyone can reach these without signing in. The plugin checks access on its own.", - "pluginPermissionGranted": "Granted", - "pluginPermissionNotGranted": "Not granted", - "pluginGrantFailed": "Failed to grant the capability", - "pluginRevokeFailed": "Failed to revoke the capability", "secondFactorsSectionTitle": "Second factors", "secondFactorsNone": "No second factors enrolled", - "secondFactorUnavailable": "The {{plugin}} plugin is off", + "secondFactorUnavailable": "Unavailable", "resetSecondFactors": "Reset second factors", "resetSecondFactorsConfirm": "Remove every second factor for {{username}}? They can sign in with their password or SSO and enrol again.", "secondFactorsResetSuccess": "Second factors reset", "secondFactorsResetFailed": "Failed to reset second factors", "passwordLoginForced": "Password login is turned off, but no other login method is enabled, so it stays on for now. Nobody gets locked out.", - "sslNotRenewed": "Nothing renews this certificate. It expires {{date}}. Turn on the ACME Certificates plugin and its automatic renewal, or upload a new one before then.", + "sslNotRenewed": "Nothing renews this certificate. It expires {{date}}. Turn on automatic renewal in the ACME Certificates section, or upload a new one before then.", "sslSelfSigned": "Self-signed certificate made by Termix. Browsers will warn about it.", "sslRenewedBy": "Renewed automatically by {{plugin}}", "sslIssuer": "Issued by {{issuer}}", @@ -2506,36 +2491,26 @@ } }, "runtime": { - "needsPlugin": "This needs the {{name}} plugin", - "needsUnknownPlugin": "This needs a plugin that is not installed", - "disabledHint": "An admin can turn it on in Settings under Plugins.", - "missingHint": "It was added by a plugin that has since been removed.", - "failed": "The {{name}} plugin could not start", - "failedHint": "An admin can see why in Settings under Plugins.", + "needsPlugin": "{{name}} is not available", + "needsUnknownPlugin": "This feature is not available", + "disabledHint": "It is turned off on this server.", + "missingHint": "The feature it came from has been removed.", + "failed": "{{name}} could not start", + "failedHint": "An admin can check the server logs for details.", "loading": "Loading {{name}}", - "crashed": "Something went wrong in the {{name}} plugin", + "crashed": "Something went wrong in {{name}}", "retry": "Try again" } }, "settings": { - "title": "Settings", - "bandYou": "You", - "bandPlugins": "Plugins", - "bandInstance": "Instance", - "account": "Account", "secretSet": "Set", "secretNotSet": "Not set", "secretReplace": "Replace", - "pluginGroupAdmin": "Server settings", - "pluginGroupUser": "Your settings", - "pluginNoSettings": "{{name}} has no settings you can change.", - "pluginStoppedNotice": "{{name}} is installed but not running. These settings are saved but will not take effect.", "pluginSettingsSaved": "Settings saved", "pluginSettingsInvalid": "Some settings could not be saved. Check the highlighted fields.", "pluginSettingsSaveFailed": "Failed to save settings", "pluginSettingsLoadFailed": "Failed to load settings", - "pluginsGroupLabel": "Plugins", - "hostPluginNotRunning": "{{name}} is installed but not running. These settings are saved but will not take effect." + "featureUnavailable": "{{name}} is unavailable right now. These settings are saved but will not take effect." }, "managerCard": { "refresh": "Refresh", diff --git a/src/ui/plugin-host/PluginViewPlaceholder.tsx b/src/ui/plugin-host/PluginViewPlaceholder.tsx index 52b92f66f..54082d494 100644 --- a/src/ui/plugin-host/PluginViewPlaceholder.tsx +++ b/src/ui/plugin-host/PluginViewPlaceholder.tsx @@ -1,4 +1,4 @@ -import { Puzzle } from "lucide-react"; +import { CircleSlash } from "lucide-react"; import { useTranslation } from "react-i18next"; import { usePluginStore } from "./plugin-store"; import { unregisteredViewStatus, type ViewKind } from "./view-ownership"; @@ -55,7 +55,7 @@ export function PluginViewPlaceholder({ data-status={status} className={`flex h-full w-full flex-col items-center justify-center gap-2 text-center ${compact ? "p-3" : "p-6"}`} > - + {title} diff --git a/src/ui/plugin-host/loader.ts b/src/ui/plugin-host/loader.ts index 628f011ee..ad9fba2a5 100644 --- a/src/ui/plugin-host/loader.ts +++ b/src/ui/plugin-host/loader.ts @@ -16,7 +16,7 @@ import { setPluginSummaries, } from "./plugin-store"; import { workspaceFrontends, workspaceLocales } from "./workspace-plugins"; -import { syncHostPluginsTab } from "@/settings/host-plugins-tab"; +import { syncHostFeatureTabs } from "@/settings/host-feature-tabs"; /** * Loads plugin frontends into the running shell. @@ -273,8 +273,6 @@ async function loadNamespaces(summaries: PluginSummary[]): Promise { async function reconcile(summaries: PluginSummary[]): Promise { setPluginSummaries(summaries); - // The host editor's Plugins tab exists only while something fills it. - syncHostPluginsTab(summaries); await loadNamespaces(summaries); const { order, blocked } = orderForActivation(summaries); @@ -301,6 +299,9 @@ async function reconcile(summaries: PluginSummary[]): Promise { } await activateConcurrently(order); + // After activation, so a plugin that registered its own host editor + // section is not given a second, generated one. + syncHostFeatureTabs(summaries); } /** diff --git a/src/ui/plugin-host/shell-bridge.ts b/src/ui/plugin-host/shell-bridge.ts index 4fa7f792c..a90e9c4f9 100644 --- a/src/ui/plugin-host/shell-bridge.ts +++ b/src/ui/plugin-host/shell-bridge.ts @@ -87,6 +87,7 @@ export const shell: TabShellCallbacks = { renameTab: (...args) => callbacks?.renameTab(...args), openRailView: (...args) => callbacks?.openRailView(...args), closeRailView: (...args) => callbacks?.closeRailView(...args), + openHostEditor: (...args) => callbacks?.openHostEditor?.(...args), saveQuickConnect: (...args) => callbacks?.saveQuickConnect?.(...args) ?? Promise.resolve(), }; diff --git a/src/ui/plugin-host/testing-host.tsx b/src/ui/plugin-host/testing-host.tsx index c7be25c7b..388af1507 100644 --- a/src/ui/plugin-host/testing-host.tsx +++ b/src/ui/plugin-host/testing-host.tsx @@ -80,6 +80,7 @@ function recordingShell(calls: ShellCall[]): TabShellCallbacks { renameTab: record("renameTab"), openRailView: record("openRailView"), closeRailView: record("closeRailView"), + openHostEditor: record("openHostEditor"), saveQuickConnect: async (...args) => { calls.push({ method: "saveQuickConnect", args }); }, diff --git a/src/ui/settings/PluginSettingsPage.tsx b/src/ui/settings/FeatureSettingsSections.tsx similarity index 53% rename from src/ui/settings/PluginSettingsPage.tsx rename to src/ui/settings/FeatureSettingsSections.tsx index 65f97537e..2d7279fe5 100644 --- a/src/ui/settings/PluginSettingsPage.tsx +++ b/src/ui/settings/FeatureSettingsSections.tsx @@ -1,10 +1,7 @@ +/* eslint-disable react-refresh/only-export-components */ /** - * One plugin's settings page, drawn from its manifest. - * - * The plugin declares groups and fields; Termix renders them with the same - * SectionCard and SettingRow everything else uses. A plugin cannot ship its - * own form styling and drift from the rest of the app, and a page disappears - * cleanly when the plugin is uninstalled. + * Manifest-declared settings shown as ordinary sections in the profile and + * admin panels, one per feature, named after it. */ import { useCallback, useEffect, useMemo, useState } from "react"; @@ -13,9 +10,11 @@ import { Loader2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/button"; import { SectionCard } from "@/components/section-card"; +import { AccordionSection } from "@/sidebar/AdminSettingsShared"; import { pluginKey } from "@/lib/plugin-i18n"; import { PluginIcon } from "@/lib/plugin-icon"; import { + getPlugins, getPluginAdminSettings, getPluginUserSettings, updatePluginAdminSettings, @@ -26,83 +25,82 @@ import { type PluginSummary, } from "@/api/plugins-api"; import { SettingsFieldRow } from "./SettingsFields"; -import { isFieldActive } from "./settings-fields-util"; +import { hasVisibleFields, isFieldActive } from "./settings-fields-util"; -interface PluginSettingsPageProps { - plugin: PluginSummary; - isAdmin: boolean; +export type FeatureSettingsScope = "admin" | "user"; + +export type FeatureSectionId = `feature:${string}`; + +export function featureSectionId(pluginId: string): FeatureSectionId { + return `feature:${pluginId}`; } -type Scope = "admin" | "user"; +/** Enabled features with fields in this scope, sorted by name. */ +export function useFeatureSettings( + scope: FeatureSettingsScope, +): PluginSummary[] { + const [plugins, setPlugins] = useState([]); -export function PluginSettingsPage({ - plugin, - isAdmin, -}: PluginSettingsPageProps) { - const { t } = useTranslation(); - const settings = plugin.contributes?.settings; - const running = plugin.enabled && plugin.state !== "failed"; + useEffect(() => { + let cancelled = false; + void getPlugins() + .then((loaded) => { + if (!cancelled) setPlugins(loaded); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); - // Admin fields are not merely disabled for a non-admin: the values behind - // them are install-wide configuration they have no business reading. - const scopes = useMemo(() => { - const entries: { scope: Scope; fields: PluginSettingsField[] }[] = []; - if (isAdmin && (settings?.admin?.length ?? 0) > 0) { - entries.push({ scope: "admin", fields: settings!.admin! }); - } - if ((settings?.user?.length ?? 0) > 0) { - entries.push({ scope: "user", fields: settings!.user! }); - } - return entries; - }, [settings, isAdmin]); - - if (scopes.length === 0) { - return ( -
-

- {t("settings.pluginNoSettings", { name: plugin.name })} -

-
- ); - } - - return ( -
- {!running && ( -
- {t("settings.pluginStoppedNotice", { name: plugin.name })} -
- )} - - {scopes.map(({ scope, fields }) => ( - - ))} -
+ return useMemo( + () => + plugins + .filter( + (plugin) => + plugin.enabled && + hasVisibleFields(plugin.contributes?.settings?.[scope] ?? []), + ) + .sort((a, b) => a.name.localeCompare(b.name)), + [plugins, scope], ); } -/** - * One scope's form. - * - * Admin and user settings are separate requests with separate permissions, so - * they save separately rather than sharing one dirty state. - */ -function PluginScopeForm({ +export function FeatureSettingsSection({ + plugin, + scope, + open, + onToggle, +}: { + plugin: PluginSummary; + scope: FeatureSettingsScope; + open: boolean; + onToggle: () => void; +}) { + return ( + } + open={open} + onToggle={onToggle} + > + + + ); +} + +function FeatureSettingsForm({ plugin, scope, fields, - running, }: { plugin: PluginSummary; - scope: Scope; + scope: FeatureSettingsScope; fields: PluginSettingsField[]; - running: boolean; }) { const { t } = useTranslation(); const [values, setValues] = useState>({}); @@ -110,6 +108,7 @@ function PluginScopeForm({ const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [dirty, setDirty] = useState(false); + const running = plugin.enabled && plugin.state !== "failed"; const load = useCallback(async () => { setLoading(true); @@ -166,13 +165,9 @@ function PluginScopeForm({ } }; - // Fields keep their declared order within a group, and groups appear in the - // order their first field does, so a manifest controls the whole layout. + // Groups appear in the order of their first field, so the manifest sets the layout. const groups = useMemo(() => { - const ordered: { - key: string | undefined; - fields: PluginSettingsField[]; - }[] = []; + const ordered: { key?: string; fields: PluginSettingsField[] }[] = []; for (const field of fields) { const existing = ordered.find((group) => group.key === field.group); if (existing) existing.fields.push(field); @@ -183,46 +178,53 @@ function PluginScopeForm({ if (loading) { return ( -
+
{t("common.loading")}
); } + const rows = (group: PluginSettingsField[]) => + group + .filter((field) => isFieldActive(field, values)) + .map((field) => ( + + )); + const icon = ; return ( - <> - {groups.map((group, index) => ( - - {group.fields - .filter((field) => isFieldActive(field, values)) - .map((field) => ( - - ))} - - ))} +
+ {!running && ( +
+ {t("settings.featureUnavailable", { name: plugin.name })} +
+ )} -
+ {groups.map((group, index) => + group.key ? ( + + {rows(group.fields)} + + ) : ( +
{rows(group.fields)}
+ ), + )} + +
- +
); } diff --git a/src/ui/settings/HostPluginSections.tsx b/src/ui/settings/HostPluginSections.tsx index ca54e5421..9f9f90434 100644 --- a/src/ui/settings/HostPluginSections.tsx +++ b/src/ui/settings/HostPluginSections.tsx @@ -139,7 +139,7 @@ function HostPluginSection({ > {!running && (
- {t("settings.hostPluginNotRunning", { name: plugin.name })} + {t("settings.featureUnavailable", { name: plugin.name })}
)} diff --git a/src/ui/settings/SettingsFields.tsx b/src/ui/settings/SettingsFields.tsx index cc99e6b96..54360c8a9 100644 --- a/src/ui/settings/SettingsFields.tsx +++ b/src/ui/settings/SettingsFields.tsx @@ -80,20 +80,49 @@ export function SettingsFieldRow({ {error} ) : undefined; + const control = ( + + ); + + // A switch fits beside its label. Anything wider goes underneath, since + // these rows also render in a narrow sidebar. + if (field.type === "boolean") { + return ( + + {control} + + ); + } + return ( - - - +
+
+ + {label(field.labelKey)} + + {error && ( + + {t("common.error")} + + )} +
+ {description && ( + + {description} + + )} +
{control}
+
); } @@ -130,7 +159,7 @@ function FieldControl({ return ( setValue(field.key, next)} > - + @@ -168,13 +197,12 @@ function FieldControl({ case "multiselect": { const selected = Array.isArray(value) ? (value as string[]) : []; return ( -
+
{(field.options ?? []).map((option) => ( ))}
@@ -208,7 +237,7 @@ function FieldControl({ case "json": return (