mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning
This commit is contained in:
1 parent
c21bca8cf0
commit
fbe736bc20
70 files changed
+1117
-1139
No files matched your search
@@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -173,6 +173,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"host": {
|
"host": {
|
||||||
|
"editorGroup": "ssh",
|
||||||
|
"editorOrder": 90,
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
"key": "enableAiAssistant",
|
"key": "enableAiAssistant",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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": []
|
||||||
|
|||||||
@@ -84,6 +84,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"host": {
|
"host": {
|
||||||
|
"editorGroup": "ssh",
|
||||||
|
"editorOrder": 80,
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
"key": "allowSessionSharing",
|
"key": "allowSessionSharing",
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -38,6 +38,8 @@
|
|||||||
],
|
],
|
||||||
"settings": {
|
"settings": {
|
||||||
"host": {
|
"host": {
|
||||||
|
"editorGroup": "top",
|
||||||
|
"editorOrder": 90,
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
"key": "macAddress",
|
"key": "macAddress",
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"host": {
|
"host": {
|
||||||
|
"editorGroup": "ssh",
|
||||||
|
"editorOrder": 95,
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
"key": "useWarpgate",
|
"key": "useWarpgate",
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 });
|
||||||
},
|
},
|
||||||
|
|||||||
+108
-106
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+67
-33
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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/] } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in new issue
Block a user