mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: give plugins their own settings and one settings screen
This commit is contained in:
1 parent
cc15559ea1
commit
49f0286a14
66 files changed
+36497
-451
No files matched your search
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE `plugin_settings` (
|
||||||
|
`id` int AUTO_INCREMENT NOT NULL,
|
||||||
|
`plugin_id` varchar(255) NOT NULL,
|
||||||
|
`scope` varchar(255) NOT NULL,
|
||||||
|
`scope_id` varchar(255),
|
||||||
|
`key` varchar(255) NOT NULL,
|
||||||
|
`value` text,
|
||||||
|
`encrypted` boolean NOT NULL DEFAULT false,
|
||||||
|
`updated_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
CONSTRAINT `plugin_settings_id` PRIMARY KEY(`id`),
|
||||||
|
CONSTRAINT `idx_plugin_settings_scope_key` UNIQUE(`plugin_id`,`scope`,`scope_id`,`key`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `plugin_settings` ADD CONSTRAINT `plugin_settings_plugin_id_plugins_id_fk` FOREIGN KEY (`plugin_id`) REFERENCES `plugins`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_plugin_settings_plugin_scope` ON `plugin_settings` (`plugin_id`,`scope`);
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -204,6 +204,13 @@
|
|||||||
"when": 1790114765594,
|
"when": 1790114765594,
|
||||||
"tag": "0028_fair_tusk",
|
"tag": "0028_fair_tusk",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 29,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1790118361160,
|
||||||
|
"tag": "0029_open_captain_america",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE "plugin_settings" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"plugin_id" varchar(255) NOT NULL,
|
||||||
|
"scope" varchar(255) NOT NULL,
|
||||||
|
"scope_id" varchar(255),
|
||||||
|
"key" varchar(255) NOT NULL,
|
||||||
|
"value" text,
|
||||||
|
"encrypted" boolean DEFAULT false NOT NULL,
|
||||||
|
"updated_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "plugin_settings" ADD CONSTRAINT "plugin_settings_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "idx_plugin_settings_scope_key" ON "plugin_settings" USING btree ("plugin_id","scope","scope_id","key");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_plugin_settings_plugin_scope" ON "plugin_settings" USING btree ("plugin_id","scope");
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -211,6 +211,13 @@
|
|||||||
"when": 1790114765114,
|
"when": 1790114765114,
|
||||||
"tag": "0029_nosy_roulette",
|
"tag": "0029_nosy_roulette",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 30,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1790118360674,
|
||||||
|
"tag": "0030_cynical_callisto",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE `plugin_settings` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`plugin_id` text NOT NULL,
|
||||||
|
`scope` text NOT NULL,
|
||||||
|
`scope_id` text,
|
||||||
|
`key` text NOT NULL,
|
||||||
|
`value` text,
|
||||||
|
`encrypted` integer DEFAULT false NOT NULL,
|
||||||
|
`updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
FOREIGN KEY (`plugin_id`) REFERENCES `plugins`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `idx_plugin_settings_scope_key` ON `plugin_settings` (`plugin_id`,`scope`,`scope_id`,`key`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_plugin_settings_plugin_scope` ON `plugin_settings` (`plugin_id`,`scope`);
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -183,6 +183,13 @@
|
|||||||
"when": 1790114764617,
|
"when": 1790114764617,
|
||||||
"tag": "0025_overrated_cable",
|
"tag": "0025_overrated_cable",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 26,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1790118360159,
|
||||||
|
"tag": "0026_fair_sauron",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -32,6 +32,7 @@ dependencies. Entry points:
|
|||||||
| `@termix/plugin-sdk/frontend` | The `app` object types (stubs until A7) |
|
| `@termix/plugin-sdk/frontend` | The `app` object types (stubs until A7) |
|
||||||
| `@termix/plugin-sdk/manifest` | Manifest types, validation, the JSON schema |
|
| `@termix/plugin-sdk/manifest` | Manifest types, validation, the JSON schema |
|
||||||
| `@termix/plugin-sdk/capabilities` | The capability catalog |
|
| `@termix/plugin-sdk/capabilities` | The capability catalog |
|
||||||
|
| `@termix/plugin-sdk/settings` | Settings field types and the shared value validator |
|
||||||
| `@termix/plugin-sdk/testing` | Test helpers, incl. `createFakeContext()` |
|
| `@termix/plugin-sdk/testing` | Test helpers, incl. `createFakeContext()` |
|
||||||
|
|
||||||
Plugins never import from `src/` or `@/`, and core never imports from
|
Plugins never import from `src/` or `@/`, and core never imports from
|
||||||
@@ -205,10 +206,68 @@ by an authenticated route).
|
|||||||
### 7. Settings
|
### 7. Settings
|
||||||
|
|
||||||
Plugins declare settings fields in the manifest with scope `admin`, `user` or
|
Plugins declare settings fields in the manifest with scope `admin`, `user` or
|
||||||
`host`. Core renders them from the schema (with an optional custom component),
|
`host`. Core renders them from the schema, stores values in `plugin_settings`,
|
||||||
stores values in `plugin_settings`, and encrypts secret fields. One Settings
|
and encrypts secret fields.
|
||||||
screen has a Plugins section with a page per plugin; host settings render in
|
|
||||||
the host editor. **A6.**
|
**A6 delivered this.** A field is `{ key, type, labelKey, ... }`, with types
|
||||||
|
`boolean`, `string`, `number`, `select`, `multiselect`, `secret`, `textarea`,
|
||||||
|
`json` and `custom`. `requires` names a boolean in the same scope that gates
|
||||||
|
it, `group` is a section heading, and `permission` is who may write it: admin
|
||||||
|
fields fall back to `admin.plugins.manage`, and a short name resolves against
|
||||||
|
the plugin's own permissions exactly as `ctx.rbac` does.
|
||||||
|
|
||||||
|
`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
|
||||||
|
narrow: a plugin supplies data everywhere else, so it cannot ship its own form
|
||||||
|
styling and drift from the rest of the app, and a page disappears cleanly when
|
||||||
|
its plugin does.
|
||||||
|
|
||||||
|
**One service, two callers.** `src/backend/plugins/settings.ts` is behind both
|
||||||
|
`ctx.settings` and the HTTP routes, so validation, encryption and redaction are
|
||||||
|
decided once. Two implementations would drift, and the one that drifted would
|
||||||
|
be the one writing to the database.
|
||||||
|
|
||||||
|
**The manifest is the schema.** A read is driven by the declared fields, not by
|
||||||
|
the stored rows: an unwritten field comes back with its `default`, and a row
|
||||||
|
for a field the plugin has since dropped is ignored rather than handed to the
|
||||||
|
UI. A write to a key the manifest never declared is refused, which is what
|
||||||
|
stops a PUT from filling a plugin's namespace with arbitrary keys.
|
||||||
|
|
||||||
|
**Secrets never leave the server.** A `secret` field is encrypted with
|
||||||
|
`encryptSystemSecret` (the installation key, not a user DEK: these have to be
|
||||||
|
readable before any user is unlocked) and every HTTP read replaces it with
|
||||||
|
`{ set: boolean }`. Sending that marker back is a no-op, so saving a form
|
||||||
|
cannot clear a key it was never shown. Clearing one writes null rather than an
|
||||||
|
encrypted empty string, so it reads back as unset.
|
||||||
|
|
||||||
|
**Storage.** `plugin_settings(plugin_id, scope, scope_id, key, value,
|
||||||
|
encrypted, updated_at)`, unique on the first four. `scope_id` is null for
|
||||||
|
admin, a user id for user and a host id as text for host. It is polymorphic, so
|
||||||
|
it carries no foreign key and the engine cannot cascade it: `UserRepository`
|
||||||
|
and `HostRepository` delete these rows explicitly, with tests that fail if
|
||||||
|
those calls ever go. The foreign key to `plugins` is real, so uninstalling
|
||||||
|
leaves nothing.
|
||||||
|
|
||||||
|
**Routes**, all with core auth: `GET|PUT /plugins/:id/settings/admin`
|
||||||
|
(`admin.plugins.manage`, or the field's own permission), `/settings/user`
|
||||||
|
(always the caller, never a user id from the body) and
|
||||||
|
`/settings/host/:hostId` (needs edit access to that host). A rejected PUT
|
||||||
|
returns `400 { errors: { <key>: message } }` per field, so a form shows every
|
||||||
|
problem at once.
|
||||||
|
|
||||||
|
**Host payloads carry them.** Host list and get responses include a
|
||||||
|
`pluginSettings` map, enabled plugins only and secrets redacted, built in one
|
||||||
|
query for the whole list rather than one request per plugin per host. It is
|
||||||
|
attached after the sanitizers, because the connect-level projection of a shared
|
||||||
|
host reduces it to an allowlist.
|
||||||
|
|
||||||
|
`ctx.settings` is `get/set`, `getUser/setUser`, `getHost/setHost`, `getAll`,
|
||||||
|
`onChange` (disposed with the plugin) and `readCore`. **Reading and writing a
|
||||||
|
plugin's own settings needs no capability**: the manifest already declares
|
||||||
|
every field, and a plugin that had to ask permission to read its own
|
||||||
|
configuration would be useless. Only `readCore`, which reaches outside the
|
||||||
|
plugin's namespace, is gated on `settings:read-core`, and it serves a short
|
||||||
|
allowlist (`CORE_SETTINGS_ALLOWLIST`) rather than the whole settings table.
|
||||||
|
|
||||||
### 8. UI
|
### 8. UI
|
||||||
|
|
||||||
@@ -296,6 +355,32 @@ plugins/<id>/
|
|||||||
Every move of data, settings or host columns into a plugin comes with a
|
Every move of data, settings or host columns into a plugin comes with a
|
||||||
migration for all three dialects and a test proving existing data survives.
|
migration for all three dialects and a test proving existing data survives.
|
||||||
|
|
||||||
|
#### Moving a host column into a plugin
|
||||||
|
|
||||||
|
A6 built the host-scope settings path but moved no columns; each Phase B step
|
||||||
|
moves its own. `ssh_data` still holds `enable_docker`, `stats_config`,
|
||||||
|
`web_ui_config`, `enable_proxmox`, `enable_rdp`, `mac_address` and the rest,
|
||||||
|
and the hardcoded host editor tabs still read them. The order matters, because
|
||||||
|
dropping a column before its data has moved loses it:
|
||||||
|
|
||||||
|
1. Declare the fields in the plugin's `contributes.settings.host`, keeping the
|
||||||
|
key names the form already uses so the editor tab can be deleted rather than
|
||||||
|
rewritten.
|
||||||
|
2. Write a boot migration that copies each row's column into host-scope
|
||||||
|
`plugin_settings` for that plugin, keyed by the host id as text. Make it
|
||||||
|
idempotent by checking for an existing row first, the way
|
||||||
|
`tailscale-settings-migration.ts` does, and cover it with a test that runs
|
||||||
|
it twice.
|
||||||
|
3. Switch the plugin's backend to `ctx.settings.getHost` and delete the
|
||||||
|
hardcoded editor tab; the schema-driven Plugins group replaces it.
|
||||||
|
4. Only then drop the column, in lockstep across all four artifacts: the
|
||||||
|
`schema.ts` declaration, the `db/index.ts` bootstrap DDL, a drizzle
|
||||||
|
migration per dialect, and `npm run schema:generate` for the pg and mysql
|
||||||
|
variants. The two bootstrap guard tests fail if these disagree.
|
||||||
|
|
||||||
|
A column that is still read anywhere in core is not ready to be dropped. Ship
|
||||||
|
the copy and the switch first, and drop it in a later step if that is safer.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Manifest v2 reference
|
## Manifest v2 reference
|
||||||
@@ -370,6 +455,52 @@ not do.
|
|||||||
"defaultRoles": ["user"], // system roles only, applied once
|
"defaultRoles": ["user"], // system roles only, applied once
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"settings": {
|
||||||
|
"admin": [
|
||||||
|
{
|
||||||
|
"key": "apiKey",
|
||||||
|
"type": "secret", // encrypted at rest, never sent to a browser
|
||||||
|
"labelKey": "settings.apiKey.label",
|
||||||
|
"descriptionKey": "settings.apiKey.description",
|
||||||
|
"placeholderKey": "settings.apiKey.placeholder",
|
||||||
|
"group": "settings.group.api", // section heading
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "retries",
|
||||||
|
"type": "number",
|
||||||
|
"labelKey": "k",
|
||||||
|
"min": 0,
|
||||||
|
"max": 5,
|
||||||
|
"default": 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "browser",
|
||||||
|
"type": "custom", // a registered component, for what a schema cannot express
|
||||||
|
"component": "devices",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"user": [
|
||||||
|
{
|
||||||
|
"key": "advanced",
|
||||||
|
"type": "boolean",
|
||||||
|
"labelKey": "k",
|
||||||
|
"permission": "tweak", // one of this plugin's own permissions
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"host": {
|
||||||
|
"enableKey": "enableExample", // boolean rendered first, gating the rest
|
||||||
|
"enableLabelKey": "k",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"key": "port",
|
||||||
|
"type": "number",
|
||||||
|
"labelKey": "k",
|
||||||
|
"requires": "enableExample",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
"hostCapability": {
|
"hostCapability": {
|
||||||
"key": "enableExample",
|
"key": "enableExample",
|
||||||
"labelKey": "k",
|
"labelKey": "k",
|
||||||
@@ -389,6 +520,13 @@ Networking, Access & Security, Productivity.
|
|||||||
|
|
||||||
`openFrom` is one of: rail, host-context-menu, palette.
|
`openFrom` is one of: rail, host-context-menu, palette.
|
||||||
|
|
||||||
|
Field types are `boolean`, `string`, `number`, `select`, `multiselect`,
|
||||||
|
`secret`, `textarea`, `json` and `custom`. `labelKey` is required for every
|
||||||
|
type but `custom`, which needs `component` instead; `options` is required and
|
||||||
|
non-empty for `select` and `multiselect` and rejected elsewhere; `min`/`max`
|
||||||
|
belong to `number` alone; and `requires` must name a boolean field in the same
|
||||||
|
scope, which includes the host section's `enableKey`.
|
||||||
|
|
||||||
Cross-field rules the JSON schema cannot express, checked by `parseManifest`:
|
Cross-field rules the JSON schema cannot express, checked by `parseManifest`:
|
||||||
|
|
||||||
- `engine.api` must match the SDK major version this build implements.
|
- `engine.api` must match the SDK major version this build implements.
|
||||||
@@ -403,6 +541,9 @@ Cross-field rules the JSON schema cannot express, checked by `parseManifest`:
|
|||||||
- Every `contributes.permissions[].name` must be unique, must not start with a
|
- Every `contributes.permissions[].name` must be unique, must not start with a
|
||||||
core group (`hosts`, `snippets`, `credentials`, `admin`) or with the plugin's
|
core group (`hosts`, `snippets`, `credentials`, `admin`) or with the plugin's
|
||||||
own id, and every `defaultRoles` entry must be `admin` or `user`.
|
own id, and every `defaultRoles` entry must be `admin` or `user`.
|
||||||
|
- A `contributes.settings` field `permission` written as a bare short name must
|
||||||
|
appear in the plugin's own `contributes.permissions`. A dotted id is taken as
|
||||||
|
given, because the validator cannot know which other plugins exist.
|
||||||
|
|
||||||
`contributes.hostCapability`, `provides`, `requires`, `providesSecret` and
|
`contributes.hostCapability`, `provides`, `requires`, `providesSecret` and
|
||||||
`requiresSecret` are carried forward from v1 unchanged because the service and
|
`requiresSecret` are carried forward from v1 unchanged because the service and
|
||||||
@@ -469,28 +610,28 @@ deciding, not the mechanism.
|
|||||||
|
|
||||||
Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
|
Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
|
||||||
|
|
||||||
| Member | Capability | Status |
|
| Member | Capability | Status |
|
||||||
| ------------------------------------------------ | -------------------------------- | ------ |
|
| ------------------------------------------------ | ------------------------------------ | ------ |
|
||||||
| `ctx.pluginId`, `ctx.manifest` | none | **A1** |
|
| `ctx.pluginId`, `ctx.manifest` | none | **A1** |
|
||||||
| `ctx.log.*` | none | **A1** |
|
| `ctx.log.*` | none | **A1** |
|
||||||
| `ctx.events.emit` / `.on` | `events:core` for core topics | **A1** |
|
| `ctx.events.emit` / `.on` | `events:core` for core topics | **A1** |
|
||||||
| `ctx.kv.get/set/delete/list` | `kv:own` | **A1** |
|
| `ctx.kv.get/set/delete/list` | `kv:own` | **A1** |
|
||||||
| `ctx.registry.*` | none | **A1** |
|
| `ctx.registry.*` | none | **A1** |
|
||||||
| `ctx.services.provide` / `.get` | per-service RBAC | **A1** |
|
| `ctx.services.provide` / `.get` | per-service RBAC | **A1** |
|
||||||
| `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** |
|
| `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** |
|
||||||
| `ctx.disposables.add` | none | **A1** |
|
| `ctx.disposables.add` | none | **A1** |
|
||||||
| `ctx.asUser(userId, fn)` | none, always audited | **A1** |
|
| `ctx.asUser(userId, fn)` | none, always audited | **A1** |
|
||||||
| `ctx.currentActor()` | none | **A1** |
|
| `ctx.currentActor()` | none | **A1** |
|
||||||
| `ctx.db.define` / `.client` / `.refs` | `db:own` | **A3** |
|
| `ctx.db.define` / `.client` / `.refs` | `db:own` | **A3** |
|
||||||
| `ctx.sync.registerEntity` | none | **A3** |
|
| `ctx.sync.registerEntity` | none | **A3** |
|
||||||
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
|
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
|
||||||
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
|
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
|
||||||
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | B |
|
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | B |
|
||||||
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | B |
|
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | B |
|
||||||
| `ctx.settings.*` | `settings:read-core` | A6 |
|
| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** |
|
||||||
| `ctx.notify.*` | `notify:send` | A6 |
|
| `ctx.notify.*` | `notify:send` | A6 |
|
||||||
| `ctx.auth.*` | `auth:provide` | A8 |
|
| `ctx.auth.*` | `auth:provide` | A8 |
|
||||||
| `ctx.fetch` | `network:outbound` | B |
|
| `ctx.fetch` | `network:outbound` | B |
|
||||||
|
|
||||||
### The actor
|
### The actor
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,10 @@
|
|||||||
"types": "./dist/capabilities.d.ts",
|
"types": "./dist/capabilities.d.ts",
|
||||||
"default": "./dist/capabilities.js"
|
"default": "./dist/capabilities.js"
|
||||||
},
|
},
|
||||||
|
"./settings": {
|
||||||
|
"types": "./dist/settings.d.ts",
|
||||||
|
"default": "./dist/settings.js"
|
||||||
|
},
|
||||||
"./testing": {
|
"./testing": {
|
||||||
"types": "./dist/testing.d.ts",
|
"types": "./dist/testing.d.ts",
|
||||||
"default": "./dist/testing.js"
|
"default": "./dist/testing.js"
|
||||||
|
|||||||
@@ -21,24 +21,42 @@
|
|||||||
"pattern": "^[a-z][a-z0-9-]{1,39}$",
|
"pattern": "^[a-z][a-z0-9-]{1,39}$",
|
||||||
"description": "Unique plugin id. Lowercase, starts with a letter, 2-40 characters. Must equal the plugin's directory name."
|
"description": "Unique plugin id. Lowercase, starts with a letter, 2-40 characters. Must equal the plugin's directory name."
|
||||||
},
|
},
|
||||||
"name": { "type": "string", "minLength": 1 },
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
"version": {
|
"version": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z-.]+)?(\\+[0-9A-Za-z-.]+)?$",
|
"pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z-.]+)?(\\+[0-9A-Za-z-.]+)?$",
|
||||||
"description": "Semver version string."
|
"description": "Semver version string."
|
||||||
},
|
},
|
||||||
"description": { "type": "string", "minLength": 1 },
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
"author": {
|
"author": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["name"],
|
"required": ["name"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": { "type": "string", "minLength": 1 },
|
"name": {
|
||||||
"url": { "type": "string", "minLength": 1 }
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"license": { "type": "string", "minLength": 1 },
|
"license": {
|
||||||
"repository": { "type": "string", "minLength": 1 },
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
"category": {
|
"category": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
@@ -106,14 +124,24 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Plugin ids this plugin cannot run without, mapped to a semver range.",
|
"description": "Plugin ids this plugin cannot run without, mapped to a semver range.",
|
||||||
"additionalProperties": { "type": "string", "minLength": 1 },
|
"additionalProperties": {
|
||||||
"propertyNames": { "pattern": "^[a-z][a-z0-9-]{1,39}$" }
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"propertyNames": {
|
||||||
|
"pattern": "^[a-z][a-z0-9-]{1,39}$"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Plugin ids this plugin uses when present. It must keep working without them.",
|
"description": "Plugin ids this plugin uses when present. It must keep working without them.",
|
||||||
"additionalProperties": { "type": "string", "minLength": 1 },
|
"additionalProperties": {
|
||||||
"propertyNames": { "pattern": "^[a-z][a-z0-9-]{1,39}$" }
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"propertyNames": {
|
||||||
|
"pattern": "^[a-z][a-z0-9-]{1,39}$"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"provides": {
|
"provides": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
@@ -130,7 +158,10 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z-.]+)?(\\+[0-9A-Za-z-.]+)?$"
|
"pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z-.]+)?(\\+[0-9A-Za-z-.]+)?$"
|
||||||
},
|
},
|
||||||
"permission": { "type": "string", "minLength": 1 }
|
"permission": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -145,8 +176,13 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"pattern": "^[a-z0-9-]+(\\.[a-z0-9-]+)+$"
|
"pattern": "^[a-z0-9-]+(\\.[a-z0-9-]+)+$"
|
||||||
},
|
},
|
||||||
"versionRange": { "type": "string", "minLength": 1 },
|
"versionRange": {
|
||||||
"optional": { "type": "boolean" }
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"optional": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -157,9 +193,18 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["key", "permission"],
|
"required": ["key", "permission"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"key": { "type": "string", "pattern": "^[a-z0-9-]+$" },
|
"key": {
|
||||||
"permission": { "type": "string", "minLength": 1 },
|
"type": "string",
|
||||||
"descriptionKey": { "type": "string", "minLength": 1 }
|
"pattern": "^[a-z0-9-]+$"
|
||||||
|
},
|
||||||
|
"permission": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"descriptionKey": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -170,9 +215,17 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["plugin", "key"],
|
"required": ["plugin", "key"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"plugin": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,39}$" },
|
"plugin": {
|
||||||
"key": { "type": "string", "pattern": "^[a-z0-9-]+$" },
|
"type": "string",
|
||||||
"optional": { "type": "boolean" }
|
"pattern": "^[a-z][a-z0-9-]{1,39}$"
|
||||||
|
},
|
||||||
|
"key": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z0-9-]+$"
|
||||||
|
},
|
||||||
|
"optional": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -187,9 +240,18 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["id", "titleKey", "icon", "openFrom"],
|
"required": ["id", "titleKey", "icon", "openFrom"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": { "type": "string", "minLength": 1 },
|
"id": {
|
||||||
"titleKey": { "type": "string", "minLength": 1 },
|
"type": "string",
|
||||||
"icon": { "type": "string", "minLength": 1 },
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"titleKey": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"icon": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
"openFrom": {
|
"openFrom": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"minItems": 1,
|
"minItems": 1,
|
||||||
@@ -212,15 +274,30 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"pattern": "^[a-z0-9-]+(\\.[a-zA-Z0-9-]+)+$"
|
"pattern": "^[a-z0-9-]+(\\.[a-zA-Z0-9-]+)+$"
|
||||||
},
|
},
|
||||||
"titleKey": { "type": "string", "minLength": 1 },
|
"titleKey": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
"handler": {
|
"handler": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"pattern": "^[A-Za-z_$][A-Za-z0-9_$]*$"
|
"pattern": "^[A-Za-z_$][A-Za-z0-9_$]*$"
|
||||||
},
|
},
|
||||||
"icon": { "type": "string", "minLength": 1 },
|
"icon": {
|
||||||
"permission": { "type": "string", "minLength": 1 },
|
"type": "string",
|
||||||
"slot": { "type": "string", "minLength": 1 },
|
"minLength": 1
|
||||||
"kind": { "type": "string", "enum": ["button"] }
|
},
|
||||||
|
"permission": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"slot": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["button"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -238,9 +315,15 @@
|
|||||||
"accepts": {
|
"accepts": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"minItems": 1,
|
"minItems": 1,
|
||||||
"items": { "type": "string", "enum": ["button"] }
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["button"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"descriptionKey": { "type": "string", "minLength": 1 }
|
"descriptionKey": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -257,12 +340,320 @@
|
|||||||
"description": "Plugin-relative name. May contain dots. Must not start with a core group or this plugin's id.",
|
"description": "Plugin-relative name. May contain dots. Must not start with a core group or this plugin's id.",
|
||||||
"pattern": "^[a-z0-9_-]+(\\.[a-z0-9_-]+)*$"
|
"pattern": "^[a-z0-9_-]+(\\.[a-z0-9_-]+)*$"
|
||||||
},
|
},
|
||||||
"titleKey": { "type": "string", "minLength": 1 },
|
"titleKey": {
|
||||||
"descriptionKey": { "type": "string", "minLength": 1 },
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"descriptionKey": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
"defaultRoles": {
|
"defaultRoles": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"description": "System roles that hold this the first time it is seen.",
|
"description": "System roles that hold this the first time it is seen.",
|
||||||
"items": { "type": "string", "enum": ["admin", "user"] }
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["admin", "user"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Settings fields core renders on the plugin's settings page and in the host editor.",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"admin": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["key", "type"],
|
||||||
|
"properties": {
|
||||||
|
"key": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Storage key, unique within its scope.",
|
||||||
|
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]{0,63}$"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"boolean",
|
||||||
|
"string",
|
||||||
|
"number",
|
||||||
|
"select",
|
||||||
|
"multiselect",
|
||||||
|
"secret",
|
||||||
|
"textarea",
|
||||||
|
"json",
|
||||||
|
"custom"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"labelKey": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "Required for every type except \"custom\"."
|
||||||
|
},
|
||||||
|
"descriptionKey": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"placeholderKey": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"description": "Value used until one is written."
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "select and multiselect only.",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["value", "labelKey"],
|
||||||
|
"properties": {
|
||||||
|
"value": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"labelKey": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"min": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "number fields only."
|
||||||
|
},
|
||||||
|
"max": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "number fields only."
|
||||||
|
},
|
||||||
|
"requires": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Key of a boolean field in the same scope that must be on."
|
||||||
|
},
|
||||||
|
"permission": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "Permission needed to write it. Admin fields default to admin.plugins.manage."
|
||||||
|
},
|
||||||
|
"group": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "Section heading this field sits under."
|
||||||
|
},
|
||||||
|
"component": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "Registered component id. Required when type is \"custom\"."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["key", "type"],
|
||||||
|
"properties": {
|
||||||
|
"key": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Storage key, unique within its scope.",
|
||||||
|
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]{0,63}$"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"boolean",
|
||||||
|
"string",
|
||||||
|
"number",
|
||||||
|
"select",
|
||||||
|
"multiselect",
|
||||||
|
"secret",
|
||||||
|
"textarea",
|
||||||
|
"json",
|
||||||
|
"custom"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"labelKey": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "Required for every type except \"custom\"."
|
||||||
|
},
|
||||||
|
"descriptionKey": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"placeholderKey": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"description": "Value used until one is written."
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "select and multiselect only.",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["value", "labelKey"],
|
||||||
|
"properties": {
|
||||||
|
"value": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"labelKey": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"min": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "number fields only."
|
||||||
|
},
|
||||||
|
"max": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "number fields only."
|
||||||
|
},
|
||||||
|
"requires": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Key of a boolean field in the same scope that must be on."
|
||||||
|
},
|
||||||
|
"permission": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "Permission needed to write it. Admin fields default to admin.plugins.manage."
|
||||||
|
},
|
||||||
|
"group": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "Section heading this field sits under."
|
||||||
|
},
|
||||||
|
"component": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "Registered component id. Required when type is \"custom\"."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"host": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["fields"],
|
||||||
|
"properties": {
|
||||||
|
"enableKey": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]{0,63}$",
|
||||||
|
"description": "Boolean rendered first, gating the rest of the section."
|
||||||
|
},
|
||||||
|
"enableLabelKey": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["key", "type"],
|
||||||
|
"properties": {
|
||||||
|
"key": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Storage key, unique within its scope.",
|
||||||
|
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]{0,63}$"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"boolean",
|
||||||
|
"string",
|
||||||
|
"number",
|
||||||
|
"select",
|
||||||
|
"multiselect",
|
||||||
|
"secret",
|
||||||
|
"textarea",
|
||||||
|
"json",
|
||||||
|
"custom"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"labelKey": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "Required for every type except \"custom\"."
|
||||||
|
},
|
||||||
|
"descriptionKey": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"placeholderKey": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"description": "Value used until one is written."
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "select and multiselect only.",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["value", "labelKey"],
|
||||||
|
"properties": {
|
||||||
|
"value": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"labelKey": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"min": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "number fields only."
|
||||||
|
},
|
||||||
|
"max": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "number fields only."
|
||||||
|
},
|
||||||
|
"requires": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Key of a boolean field in the same scope that must be on."
|
||||||
|
},
|
||||||
|
"permission": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "Permission needed to write it. Admin fields default to admin.plugins.manage."
|
||||||
|
},
|
||||||
|
"group": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "Section heading this field sits under."
|
||||||
|
},
|
||||||
|
"component": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "Registered component id. Required when type is \"custom\"."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,11 +661,15 @@
|
|||||||
"hostCapability": {
|
"hostCapability": {
|
||||||
"description": "A host-editor checkbox backed by a boolean column on the host record. Reshaped by a later step.",
|
"description": "A host-editor checkbox backed by a boolean column on the host record. Reshaped by a later step.",
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{ "$ref": "#/$defs/hostCapability" },
|
{
|
||||||
|
"$ref": "#/$defs/hostCapability"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"minItems": 1,
|
"minItems": 1,
|
||||||
"items": { "$ref": "#/$defs/hostCapability" }
|
"items": {
|
||||||
|
"$ref": "#/$defs/hostCapability"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -298,7 +693,10 @@
|
|||||||
"platforms": {
|
"platforms": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"minItems": 1,
|
"minItems": 1,
|
||||||
"items": { "type": "string", "enum": ["linux", "win32", "darwin"] }
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["linux", "win32", "darwin"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"$defs": {
|
"$defs": {
|
||||||
@@ -307,9 +705,18 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["key", "labelKey", "editorTab"],
|
"required": ["key", "labelKey", "editorTab"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"key": { "type": "string", "minLength": 1 },
|
"key": {
|
||||||
"labelKey": { "type": "string", "minLength": 1 },
|
"type": "string",
|
||||||
"editorTab": { "type": "string", "minLength": 1 }
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"labelKey": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"editorTab": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -297,6 +297,58 @@ export interface PluginDisposables {
|
|||||||
add: (dispose: () => void | Promise<void>) => void;
|
add: (dispose: () => void | Promise<void>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plugin's own settings, as declared in contributes.settings.
|
||||||
|
*
|
||||||
|
* Reading and writing a plugin's own settings needs no capability: the
|
||||||
|
* manifest already says which fields exist, and a plugin that could not read
|
||||||
|
* its own configuration would be useless. Only readCore, which reaches outside
|
||||||
|
* the plugin's namespace, is gated on settings:read-core.
|
||||||
|
*
|
||||||
|
* Values are validated against the declared field on every write, so a key the
|
||||||
|
* manifest never declared is rejected rather than stored. Secret fields are
|
||||||
|
* encrypted at rest and returned decrypted here; the HTTP surface never sends
|
||||||
|
* them to a browser.
|
||||||
|
*/
|
||||||
|
export interface PluginSettings {
|
||||||
|
/** Install-wide admin settings. */
|
||||||
|
get: <T = unknown>(key: string) => Promise<T | undefined>;
|
||||||
|
set: (key: string, value: unknown) => Promise<void>;
|
||||||
|
|
||||||
|
/** Per-user settings. */
|
||||||
|
getUser: <T = unknown>(userId: string, key: string) => Promise<T | undefined>;
|
||||||
|
setUser: (userId: string, key: string, value: unknown) => Promise<void>;
|
||||||
|
|
||||||
|
/** Per-host settings. */
|
||||||
|
getHost: <T = unknown>(
|
||||||
|
hostId: number | string,
|
||||||
|
key: string,
|
||||||
|
) => Promise<T | undefined>;
|
||||||
|
setHost: (
|
||||||
|
hostId: number | string,
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
/** Every value in one scope, with declared defaults merged in. */
|
||||||
|
getAll: (
|
||||||
|
scope: "admin" | "user" | "host",
|
||||||
|
scopeId?: string | number,
|
||||||
|
) => Promise<Record<string, unknown>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fires when a key in this plugin's settings changes, from either the ctx
|
||||||
|
* API or the HTTP routes. Disposed automatically on deactivate.
|
||||||
|
*/
|
||||||
|
onChange: (key: string, listener: (value: unknown) => void) => () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a core server setting from a small documented allowlist.
|
||||||
|
* Requires the settings:read-core capability.
|
||||||
|
*/
|
||||||
|
readCore: (key: string) => Promise<string | null>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PluginContext {
|
export interface PluginContext {
|
||||||
readonly pluginId: string;
|
readonly pluginId: string;
|
||||||
readonly manifest: PluginManifest;
|
readonly manifest: PluginManifest;
|
||||||
@@ -311,6 +363,7 @@ export interface PluginContext {
|
|||||||
readonly http: PluginHttp;
|
readonly http: PluginHttp;
|
||||||
readonly ws: PluginWebSockets;
|
readonly ws: PluginWebSockets;
|
||||||
readonly rbac: PluginRbac;
|
readonly rbac: PluginRbac;
|
||||||
|
readonly settings: PluginSettings;
|
||||||
readonly disposables: PluginDisposables;
|
readonly disposables: PluginDisposables;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -347,3 +400,8 @@ export function definePlugin(plugin: PluginModule): PluginModule {
|
|||||||
|
|
||||||
export type { PluginManifest } from "./manifest.js";
|
export type { PluginManifest } from "./manifest.js";
|
||||||
export type { PluginTableDefinition } from "./db.js";
|
export type { PluginTableDefinition } from "./db.js";
|
||||||
|
export type {
|
||||||
|
PluginSettingsField,
|
||||||
|
PluginSettingsContribution,
|
||||||
|
PluginSettingsScope,
|
||||||
|
} from "./manifest.js";
|
||||||
@@ -154,11 +154,80 @@ export interface PluginSecretRequire {
|
|||||||
optional?: boolean;
|
optional?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const SETTINGS_FIELD_TYPES = [
|
||||||
|
"boolean",
|
||||||
|
"string",
|
||||||
|
"number",
|
||||||
|
"select",
|
||||||
|
"multiselect",
|
||||||
|
"secret",
|
||||||
|
"textarea",
|
||||||
|
"json",
|
||||||
|
"custom",
|
||||||
|
] as const;
|
||||||
|
export type PluginSettingsFieldType = (typeof SETTINGS_FIELD_TYPES)[number];
|
||||||
|
|
||||||
|
export const SETTINGS_SCOPES = ["admin", "user", "host"] as const;
|
||||||
|
export type PluginSettingsScope = (typeof SETTINGS_SCOPES)[number];
|
||||||
|
|
||||||
|
export interface PluginSettingsOption {
|
||||||
|
value: string;
|
||||||
|
labelKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One field on a plugin's settings page.
|
||||||
|
*
|
||||||
|
* Core renders these; a plugin supplies data only, so it cannot ship its own
|
||||||
|
* form styling and drift from the rest of the app. `type: "custom"` is the
|
||||||
|
* escape hatch for UI a schema cannot express, and names a component the
|
||||||
|
* frontend registered rather than carrying markup.
|
||||||
|
*/
|
||||||
|
export interface PluginSettingsField {
|
||||||
|
key: string;
|
||||||
|
type: PluginSettingsFieldType;
|
||||||
|
/** Required except for "custom", which draws its own label. */
|
||||||
|
labelKey?: string;
|
||||||
|
descriptionKey?: string;
|
||||||
|
placeholderKey?: string;
|
||||||
|
default?: unknown;
|
||||||
|
/** select and multiselect only. */
|
||||||
|
options?: PluginSettingsOption[];
|
||||||
|
/** number only. */
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
/** Key of a boolean field in the same scope that must be on. */
|
||||||
|
requires?: string;
|
||||||
|
/**
|
||||||
|
* Who may write it. Admin fields default to admin.plugins.manage; a short
|
||||||
|
* name resolves against this plugin's own permissions.
|
||||||
|
*/
|
||||||
|
permission?: string;
|
||||||
|
/** Section heading this field sits under. */
|
||||||
|
group?: string;
|
||||||
|
/** Registered component id. Required when type is "custom". */
|
||||||
|
component?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginHostSettingsContribution {
|
||||||
|
/** Boolean field rendered first, gating the rest of the section. */
|
||||||
|
enableKey?: string;
|
||||||
|
enableLabelKey?: string;
|
||||||
|
fields: PluginSettingsField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginSettingsContribution {
|
||||||
|
admin?: PluginSettingsField[];
|
||||||
|
user?: PluginSettingsField[];
|
||||||
|
host?: PluginHostSettingsContribution;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PluginContributions {
|
export interface PluginContributions {
|
||||||
tabs?: PluginTabContribution[];
|
tabs?: PluginTabContribution[];
|
||||||
actions?: PluginActionContribution[];
|
actions?: PluginActionContribution[];
|
||||||
actionSlots?: PluginActionSlot[];
|
actionSlots?: PluginActionSlot[];
|
||||||
permissions?: PluginPermissionContribution[];
|
permissions?: PluginPermissionContribution[];
|
||||||
|
settings?: PluginSettingsContribution;
|
||||||
/**
|
/**
|
||||||
* A host-editor checkbox backed by a boolean column on the host record.
|
* A host-editor checkbox backed by a boolean column on the host record.
|
||||||
* A5/A6/A7 reshape this; it stays in v2 because remote-desktop, docker,
|
* A5/A6/A7 reshape this; it stays in v2 because remote-desktop, docker,
|
||||||
@@ -236,9 +305,29 @@ const ALLOWED_CONTRIBUTES = new Set([
|
|||||||
"actions",
|
"actions",
|
||||||
"actionSlots",
|
"actionSlots",
|
||||||
"permissions",
|
"permissions",
|
||||||
|
"settings",
|
||||||
"hostCapability",
|
"hostCapability",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const ALLOWED_SETTINGS_FIELD = [
|
||||||
|
"key",
|
||||||
|
"type",
|
||||||
|
"labelKey",
|
||||||
|
"descriptionKey",
|
||||||
|
"placeholderKey",
|
||||||
|
"default",
|
||||||
|
"options",
|
||||||
|
"min",
|
||||||
|
"max",
|
||||||
|
"requires",
|
||||||
|
"permission",
|
||||||
|
"group",
|
||||||
|
"component",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Settings keys are stored as-is, so they stay short and index-safe. */
|
||||||
|
const SETTINGS_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
|
||||||
|
|
||||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
@@ -564,9 +653,201 @@ function validateContributes(
|
|||||||
validatePermissions(contributes.permissions, pluginId, errors);
|
validatePermissions(contributes.permissions, pluginId, errors);
|
||||||
validateActions(contributes.actions, errors);
|
validateActions(contributes.actions, errors);
|
||||||
validateActionSlots(contributes.actionSlots, errors);
|
validateActionSlots(contributes.actionSlots, errors);
|
||||||
|
validateSettings(contributes.settings, errors);
|
||||||
validateHostCapability(contributes.hostCapability, errors);
|
validateHostCapability(contributes.hostCapability, errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateSettings(settings: unknown, errors: string[]): void {
|
||||||
|
if (settings === undefined) return;
|
||||||
|
const where = "contributes.settings";
|
||||||
|
if (!isPlainObject(settings)) {
|
||||||
|
errors.push(`${where} must be an object`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rejectUnknown(settings, ["admin", "user", "host"], `"${where}"`, errors);
|
||||||
|
|
||||||
|
for (const scope of ["admin", "user"] as const) {
|
||||||
|
const value = settings[scope];
|
||||||
|
if (value === undefined) continue;
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
errors.push(`${where}.${scope} must be an array of fields`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
validateSettingsFields(value, `${where}.${scope}`, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.host !== undefined) {
|
||||||
|
const host = settings.host;
|
||||||
|
const at = `${where}.host`;
|
||||||
|
if (!isPlainObject(host)) {
|
||||||
|
errors.push(`${at} must be an object`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rejectUnknown(
|
||||||
|
host,
|
||||||
|
["enableKey", "enableLabelKey", "fields"],
|
||||||
|
`"${at}"`,
|
||||||
|
errors,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ("enableKey" in host) {
|
||||||
|
if (
|
||||||
|
typeof host.enableKey !== "string" ||
|
||||||
|
!SETTINGS_KEY_PATTERN.test(host.enableKey)
|
||||||
|
) {
|
||||||
|
errors.push(`${at}.enableKey must be a short alphanumeric key`);
|
||||||
|
}
|
||||||
|
// An enable switch with no label is a blank row in the host editor.
|
||||||
|
requireString(host.enableLabelKey, `${at}.enableLabelKey`, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(host.fields)) {
|
||||||
|
errors.push(`${at}.fields must be an array`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
validateSettingsFields(
|
||||||
|
host.fields,
|
||||||
|
`${at}.fields`,
|
||||||
|
errors,
|
||||||
|
typeof host.enableKey === "string" ? host.enableKey : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates one scope's fields.
|
||||||
|
*
|
||||||
|
* `requires` is checked against the keys declared in the same scope, plus the
|
||||||
|
* host scope's enableKey, because a field pointing at a key that does not
|
||||||
|
* exist would simply never render.
|
||||||
|
*/
|
||||||
|
function validateSettingsFields(
|
||||||
|
fields: unknown[],
|
||||||
|
where: string,
|
||||||
|
errors: string[],
|
||||||
|
enableKey?: string,
|
||||||
|
): void {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const booleanKeys = new Set<string>();
|
||||||
|
if (enableKey) booleanKeys.add(enableKey);
|
||||||
|
|
||||||
|
fields.forEach((raw, index) => {
|
||||||
|
const at = `${where}[${index}]`;
|
||||||
|
if (!isPlainObject(raw)) {
|
||||||
|
errors.push(`${at} must be an object`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rejectUnknown(raw, ALLOWED_SETTINGS_FIELD, at, errors);
|
||||||
|
|
||||||
|
if (typeof raw.key !== "string" || !SETTINGS_KEY_PATTERN.test(raw.key)) {
|
||||||
|
errors.push(`${at}.key must be a short alphanumeric key`);
|
||||||
|
} else {
|
||||||
|
if (seen.has(raw.key)) {
|
||||||
|
errors.push(`${at}.key duplicates "${raw.key}"`);
|
||||||
|
}
|
||||||
|
seen.add(raw.key);
|
||||||
|
if (raw.type === "boolean") booleanKeys.add(raw.key);
|
||||||
|
if (enableKey && raw.key === enableKey) {
|
||||||
|
errors.push(
|
||||||
|
`${at}.key "${raw.key}" is already the section's enableKey`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof raw.type !== "string" ||
|
||||||
|
!(SETTINGS_FIELD_TYPES as readonly string[]).includes(raw.type)
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
`${at}.type must be one of: ${SETTINGS_FIELD_TYPES.join(", ")}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A custom field draws its own label, so it needs a component instead.
|
||||||
|
if (raw.type === "custom") {
|
||||||
|
requireString(raw.component, `${at}.component`, errors);
|
||||||
|
} else {
|
||||||
|
requireString(raw.labelKey, `${at}.labelKey`, errors);
|
||||||
|
if ("component" in raw) {
|
||||||
|
errors.push(`${at}.component is only valid when type is "custom"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of ["descriptionKey", "placeholderKey", "group"] as const) {
|
||||||
|
if (key in raw) requireString(raw[key], `${at}.${key}`, errors);
|
||||||
|
}
|
||||||
|
if ("permission" in raw) {
|
||||||
|
requireString(raw.permission, `${at}.permission`, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.type === "select" || raw.type === "multiselect") {
|
||||||
|
validateSettingsOptions(raw.options, at, errors);
|
||||||
|
} else if ("options" in raw) {
|
||||||
|
errors.push(`${at}.options is only valid for select and multiselect`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.type === "number") {
|
||||||
|
for (const bound of ["min", "max"] as const) {
|
||||||
|
if (bound in raw && typeof raw[bound] !== "number") {
|
||||||
|
errors.push(`${at}.${bound} must be a number`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof raw.min === "number" &&
|
||||||
|
typeof raw.max === "number" &&
|
||||||
|
raw.min > raw.max
|
||||||
|
) {
|
||||||
|
errors.push(`${at}.min must not be greater than ${at}.max`);
|
||||||
|
}
|
||||||
|
} else if ("min" in raw || "max" in raw) {
|
||||||
|
errors.push(`${at}.min and ${at}.max are only valid for number fields`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Second pass: every key is known by now, so forward references are fine.
|
||||||
|
fields.forEach((raw, index) => {
|
||||||
|
if (!isPlainObject(raw) || !("requires" in raw)) return;
|
||||||
|
const at = `${where}[${index}]`;
|
||||||
|
if (typeof raw.requires !== "string") {
|
||||||
|
errors.push(`${at}.requires must be a string`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!booleanKeys.has(raw.requires)) {
|
||||||
|
errors.push(
|
||||||
|
`${at}.requires "${raw.requires}" must name a boolean field in the same scope`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSettingsOptions(
|
||||||
|
options: unknown,
|
||||||
|
where: string,
|
||||||
|
errors: string[],
|
||||||
|
): void {
|
||||||
|
if (!Array.isArray(options) || options.length === 0) {
|
||||||
|
errors.push(`${where}.options must be a non-empty array`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const seen = new Set<string>();
|
||||||
|
options.forEach((raw, index) => {
|
||||||
|
const at = `${where}.options[${index}]`;
|
||||||
|
if (!isPlainObject(raw)) {
|
||||||
|
errors.push(`${at} must be an object`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rejectUnknown(raw, ["value", "labelKey"], at, errors);
|
||||||
|
if (requireString(raw.value, `${at}.value`, errors)) {
|
||||||
|
if (seen.has(raw.value)) {
|
||||||
|
errors.push(`${at}.value duplicates "${raw.value}"`);
|
||||||
|
}
|
||||||
|
seen.add(raw.value);
|
||||||
|
}
|
||||||
|
requireString(raw.labelKey, `${at}.labelKey`, errors);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function validateTabs(tabs: unknown, errors: string[]): void {
|
function validateTabs(tabs: unknown, errors: string[]): void {
|
||||||
if (tabs === undefined) return;
|
if (tabs === undefined) return;
|
||||||
if (!Array.isArray(tabs)) {
|
if (!Array.isArray(tabs)) {
|
||||||
@@ -850,6 +1131,27 @@ export function parseManifest(raw: unknown): ParsedManifest {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A settings field may gate on one of this plugin's own permissions, or on
|
||||||
|
// a core/other-plugin id used as given. Only the first form is checkable
|
||||||
|
// here, and an undeclared one would hide the field from every admin.
|
||||||
|
const settings = manifest.contributes?.settings;
|
||||||
|
const settingsScopes: [string, PluginSettingsField[]][] = [
|
||||||
|
["admin", settings?.admin ?? []],
|
||||||
|
["user", settings?.user ?? []],
|
||||||
|
["host", settings?.host?.fields ?? []],
|
||||||
|
];
|
||||||
|
for (const [scope, fields] of settingsScopes) {
|
||||||
|
for (const field of fields) {
|
||||||
|
if (!field.permission) continue;
|
||||||
|
if (field.permission.includes(".")) continue;
|
||||||
|
if (!declared.has(qualifyPermission(manifest.id, field.permission))) {
|
||||||
|
errors.push(
|
||||||
|
`contributes.settings.${scope} field "${field.key}" requires permission "${field.permission}", which is not declared in contributes.permissions`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const secret of manifest.requiresSecret ?? []) {
|
for (const secret of manifest.requiresSecret ?? []) {
|
||||||
if (secret.plugin === manifest.id) {
|
if (secret.plugin === manifest.id) {
|
||||||
errors.push(
|
errors.push(
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Settings field validation, shared by core and the frontend.
|
||||||
|
*
|
||||||
|
* Both the HTTP routes and the form renderer call these, so "what counts as a
|
||||||
|
* valid value" is decided once. A field the manifest never declared has no
|
||||||
|
* entry here and is rejected rather than stored, which is what stops a PUT
|
||||||
|
* from writing arbitrary keys into a plugin's namespace.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PluginSettingsField } from "./manifest.js";
|
||||||
|
|
||||||
|
/** The permission an admin-scope field requires when it names none itself. */
|
||||||
|
export const ADMIN_SETTINGS_PERMISSION = "admin.plugins.manage";
|
||||||
|
|
||||||
|
/** What a secret looks like on the wire. The value never leaves the server. */
|
||||||
|
export interface RedactedSecret {
|
||||||
|
set: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRedactedSecret(value: unknown): value is RedactedSecret {
|
||||||
|
return (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
!Array.isArray(value) &&
|
||||||
|
Object.keys(value).length === 1 &&
|
||||||
|
typeof (value as RedactedSecret).set === "boolean"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns whatever arrived on the wire into the type the field declares.
|
||||||
|
*
|
||||||
|
* Forms send everything as strings, so a number field that refused "30" would
|
||||||
|
* reject every value the UI can produce. Anything genuinely unconvertible is
|
||||||
|
* left alone for validateSettingValue to reject with a readable message.
|
||||||
|
*/
|
||||||
|
export function coerceSettingValue(
|
||||||
|
field: PluginSettingsField,
|
||||||
|
value: unknown,
|
||||||
|
): unknown {
|
||||||
|
if (value === null || value === undefined) return value;
|
||||||
|
|
||||||
|
switch (field.type) {
|
||||||
|
case "boolean":
|
||||||
|
if (typeof value === "boolean") return value;
|
||||||
|
if (value === "true") return true;
|
||||||
|
if (value === "false") return false;
|
||||||
|
return value;
|
||||||
|
|
||||||
|
case "number": {
|
||||||
|
if (typeof value === "number") return value;
|
||||||
|
if (typeof value === "string" && value.trim() !== "") {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isNaN(parsed) ? value : parsed;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "multiselect":
|
||||||
|
return Array.isArray(value) ? value : value === "" ? [] : value;
|
||||||
|
|
||||||
|
case "string":
|
||||||
|
case "secret":
|
||||||
|
case "textarea":
|
||||||
|
case "select":
|
||||||
|
return typeof value === "number" || typeof value === "boolean"
|
||||||
|
? String(value)
|
||||||
|
: value;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an error message, or null when the value is acceptable.
|
||||||
|
*
|
||||||
|
* Messages are plain English rather than i18n keys: they name the offending
|
||||||
|
* field and the rule it broke, and the UI shows them beside the input.
|
||||||
|
*/
|
||||||
|
export function validateSettingValue(
|
||||||
|
field: PluginSettingsField,
|
||||||
|
value: unknown,
|
||||||
|
): string | null {
|
||||||
|
// Clearing a field is always allowed; the default takes over on read.
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
|
||||||
|
switch (field.type) {
|
||||||
|
case "boolean":
|
||||||
|
return typeof value === "boolean"
|
||||||
|
? null
|
||||||
|
: `"${field.key}" must be true or false`;
|
||||||
|
|
||||||
|
case "number": {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||||
|
return `"${field.key}" must be a number`;
|
||||||
|
}
|
||||||
|
if (field.min !== undefined && value < field.min) {
|
||||||
|
return `"${field.key}" must be at least ${field.min}`;
|
||||||
|
}
|
||||||
|
if (field.max !== undefined && value > field.max) {
|
||||||
|
return `"${field.key}" must be at most ${field.max}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "select": {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return `"${field.key}" must be a string`;
|
||||||
|
}
|
||||||
|
const allowed = (field.options ?? []).map((option) => option.value);
|
||||||
|
return allowed.includes(value)
|
||||||
|
? null
|
||||||
|
: `"${field.key}" must be one of: ${allowed.join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "multiselect": {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return `"${field.key}" must be an array`;
|
||||||
|
}
|
||||||
|
const allowed = (field.options ?? []).map((option) => option.value);
|
||||||
|
for (const entry of value) {
|
||||||
|
if (typeof entry !== "string" || !allowed.includes(entry)) {
|
||||||
|
return `"${field.key}" may only contain: ${allowed.join(", ")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "json": {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
try {
|
||||||
|
JSON.parse(value);
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return `"${field.key}" must be valid JSON`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Already-parsed JSON is fine as long as it survives a round trip.
|
||||||
|
try {
|
||||||
|
JSON.stringify(value);
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return `"${field.key}" must be valid JSON`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "secret":
|
||||||
|
// A redacted echo is a no-op rather than a value, handled by the caller.
|
||||||
|
if (isRedactedSecret(value)) return null;
|
||||||
|
return typeof value === "string"
|
||||||
|
? null
|
||||||
|
: `"${field.key}" must be a string`;
|
||||||
|
|
||||||
|
case "string":
|
||||||
|
case "textarea":
|
||||||
|
return typeof value === "string"
|
||||||
|
? null
|
||||||
|
: `"${field.key}" must be a string`;
|
||||||
|
|
||||||
|
case "custom":
|
||||||
|
// The component owns its own shape; core only stores what it sends.
|
||||||
|
return null;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return `"${field.key}" has an unknown type`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The declared defaults for a scope, used when no row has been written. */
|
||||||
|
export function defaultsFor(
|
||||||
|
fields: readonly PluginSettingsField[] = [],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const defaults: Record<string, unknown> = {};
|
||||||
|
for (const field of fields) {
|
||||||
|
if (field.default !== undefined) defaults[field.key] = field.default;
|
||||||
|
}
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Looks a field up by key, for a caller holding only the declared list. */
|
||||||
|
export function findSettingsField(
|
||||||
|
fields: readonly PluginSettingsField[] = [],
|
||||||
|
key: string,
|
||||||
|
): PluginSettingsField | undefined {
|
||||||
|
return fields.find((field) => field.key === key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a field should be shown or accepted, given the rest of the scope.
|
||||||
|
*
|
||||||
|
* A field declaring `requires` is inert while its named boolean is off, so the
|
||||||
|
* UI hides it and a write to it is pointless rather than wrong.
|
||||||
|
*/
|
||||||
|
export function isFieldActive(
|
||||||
|
field: PluginSettingsField,
|
||||||
|
values: Record<string, unknown>,
|
||||||
|
): boolean {
|
||||||
|
if (!field.requires) return true;
|
||||||
|
return values[field.requires] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { PluginSettingsField } from "./manifest.js";
|
||||||
@@ -25,6 +25,10 @@ export interface FakeContextOptions {
|
|||||||
manifest?: Partial<PluginManifest>;
|
manifest?: Partial<PluginManifest>;
|
||||||
/** Acting user returned by currentActor and used by asUser. */
|
/** Acting user returned by currentActor and used by asUser. */
|
||||||
actor?: string;
|
actor?: string;
|
||||||
|
/** Seed for admin-scope ctx.settings. */
|
||||||
|
settings?: Record<string, unknown>;
|
||||||
|
/** Seed for the core settings ctx.settings.readCore can reach. */
|
||||||
|
coreSettings?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FakePluginContext {
|
export interface FakePluginContext {
|
||||||
@@ -43,6 +47,19 @@ export interface FakePluginContext {
|
|||||||
wsRoutes: Array<{ path: string; raw: boolean }>;
|
wsRoutes: Array<{ path: string; raw: boolean }>;
|
||||||
/** Router options passed to ctx.http.router, in order. */
|
/** Router options passed to ctx.http.router, in order. */
|
||||||
httpRouters: Array<PluginRouterOptions | undefined>;
|
httpRouters: Array<PluginRouterOptions | undefined>;
|
||||||
|
/** Backing store behind ctx.settings, keyed "<scope>:<scopeId>:<key>". */
|
||||||
|
settings: Map<string, unknown>;
|
||||||
|
/** Core settings readable through ctx.settings.readCore. */
|
||||||
|
coreSettings: Map<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How a settings row is keyed in the doubles. Mirrors the real unique index. */
|
||||||
|
function settingsKey(
|
||||||
|
scope: string,
|
||||||
|
scopeId: string | number | undefined,
|
||||||
|
key: string,
|
||||||
|
): string {
|
||||||
|
return `${scope}:${scopeId ?? ""}:${key}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function noopLogger(): PluginLogger {
|
function noopLogger(): PluginLogger {
|
||||||
@@ -71,8 +88,27 @@ export function createFakeContext(
|
|||||||
const wsRoutes: Array<{ path: string; raw: boolean }> = [];
|
const wsRoutes: Array<{ path: string; raw: boolean }> = [];
|
||||||
const httpRouters: Array<PluginRouterOptions | undefined> = [];
|
const httpRouters: Array<PluginRouterOptions | undefined> = [];
|
||||||
const listeners = new Map<string, Set<(payload: unknown) => void>>();
|
const listeners = new Map<string, Set<(payload: unknown) => void>>();
|
||||||
|
const settings = new Map<string, unknown>();
|
||||||
|
const coreSettings = new Map<string, string>(
|
||||||
|
Object.entries(options.coreSettings ?? {}),
|
||||||
|
);
|
||||||
|
const settingsListeners = new Map<string, Set<(value: unknown) => void>>();
|
||||||
let actor = options.actor;
|
let actor = options.actor;
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(options.settings ?? {})) {
|
||||||
|
settings.set(settingsKey("admin", undefined, key), value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeSetting = (
|
||||||
|
scope: string,
|
||||||
|
scopeId: string | number | undefined,
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
) => {
|
||||||
|
settings.set(settingsKey(scope, scopeId, key), value);
|
||||||
|
for (const listener of settingsListeners.get(key) ?? []) listener(value);
|
||||||
|
};
|
||||||
|
|
||||||
const manifest = {
|
const manifest = {
|
||||||
id: pluginId,
|
id: pluginId,
|
||||||
name: pluginId,
|
name: pluginId,
|
||||||
@@ -186,6 +222,49 @@ export function createFakeContext(
|
|||||||
require: () => ((_req, _res, next) => next()) as PluginMiddleware,
|
require: () => ((_req, _res, next) => next()) as PluginMiddleware,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
settings: {
|
||||||
|
get: async (key) =>
|
||||||
|
settings.get(settingsKey("admin", undefined, key)) as never,
|
||||||
|
set: async (key, value) => writeSetting("admin", undefined, key, value),
|
||||||
|
|
||||||
|
getUser: async (userId, key) =>
|
||||||
|
settings.get(settingsKey("user", userId, key)) as never,
|
||||||
|
setUser: async (userId, key, value) =>
|
||||||
|
writeSetting("user", userId, key, value),
|
||||||
|
|
||||||
|
getHost: async (hostId, key) =>
|
||||||
|
settings.get(settingsKey("host", String(hostId), key)) as never,
|
||||||
|
setHost: async (hostId, key, value) =>
|
||||||
|
writeSetting("host", String(hostId), key, value),
|
||||||
|
|
||||||
|
getAll: async (scope, scopeId) => {
|
||||||
|
const prefix = `${scope}:${scopeId ?? ""}:`;
|
||||||
|
const all: Record<string, unknown> = {};
|
||||||
|
for (const [storedKey, value] of settings) {
|
||||||
|
if (storedKey.startsWith(prefix)) {
|
||||||
|
all[storedKey.slice(prefix.length)] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
},
|
||||||
|
|
||||||
|
onChange: (key, listener) => {
|
||||||
|
let set = settingsListeners.get(key);
|
||||||
|
if (!set) {
|
||||||
|
set = new Set();
|
||||||
|
settingsListeners.set(key, set);
|
||||||
|
}
|
||||||
|
set.add(listener);
|
||||||
|
const unsubscribe = () => {
|
||||||
|
set!.delete(listener);
|
||||||
|
};
|
||||||
|
disposals.push(unsubscribe);
|
||||||
|
return unsubscribe;
|
||||||
|
},
|
||||||
|
|
||||||
|
readCore: async (key) => coreSettings.get(key) ?? null,
|
||||||
|
},
|
||||||
|
|
||||||
disposables,
|
disposables,
|
||||||
|
|
||||||
asUser: async (userId, fn) => {
|
asUser: async (userId, fn) => {
|
||||||
@@ -210,6 +289,8 @@ export function createFakeContext(
|
|||||||
syncEntities,
|
syncEntities,
|
||||||
wsRoutes,
|
wsRoutes,
|
||||||
httpRouters,
|
httpRouters,
|
||||||
|
settings,
|
||||||
|
coreSettings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,15 +301,15 @@ export interface MockContextOptions {
|
|||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
/** Seed for ctx.kv. */
|
/** Seed for ctx.kv. */
|
||||||
kv?: Record<string, unknown>;
|
kv?: Record<string, unknown>;
|
||||||
/** Plugin settings, readable through settings.get. A5 wires the real one. */
|
/** Seed for this plugin's own admin-scope settings. */
|
||||||
settings?: Record<string, unknown>;
|
settings?: Record<string, unknown>;
|
||||||
|
/** Seed for the core settings readCore can reach. */
|
||||||
|
coreSettings?: Record<string, string>;
|
||||||
/** Acting user returned by currentActor and used by asUser. */
|
/** Acting user returned by currentActor and used by asUser. */
|
||||||
actor?: string;
|
actor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MockPluginContext extends FakePluginContext {
|
export interface MockPluginContext extends FakePluginContext {
|
||||||
/** Settings backing store, so a test can assert what a plugin wrote. */
|
|
||||||
settings: Map<string, unknown>;
|
|
||||||
/** Capability ids checked during the test, in order, denied ones included. */
|
/** Capability ids checked during the test, in order, denied ones included. */
|
||||||
checked: string[];
|
checked: string[];
|
||||||
}
|
}
|
||||||
@@ -237,22 +318,23 @@ export interface MockPluginContext extends FakePluginContext {
|
|||||||
* A context double that gates on capabilities the way src/backend/plugins/ctx.ts
|
* A context double that gates on capabilities the way src/backend/plugins/ctx.ts
|
||||||
* does, so a test can prove a plugin fails closed without a running server.
|
* does, so a test can prove a plugin fails closed without a running server.
|
||||||
*
|
*
|
||||||
* Two gates exist on today's ctx surface: kv:own on every ctx.kv call, and
|
* The gates on today's ctx surface: kv:own on every ctx.kv call, db:own on
|
||||||
* events:core on emitting a topic outside the plugin's own namespace. As the
|
* ctx.db, network:serve on ctx.http and ctx.ws, events:core on emitting a
|
||||||
* SDK grows a member, add its gate here in the same shape.
|
* topic outside the plugin's own namespace, and settings:read-core on
|
||||||
|
* ctx.settings.readCore. Reading a plugin's own settings is deliberately
|
||||||
|
* ungated. As the SDK grows a member, add its gate here in the same shape.
|
||||||
*/
|
*/
|
||||||
export function createMockCtx(
|
export function createMockCtx(
|
||||||
options: MockContextOptions = {},
|
options: MockContextOptions = {},
|
||||||
): MockPluginContext {
|
): MockPluginContext {
|
||||||
const granted = new Set(options.capabilities ?? []);
|
const granted = new Set(options.capabilities ?? []);
|
||||||
const checked: string[] = [];
|
const checked: string[] = [];
|
||||||
const settings = new Map<string, unknown>(
|
|
||||||
Object.entries(options.settings ?? {}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const base = createFakeContext({
|
const base = createFakeContext({
|
||||||
pluginId: options.pluginId,
|
pluginId: options.pluginId,
|
||||||
actor: options.actor,
|
actor: options.actor,
|
||||||
|
settings: options.settings,
|
||||||
|
coreSettings: options.coreSettings,
|
||||||
manifest: {
|
manifest: {
|
||||||
capabilities: options.capabilities ?? [],
|
capabilities: options.capabilities ?? [],
|
||||||
...options.manifest,
|
...options.manifest,
|
||||||
@@ -345,9 +427,19 @@ export function createMockCtx(
|
|||||||
ctx.ws.upgrade(path, handler, wsOptions);
|
ctx.ws.upgrade(path, handler, wsOptions);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
settings: {
|
||||||
|
// Reading and writing a plugin's OWN settings is ungated, exactly as in
|
||||||
|
// the real runtime. Only readCore reaches outside the plugin.
|
||||||
|
...ctx.settings,
|
||||||
|
readCore: async (key) => {
|
||||||
|
require("settings:read-core");
|
||||||
|
return ctx.settings.readCore(key);
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return { ...base, ctx: gatedCtx, settings, checked };
|
return { ...base, ctx: gatedCtx, checked };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -27,7 +27,34 @@
|
|||||||
"titleKey": "permissions.devices.view.title",
|
"titleKey": "permissions.devices.view.title",
|
||||||
"descriptionKey": "permissions.devices.view.description"
|
"descriptionKey": "permissions.devices.view.description"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"settings": {
|
||||||
|
"admin": [
|
||||||
|
{
|
||||||
|
"key": "apiKey",
|
||||||
|
"type": "secret",
|
||||||
|
"labelKey": "settings.apiKey.label",
|
||||||
|
"descriptionKey": "settings.apiKey.description",
|
||||||
|
"placeholderKey": "settings.apiKey.placeholder",
|
||||||
|
"group": "settings.group.api"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "apiBaseUrl",
|
||||||
|
"type": "string",
|
||||||
|
"labelKey": "settings.apiBaseUrl.label",
|
||||||
|
"descriptionKey": "settings.apiBaseUrl.description",
|
||||||
|
"placeholderKey": "settings.apiBaseUrl.placeholder",
|
||||||
|
"default": "",
|
||||||
|
"group": "settings.group.api"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "devices",
|
||||||
|
"type": "custom",
|
||||||
|
"component": "devices",
|
||||||
|
"group": "settings.group.devices"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"locales": "locales",
|
"locales": "locales",
|
||||||
"platforms": ["linux", "win32", "darwin"]
|
"platforms": ["linux", "win32", "darwin"]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { PluginContext } from "@termix/plugin-sdk/backend";
|
|||||||
import { startTailscaleService, stopTailscaleService } from "./routes.js";
|
import { startTailscaleService, stopTailscaleService } from "./routes.js";
|
||||||
|
|
||||||
export async function activate(ctx: PluginContext) {
|
export async function activate(ctx: PluginContext) {
|
||||||
startTailscaleService(ctx.http.router());
|
startTailscaleService(ctx.http.router(), ctx);
|
||||||
ctx.log.info("Tailscale routes mounted at /plugin-api/tailscale");
|
ctx.log.info("Tailscale routes mounted at /plugin-api/tailscale");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
|||||||
import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js";
|
import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js";
|
||||||
import { apiLogger } from "../../../../src/backend/utils/logger.js";
|
import { apiLogger } from "../../../../src/backend/utils/logger.js";
|
||||||
import { fetchWithProxy } from "../../../../src/backend/utils/proxy-agent.js";
|
import { fetchWithProxy } from "../../../../src/backend/utils/proxy-agent.js";
|
||||||
import { createCurrentSettingsRepository } from "../../../../src/backend/database/repositories/factory.js";
|
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||||
|
|
||||||
interface TailscaleDevice {
|
interface TailscaleDevice {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -33,6 +33,12 @@ const DEFAULT_TAILSCALE_API_BASE = "https://api.tailscale.com/api/v2";
|
|||||||
|
|
||||||
export const router = Router();
|
export const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set by activate(). The API key and base URL are this plugin's own settings
|
||||||
|
* now, read through ctx rather than out of the core settings table.
|
||||||
|
*/
|
||||||
|
let context: PluginContext | null = null;
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
const authenticateJWT = authManager.createAuthMiddleware();
|
const authenticateJWT = authManager.createAuthMiddleware();
|
||||||
|
|
||||||
@@ -85,13 +91,17 @@ router.get(
|
|||||||
requireDeviceAccess,
|
requireDeviceAccess,
|
||||||
async (_req, res) => {
|
async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const settingsRepo = createCurrentSettingsRepository();
|
if (!context) {
|
||||||
const apiKey = (await settingsRepo.get("tailscale_api_key")) ?? "";
|
return res
|
||||||
|
.status(503)
|
||||||
|
.json({ error: "Plugin is not running", devices: [] });
|
||||||
|
}
|
||||||
|
const apiKey = (await context.settings.get<string>("apiKey")) ?? "";
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return res.json({ devices: [], hasApiKey: false });
|
return res.json({ devices: [], hasApiKey: false });
|
||||||
}
|
}
|
||||||
const apiBase = normalizeApiBase(
|
const apiBase = normalizeApiBase(
|
||||||
await settingsRepo.get("tailscale_api_base_url"),
|
(await context.settings.get<string>("apiBaseUrl")) ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const url = `${apiBase}/tailnet/-/devices?fields=all`;
|
const url = `${apiBase}/tailnet/-/devices?fields=all`;
|
||||||
@@ -148,12 +158,18 @@ router.get(
|
|||||||
|
|
||||||
/** Called from activate(). Mounts this router at /tailscale via the shared
|
/** Called from activate(). Mounts this router at /tailscale via the shared
|
||||||
* dispatcher. */
|
* dispatcher. */
|
||||||
export function startTailscaleService(mountOn: Router): void {
|
export function startTailscaleService(
|
||||||
|
mountOn: Router,
|
||||||
|
ctx: PluginContext,
|
||||||
|
): void {
|
||||||
|
context = ctx;
|
||||||
mountOn.use(router);
|
mountOn.use(router);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Called from deactivate(). /tailscale/* falls back to 404 until
|
/** Called from deactivate(). /tailscale/* falls back to 404 until
|
||||||
* reactivated. */
|
* reactivated. */
|
||||||
export function stopTailscaleService(): void {
|
export function stopTailscaleService(): void {
|
||||||
// Unmounted by the runtime when the plugin deactivates.
|
// Dropped so a request arriving mid-teardown cannot read settings through a
|
||||||
|
// context the runtime has already torn down.
|
||||||
|
context = null;
|
||||||
}
|
}
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import type { Dispatch, SetStateAction } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Button } from "@/components/button";
|
|
||||||
import { Input } from "@/components/input";
|
|
||||||
import { Network } from "lucide-react";
|
|
||||||
import { AccordionSection } from "@/sidebar/AdminSettingsShared";
|
|
||||||
|
|
||||||
// Was inline in AdminGeneralSettingsSection (src/ui/sidebar/AdminSettingsSections.tsx),
|
|
||||||
// moved here into its own accordion section as part of the Tailscale plugin.
|
|
||||||
// The backing routes (GET/PATCH /users/tailscale-settings) stay in core -
|
|
||||||
// they are general user-settings infrastructure this plugin calls, not
|
|
||||||
// something it owns.
|
|
||||||
|
|
||||||
type AdminTailscaleSectionProps = {
|
|
||||||
open: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
tailscaleApiKey: string;
|
|
||||||
setTailscaleApiKey: Dispatch<SetStateAction<string>>;
|
|
||||||
tailscaleApiBaseUrl: string;
|
|
||||||
setTailscaleApiBaseUrl: Dispatch<SetStateAction<string>>;
|
|
||||||
handleSaveTailscaleApiKey: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AdminTailscaleSection({
|
|
||||||
open,
|
|
||||||
onToggle,
|
|
||||||
tailscaleApiKey,
|
|
||||||
setTailscaleApiKey,
|
|
||||||
tailscaleApiBaseUrl,
|
|
||||||
setTailscaleApiBaseUrl,
|
|
||||||
handleSaveTailscaleApiKey,
|
|
||||||
}: AdminTailscaleSectionProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AccordionSection
|
|
||||||
label={t("admin.sectionTailscale")}
|
|
||||||
icon={<Network className="size-3.5" />}
|
|
||||||
open={open}
|
|
||||||
onToggle={onToggle}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-2 pt-2">
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
||||||
{t("admin.tailscaleApiKey")}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-muted-foreground">
|
|
||||||
{t("admin.tailscaleApiKeyDescription")}{" "}
|
|
||||||
<a
|
|
||||||
href="https://docs.termix.site/features/networking/tailscale"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-accent-brand hover:underline"
|
|
||||||
>
|
|
||||||
{t("admin.tailscaleApiKeyDocsLink")}
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
value={tailscaleApiKey}
|
|
||||||
onChange={(e) => setTailscaleApiKey(e.target.value)}
|
|
||||||
placeholder="tskey-api-... / hskey-api-..."
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7 shrink-0"
|
|
||||||
onClick={handleSaveTailscaleApiKey}
|
|
||||||
>
|
|
||||||
{t("common.save")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5 mt-1">
|
|
||||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
|
||||||
{t("admin.tailscaleApiBaseUrl")}
|
|
||||||
</label>
|
|
||||||
<span className="text-[10px] text-muted-foreground">
|
|
||||||
{t("admin.tailscaleApiBaseUrlDescription")}
|
|
||||||
</span>
|
|
||||||
<Input
|
|
||||||
value={tailscaleApiBaseUrl}
|
|
||||||
onChange={(e) => setTailscaleApiBaseUrl(e.target.value)}
|
|
||||||
placeholder="https://api.tailscale.com/api/v2"
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AccordionSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,39 +1,11 @@
|
|||||||
// Was part of src/ui/api/settings-api.ts, moved here with the rest of the
|
// The API key and base URL are this plugin's own settings now, read and
|
||||||
// Tailscale plugin. The backing routes stay in place: /users/tailscale-settings
|
// written through the generic /plugins/tailscale/settings/admin routes that
|
||||||
// is general user-settings infrastructure in core, and /tailscale/devices is
|
// core renders from the manifest. Only the device list is left here, because
|
||||||
// this plugin's own route (plugins/tailscale/backend/routes.ts).
|
// it is this plugin's own route.
|
||||||
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { authApi, handleApiError } from "@/main-axios";
|
import { authApi, handleApiError } from "@/main-axios";
|
||||||
|
|
||||||
export async function getTailscaleSettings(): Promise<{
|
|
||||||
apiKey: string;
|
|
||||||
hasApiKey: boolean;
|
|
||||||
apiBaseUrl: string;
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
const response = await authApi.get("/users/tailscale-settings");
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
handleApiError(error, "fetch Tailscale settings");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateTailscaleSettings(
|
|
||||||
apiKey: string,
|
|
||||||
apiBaseUrl?: string,
|
|
||||||
): Promise<{ hasApiKey: boolean }> {
|
|
||||||
try {
|
|
||||||
const response = await authApi.patch("/users/tailscale-settings", {
|
|
||||||
apiKey,
|
|
||||||
apiBaseUrl,
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
handleApiError(error, "update Tailscale settings");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getTailscaleDevices(): Promise<{
|
export async function getTailscaleDevices(): Promise<{
|
||||||
devices: Array<{
|
devices: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { parseManifest } from "@termix/plugin-sdk/manifest";
|
||||||
|
import { createMockCtx } from "@termix/plugin-sdk/testing";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The API key and base URL are this plugin's own settings now.
|
||||||
|
*
|
||||||
|
* They used to live in the core `settings` table behind a core route that
|
||||||
|
* existed only for this plugin, with the key stored in plaintext. These tests
|
||||||
|
* pin the shape core renders the page from, and that reading a plugin's own
|
||||||
|
* settings needs no capability.
|
||||||
|
*/
|
||||||
|
const manifest = parseManifest(
|
||||||
|
JSON.parse(
|
||||||
|
fs.readFileSync(
|
||||||
|
path.resolve(import.meta.dirname, "../../manifest.json"),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("tailscale settings contribution", () => {
|
||||||
|
it("parses cleanly", () => {
|
||||||
|
expect(manifest.errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declares the API key as a secret, so it is encrypted and redacted", () => {
|
||||||
|
const apiKey = manifest.manifest?.contributes?.settings?.admin?.find(
|
||||||
|
(field) => field.key === "apiKey",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(apiKey?.type).toBe("secret");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declares the base URL as plain text, because it is not a secret", () => {
|
||||||
|
const baseUrl = manifest.manifest?.contributes?.settings?.admin?.find(
|
||||||
|
(field) => field.key === "apiBaseUrl",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(baseUrl?.type).toBe("string");
|
||||||
|
expect(baseUrl?.default).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts both fields on the admin scope, not the user one", () => {
|
||||||
|
const settings = manifest.manifest?.contributes?.settings;
|
||||||
|
|
||||||
|
expect(settings?.admin?.map((field) => field.key)).toContain("apiKey");
|
||||||
|
expect(settings?.user).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps settings:read-core, which it still holds for core keys", () => {
|
||||||
|
expect(manifest.manifest?.capabilities).toContain("settings:read-core");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reading its own settings", () => {
|
||||||
|
it("needs no capability", async () => {
|
||||||
|
// Nothing granted at all: a plugin reading its own configuration must not
|
||||||
|
// have to declare anything for it.
|
||||||
|
const { ctx } = createMockCtx({
|
||||||
|
pluginId: "tailscale",
|
||||||
|
capabilities: [],
|
||||||
|
settings: { apiKey: "tskey-api-test", apiBaseUrl: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(ctx.settings.get("apiKey")).resolves.toBe("tskey-api-test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can write and read a value back", async () => {
|
||||||
|
const { ctx } = createMockCtx({ pluginId: "tailscale", capabilities: [] });
|
||||||
|
|
||||||
|
await ctx.settings.set("apiBaseUrl", "https://headscale.example");
|
||||||
|
|
||||||
|
await expect(ctx.settings.get("apiBaseUrl")).resolves.toBe(
|
||||||
|
"https://headscale.example",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still needs settings:read-core to reach a core key", async () => {
|
||||||
|
const { ctx } = createMockCtx({ pluginId: "tailscale", capabilities: [] });
|
||||||
|
|
||||||
|
await expect(ctx.settings.readCore("app_name")).rejects.toThrow(
|
||||||
|
/settings:read-core/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { render, screen } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { AdminTailscaleSection } from "../../src/frontend/AdminTailscaleSection";
|
|
||||||
|
|
||||||
vi.mock("react-i18next", () => ({
|
|
||||||
useTranslation: () => ({ t: (key: string) => key }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("AdminTailscaleSection", () => {
|
|
||||||
it("renders the API key and base URL fields and saves on click", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const handleSave = vi.fn();
|
|
||||||
render(
|
|
||||||
<AdminTailscaleSection
|
|
||||||
open
|
|
||||||
onToggle={vi.fn()}
|
|
||||||
tailscaleApiKey="tskey-api-abc"
|
|
||||||
setTailscaleApiKey={vi.fn()}
|
|
||||||
tailscaleApiBaseUrl="https://api.tailscale.com/api/v2"
|
|
||||||
setTailscaleApiBaseUrl={vi.fn()}
|
|
||||||
handleSaveTailscaleApiKey={handleSave}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
(
|
|
||||||
screen.getByPlaceholderText(
|
|
||||||
"tskey-api-... / hskey-api-...",
|
|
||||||
) as HTMLInputElement
|
|
||||||
).value,
|
|
||||||
).toBe("tskey-api-abc");
|
|
||||||
expect(
|
|
||||||
(
|
|
||||||
screen.getByPlaceholderText(
|
|
||||||
"https://api.tailscale.com/api/v2",
|
|
||||||
) as HTMLInputElement
|
|
||||||
).value,
|
|
||||||
).toBe("https://api.tailscale.com/api/v2");
|
|
||||||
|
|
||||||
await user.click(screen.getByText("common.save"));
|
|
||||||
expect(handleSave).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not render its content when closed", () => {
|
|
||||||
render(
|
|
||||||
<AdminTailscaleSection
|
|
||||||
open={false}
|
|
||||||
onToggle={vi.fn()}
|
|
||||||
tailscaleApiKey=""
|
|
||||||
setTailscaleApiKey={vi.fn()}
|
|
||||||
tailscaleApiBaseUrl=""
|
|
||||||
setTailscaleApiBaseUrl={vi.fn()}
|
|
||||||
handleSaveTailscaleApiKey={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
screen.queryByPlaceholderText("tskey-api-... / hskey-api-..."),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -9,8 +9,8 @@
|
|||||||
"src/ui/features/session-sharing/SharedSessionView.tsx",
|
"src/ui/features/session-sharing/SharedSessionView.tsx",
|
||||||
"src/ui/features/terminal/Terminal.tsx",
|
"src/ui/features/terminal/Terminal.tsx",
|
||||||
"src/ui/main-axios.ts",
|
"src/ui/main-axios.ts",
|
||||||
|
"src/ui/settings/components/TailscaleDevicesStatus.tsx",
|
||||||
"src/ui/shell/tabUtils.tsx",
|
"src/ui/shell/tabUtils.tsx",
|
||||||
"src/ui/sidebar/AdminSettingsPanel.tsx",
|
|
||||||
"src/ui/sidebar/HostEditor.tsx",
|
"src/ui/sidebar/HostEditor.tsx",
|
||||||
"src/ui/sidebar/HostEditorWebUiSection.tsx",
|
"src/ui/sidebar/HostEditorWebUiSection.tsx",
|
||||||
"src/ui/sidebar/HostsPanel.tsx",
|
"src/ui/sidebar/HostsPanel.tsx",
|
||||||
|
|||||||
@@ -751,6 +751,21 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE
|
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS plugin_settings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
plugin_id TEXT NOT NULL,
|
||||||
|
scope TEXT NOT NULL,
|
||||||
|
scope_id TEXT,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
value TEXT,
|
||||||
|
encrypted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (plugin_id, scope, scope_id, key),
|
||||||
|
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plugin_settings_plugin_scope ON plugin_settings (plugin_id, scope);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS rbac_known_permissions (
|
CREATE TABLE IF NOT EXISTS rbac_known_permissions (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
permission TEXT NOT NULL,
|
permission TEXT NOT NULL,
|
||||||
|
|||||||
@@ -2252,6 +2252,49 @@ export const pluginStorage = mysqlTable(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Values for the settings fields a plugin declares in contributes.settings.
|
||||||
|
*
|
||||||
|
* scope_id is polymorphic: null for admin scope, a user id for user scope, a
|
||||||
|
* host id rendered as text for host scope. That is why it carries no foreign
|
||||||
|
* key - one column cannot point at two tables - so the user and host delete
|
||||||
|
* paths remove these rows explicitly. Cascading with the plugin is a real FK,
|
||||||
|
* because uninstalling should leave nothing behind.
|
||||||
|
*
|
||||||
|
* Secret fields are encrypted with the system key before they land here, and
|
||||||
|
* `encrypted` records which rows that applies to so a read knows to decrypt.
|
||||||
|
*/
|
||||||
|
export const pluginSettings = mysqlTable(
|
||||||
|
"plugin_settings",
|
||||||
|
{
|
||||||
|
id: int("id").autoincrement().primaryKey(),
|
||||||
|
pluginId: varchar("plugin_id", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||||
|
/** admin | user | host */
|
||||||
|
scope: varchar("scope", { length: 255 }).notNull(),
|
||||||
|
scopeId: varchar("scope_id", { length: 255 }),
|
||||||
|
key: varchar("key", { length: 255 }).notNull(),
|
||||||
|
/** JSON-encoded, so a field keeps its declared type across a round trip. */
|
||||||
|
value: text("value"),
|
||||||
|
encrypted: boolean("encrypted")
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
updatedAt: varchar("updated_at", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("idx_plugin_settings_scope_key").on(
|
||||||
|
table.pluginId,
|
||||||
|
table.scope,
|
||||||
|
table.scopeId,
|
||||||
|
table.key,
|
||||||
|
),
|
||||||
|
index("idx_plugin_settings_plugin_scope").on(table.pluginId, table.scope),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which of a plugin's migrations have been applied.
|
* Which of a plugin's migrations have been applied.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -2253,6 +2253,49 @@ export const pluginStorage = pgTable(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Values for the settings fields a plugin declares in contributes.settings.
|
||||||
|
*
|
||||||
|
* scope_id is polymorphic: null for admin scope, a user id for user scope, a
|
||||||
|
* host id rendered as text for host scope. That is why it carries no foreign
|
||||||
|
* key - one column cannot point at two tables - so the user and host delete
|
||||||
|
* paths remove these rows explicitly. Cascading with the plugin is a real FK,
|
||||||
|
* because uninstalling should leave nothing behind.
|
||||||
|
*
|
||||||
|
* Secret fields are encrypted with the system key before they land here, and
|
||||||
|
* `encrypted` records which rows that applies to so a read knows to decrypt.
|
||||||
|
*/
|
||||||
|
export const pluginSettings = pgTable(
|
||||||
|
"plugin_settings",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
pluginId: varchar("plugin_id", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||||
|
/** admin | user | host */
|
||||||
|
scope: varchar("scope", { length: 255 }).notNull(),
|
||||||
|
scopeId: varchar("scope_id", { length: 255 }),
|
||||||
|
key: varchar("key", { length: 255 }).notNull(),
|
||||||
|
/** JSON-encoded, so a field keeps its declared type across a round trip. */
|
||||||
|
value: text("value"),
|
||||||
|
encrypted: boolean("encrypted")
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
updatedAt: varchar("updated_at", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("idx_plugin_settings_scope_key").on(
|
||||||
|
table.pluginId,
|
||||||
|
table.scope,
|
||||||
|
table.scopeId,
|
||||||
|
table.key,
|
||||||
|
),
|
||||||
|
index("idx_plugin_settings_plugin_scope").on(table.pluginId, table.scope),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which of a plugin's migrations have been applied.
|
* Which of a plugin's migrations have been applied.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -2251,6 +2251,49 @@ export const pluginStorage = sqliteTable(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Values for the settings fields a plugin declares in contributes.settings.
|
||||||
|
*
|
||||||
|
* scope_id is polymorphic: null for admin scope, a user id for user scope, a
|
||||||
|
* host id rendered as text for host scope. That is why it carries no foreign
|
||||||
|
* key - one column cannot point at two tables - so the user and host delete
|
||||||
|
* paths remove these rows explicitly. Cascading with the plugin is a real FK,
|
||||||
|
* because uninstalling should leave nothing behind.
|
||||||
|
*
|
||||||
|
* Secret fields are encrypted with the system key before they land here, and
|
||||||
|
* `encrypted` records which rows that applies to so a read knows to decrypt.
|
||||||
|
*/
|
||||||
|
export const pluginSettings = sqliteTable(
|
||||||
|
"plugin_settings",
|
||||||
|
{
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
pluginId: text("plugin_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||||
|
/** admin | user | host */
|
||||||
|
scope: text("scope").notNull(),
|
||||||
|
scopeId: text("scope_id"),
|
||||||
|
key: text("key").notNull(),
|
||||||
|
/** JSON-encoded, so a field keeps its declared type across a round trip. */
|
||||||
|
value: text("value"),
|
||||||
|
encrypted: integer("encrypted", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
updatedAt: text("updated_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("idx_plugin_settings_scope_key").on(
|
||||||
|
table.pluginId,
|
||||||
|
table.scope,
|
||||||
|
table.scopeId,
|
||||||
|
table.key,
|
||||||
|
),
|
||||||
|
index("idx_plugin_settings_plugin_scope").on(table.pluginId, table.scope),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which of a plugin's migrations have been applied.
|
* Which of a plugin's migrations have been applied.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { OpenTabRepository } from "./open-tab-repository.js";
|
|||||||
import { OpksshTokenRepository } from "./opkssh-token-repository.js";
|
import { OpksshTokenRepository } from "./opkssh-token-repository.js";
|
||||||
import { PluginRepository } from "./plugin-repository.js";
|
import { PluginRepository } from "./plugin-repository.js";
|
||||||
import { PluginStorageRepository } from "./plugin-storage-repository.js";
|
import { PluginStorageRepository } from "./plugin-storage-repository.js";
|
||||||
|
import { PluginSettingsRepository } from "./plugin-settings-repository.js";
|
||||||
import { PluginMigrationRepository } from "./plugin-migration-repository.js";
|
import { PluginMigrationRepository } from "./plugin-migration-repository.js";
|
||||||
import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repository.js";
|
import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repository.js";
|
||||||
import { PluginRegistryRepository } from "./plugin-registry-repository.js";
|
import { PluginRegistryRepository } from "./plugin-registry-repository.js";
|
||||||
@@ -395,6 +396,13 @@ export function createCurrentPluginStorageRepository(): PluginStorageRepository
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createCurrentPluginSettingsRepository(): PluginSettingsRepository {
|
||||||
|
return new PluginSettingsRepository(
|
||||||
|
createCurrentRepositoryContext(),
|
||||||
|
createCurrentRepositoryWriteHook("plugin_settings_repository_write"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function createCurrentPluginMigrationRepository(): PluginMigrationRepository {
|
export function createCurrentPluginMigrationRepository(): PluginMigrationRepository {
|
||||||
return new PluginMigrationRepository(
|
return new PluginMigrationRepository(
|
||||||
createCurrentRepositoryContext(),
|
createCurrentRepositoryContext(),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { hostAccess, hosts } from "../db/schema.js";
|
import { hostAccess, hosts, pluginSettings } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||||
import { rowsAffected } from "./mutation-result.js";
|
import { rowsAffected } from "./mutation-result.js";
|
||||||
@@ -338,6 +338,25 @@ export class HostRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
|
// Bulk path, so it never passes through deleteAccessForHost: clear the
|
||||||
|
// host-scope plugin settings for these hosts before the rows go.
|
||||||
|
const owned = await this.context.drizzle
|
||||||
|
.select({ id: hosts.id })
|
||||||
|
.from(hosts)
|
||||||
|
.where(eq(hosts.userId, userId));
|
||||||
|
|
||||||
|
if (owned.length > 0) {
|
||||||
|
await this.context.drizzle.delete(pluginSettings).where(
|
||||||
|
and(
|
||||||
|
eq(pluginSettings.scope, "host"),
|
||||||
|
inArray(
|
||||||
|
pluginSettings.scopeId,
|
||||||
|
owned.map((row) => String(row.id)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const result = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(hosts)
|
.delete(hosts)
|
||||||
.where(eq(hosts.userId, userId));
|
.where(eq(hosts.userId, userId));
|
||||||
@@ -350,6 +369,18 @@ export class HostRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteAccessForHost(hostId: number): Promise<number> {
|
async deleteAccessForHost(hostId: number): Promise<number> {
|
||||||
|
// plugin_settings.scope_id is polymorphic, so it has no foreign key to
|
||||||
|
// ssh_data and the engine will not cascade it. Cleared alongside the
|
||||||
|
// access rows, which every host delete path already goes through.
|
||||||
|
await this.context.drizzle
|
||||||
|
.delete(pluginSettings)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(pluginSettings.scope, "host"),
|
||||||
|
eq(pluginSettings.scopeId, String(hostId)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const result = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(hostAccess)
|
.delete(hostAccess)
|
||||||
.where(eq(hostAccess.hostId, hostId));
|
.where(eq(hostAccess.hostId, hostId));
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||||
|
import { pluginSettings } from "../db/schema.js";
|
||||||
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
|
import { rowsAffected } from "./mutation-result.js";
|
||||||
|
import { insertReturning, updateReturning } from "./returning.js";
|
||||||
|
|
||||||
|
export type PluginSettingsRecord = typeof pluginSettings.$inferSelect;
|
||||||
|
|
||||||
|
export type PluginSettingsScope = "admin" | "user" | "host";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Values behind ctx.settings and the /plugins/:id/settings routes.
|
||||||
|
*
|
||||||
|
* scope_id is null for admin scope, so every lookup has to use IS NULL rather
|
||||||
|
* than = NULL: SQL equality against null never matches, which would make an
|
||||||
|
* admin setting look unset no matter how often it was written.
|
||||||
|
*/
|
||||||
|
export class PluginSettingsRepository {
|
||||||
|
constructor(
|
||||||
|
private readonly context: DatabaseContext,
|
||||||
|
private readonly onWrite?: () => void | Promise<void>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private scopeMatch(
|
||||||
|
pluginId: string,
|
||||||
|
scope: PluginSettingsScope,
|
||||||
|
scopeId: string | null,
|
||||||
|
) {
|
||||||
|
return and(
|
||||||
|
eq(pluginSettings.pluginId, pluginId),
|
||||||
|
eq(pluginSettings.scope, scope),
|
||||||
|
scopeId === null
|
||||||
|
? isNull(pluginSettings.scopeId)
|
||||||
|
: eq(pluginSettings.scopeId, scopeId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(
|
||||||
|
pluginId: string,
|
||||||
|
scope: PluginSettingsScope,
|
||||||
|
scopeId: string | null,
|
||||||
|
key: string,
|
||||||
|
): Promise<PluginSettingsRecord | null> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(pluginSettings)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
this.scopeMatch(pluginId, scope, scopeId),
|
||||||
|
eq(pluginSettings.key, key),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAll(
|
||||||
|
pluginId: string,
|
||||||
|
scope: PluginSettingsScope,
|
||||||
|
scopeId: string | null,
|
||||||
|
): Promise<PluginSettingsRecord[]> {
|
||||||
|
return this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(pluginSettings)
|
||||||
|
.where(this.scopeMatch(pluginId, scope, scopeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every row for a set of scope ids, in one query.
|
||||||
|
*
|
||||||
|
* The host list would otherwise run one query per host, which is the shape
|
||||||
|
* that made other list endpoints slow at a few hundred hosts.
|
||||||
|
*/
|
||||||
|
async getAllForScopeIds(
|
||||||
|
scope: PluginSettingsScope,
|
||||||
|
scopeIds: string[],
|
||||||
|
): Promise<PluginSettingsRecord[]> {
|
||||||
|
if (scopeIds.length === 0) return [];
|
||||||
|
|
||||||
|
return this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(pluginSettings)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(pluginSettings.scope, scope),
|
||||||
|
inArray(pluginSettings.scopeId, scopeIds),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(
|
||||||
|
pluginId: string,
|
||||||
|
scope: PluginSettingsScope,
|
||||||
|
scopeId: string | null,
|
||||||
|
key: string,
|
||||||
|
value: string | null,
|
||||||
|
encrypted = false,
|
||||||
|
now = new Date().toISOString(),
|
||||||
|
): Promise<void> {
|
||||||
|
const where = and(
|
||||||
|
this.scopeMatch(pluginId, scope, scopeId),
|
||||||
|
eq(pluginSettings.key, key),
|
||||||
|
)!;
|
||||||
|
|
||||||
|
const existing = await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(pluginSettings)
|
||||||
|
.where(where)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing[0]) {
|
||||||
|
await updateReturning(
|
||||||
|
this.context,
|
||||||
|
pluginSettings,
|
||||||
|
{ value, encrypted, updatedAt: now },
|
||||||
|
where,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await insertReturning(this.context, pluginSettings, {
|
||||||
|
pluginId,
|
||||||
|
scope,
|
||||||
|
scopeId,
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
encrypted,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(
|
||||||
|
pluginId: string,
|
||||||
|
scope: PluginSettingsScope,
|
||||||
|
scopeId: string | null,
|
||||||
|
key: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await this.context.drizzle
|
||||||
|
.delete(pluginSettings)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
this.scopeMatch(pluginId, scope, scopeId),
|
||||||
|
eq(pluginSettings.key, key),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const affected = rowsAffected(result) > 0;
|
||||||
|
if (affected) await this.afterWrite();
|
||||||
|
return affected;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteByPlugin(pluginId: string): Promise<number> {
|
||||||
|
const result = await this.context.drizzle
|
||||||
|
.delete(pluginSettings)
|
||||||
|
.where(eq(pluginSettings.pluginId, pluginId));
|
||||||
|
|
||||||
|
const affected = rowsAffected(result);
|
||||||
|
if (affected > 0) await this.afterWrite();
|
||||||
|
return affected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes every plugin's rows for one user or host.
|
||||||
|
*
|
||||||
|
* scope_id carries no foreign key, so the user and host delete paths call
|
||||||
|
* this instead of relying on the engine to cascade.
|
||||||
|
*/
|
||||||
|
async deleteByScope(
|
||||||
|
scope: PluginSettingsScope,
|
||||||
|
scopeId: string,
|
||||||
|
): Promise<number> {
|
||||||
|
const result = await this.context.drizzle
|
||||||
|
.delete(pluginSettings)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(pluginSettings.scope, scope),
|
||||||
|
eq(pluginSettings.scopeId, scopeId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const affected = rowsAffected(result);
|
||||||
|
if (affected > 0) await this.afterWrite();
|
||||||
|
return affected;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async afterWrite(): Promise<void> {
|
||||||
|
await this.onWrite?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { asc, eq, inArray, like, sql } from "drizzle-orm";
|
import { and, asc, eq, inArray, like, sql } from "drizzle-orm";
|
||||||
import { users } from "../db/schema.js";
|
import { pluginSettings, users } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
import {
|
import {
|
||||||
countValue,
|
countValue,
|
||||||
@@ -199,6 +199,15 @@ export class UserRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async delete(id: string): Promise<boolean> {
|
async delete(id: string): Promise<boolean> {
|
||||||
|
// plugin_settings.scope_id is polymorphic, so it carries no foreign key to
|
||||||
|
// users and the engine will not cascade it. Removed here rather than in a
|
||||||
|
// route so every caller that deletes a user is covered.
|
||||||
|
await this.context.drizzle
|
||||||
|
.delete(pluginSettings)
|
||||||
|
.where(
|
||||||
|
and(eq(pluginSettings.scope, "user"), eq(pluginSettings.scopeId, id)),
|
||||||
|
);
|
||||||
|
|
||||||
const result = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(users)
|
.delete(users)
|
||||||
.where(eq(users.id, id));
|
.where(eq(users.id, id));
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* The pluginSettings map attached to a host on its way out.
|
||||||
|
*
|
||||||
|
* Without this the host editor would need one request per plugin per host,
|
||||||
|
* which is the shape that makes a list of a few hundred hosts slow. One query
|
||||||
|
* covers the whole list instead.
|
||||||
|
*
|
||||||
|
* Kept out of transformHostResponse because that function is synchronous and
|
||||||
|
* shared by every host read path. Secrets are always redacted here, so nothing
|
||||||
|
* on this path has to decrypt and the async work stays in one place.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||||
|
import { createCurrentPluginSettingsRepository } from "../repositories/factory.js";
|
||||||
|
import type { PluginSettingsRecord } from "../repositories/plugin-settings-repository.js";
|
||||||
|
import { declaredFields, resolveFieldValue } from "../../plugins/settings.js";
|
||||||
|
import { getPluginRuntime } from "../../plugins/index.js";
|
||||||
|
import { sshLogger } from "../../utils/logger.js";
|
||||||
|
|
||||||
|
export type HostPluginSettings = Record<string, Record<string, unknown>>;
|
||||||
|
|
||||||
|
/** Enabled plugins that declare host-scope settings, with their manifests. */
|
||||||
|
function hostSettingsPlugins(): PluginManifest[] {
|
||||||
|
try {
|
||||||
|
const { loader } = getPluginRuntime();
|
||||||
|
return loader
|
||||||
|
.list()
|
||||||
|
.filter((plugin) => plugin.state === "active")
|
||||||
|
.map((plugin) => plugin.manifest)
|
||||||
|
.filter(
|
||||||
|
(manifest): manifest is PluginManifest =>
|
||||||
|
!!manifest && !!manifest.contributes?.settings?.host,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// The runtime is not up in every context that renders a host.
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the map for a set of hosts in one query.
|
||||||
|
*
|
||||||
|
* Returns an empty map when no enabled plugin declares host settings, so the
|
||||||
|
* common case costs nothing.
|
||||||
|
*/
|
||||||
|
export async function loadHostPluginSettings(
|
||||||
|
hostIds: number[],
|
||||||
|
): Promise<Map<number, HostPluginSettings>> {
|
||||||
|
const result = new Map<number, HostPluginSettings>();
|
||||||
|
const manifests = hostSettingsPlugins();
|
||||||
|
if (manifests.length === 0 || hostIds.length === 0) return result;
|
||||||
|
|
||||||
|
let rows: PluginSettingsRecord[] = [];
|
||||||
|
try {
|
||||||
|
rows = await createCurrentPluginSettingsRepository().getAllForScopeIds(
|
||||||
|
"host",
|
||||||
|
hostIds.map(String),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// A host list must not fail because a settings read did.
|
||||||
|
sshLogger.warn("Failed to load host plugin settings", {
|
||||||
|
operation: "host_plugin_settings",
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byHostAndPlugin = new Map<string, PluginSettingsRecord[]>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = `${row.scopeId}:${row.pluginId}`;
|
||||||
|
const list = byHostAndPlugin.get(key);
|
||||||
|
if (list) list.push(row);
|
||||||
|
else byHostAndPlugin.set(key, [row]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const hostId of hostIds) {
|
||||||
|
const perPlugin: HostPluginSettings = {};
|
||||||
|
|
||||||
|
for (const manifest of manifests) {
|
||||||
|
const fields = declaredFields(manifest, "host");
|
||||||
|
if (fields.length === 0) continue;
|
||||||
|
|
||||||
|
const stored = new Map(
|
||||||
|
(byHostAndPlugin.get(`${hostId}:${manifest.id}`) ?? []).map((row) => [
|
||||||
|
row.key,
|
||||||
|
row,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const values: Record<string, unknown> = {};
|
||||||
|
for (const field of fields) {
|
||||||
|
values[field.key] = await resolveFieldValue(
|
||||||
|
field,
|
||||||
|
stored.get(field.key),
|
||||||
|
{ redactSecrets: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
perPlugin[manifest.id] = values;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(perPlugin).length > 0) result.set(hostId, perPlugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attaches the map to already-transformed host objects, in place. */
|
||||||
|
export function attachHostPluginSettings(
|
||||||
|
hosts: Record<string, unknown>[],
|
||||||
|
settings: Map<number, HostPluginSettings>,
|
||||||
|
): void {
|
||||||
|
if (settings.size === 0) return;
|
||||||
|
for (const host of hosts) {
|
||||||
|
const hostId = Number(host.id);
|
||||||
|
const values = settings.get(hostId);
|
||||||
|
if (values) host.pluginSettings = values;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The single-host convenience wrapper. */
|
||||||
|
export async function withHostPluginSettings(
|
||||||
|
host: Record<string, unknown>,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const hostId = Number(host.id);
|
||||||
|
if (!Number.isInteger(hostId)) return host;
|
||||||
|
|
||||||
|
const settings = await loadHostPluginSettings([hostId]);
|
||||||
|
const values = settings.get(hostId);
|
||||||
|
return values ? { ...host, pluginSettings: values } : host;
|
||||||
|
}
|
||||||
@@ -45,6 +45,11 @@ import {
|
|||||||
stripSensitiveFields,
|
stripSensitiveFields,
|
||||||
transformHostResponse,
|
transformHostResponse,
|
||||||
} from "./host-normalizers.js";
|
} from "./host-normalizers.js";
|
||||||
|
import {
|
||||||
|
attachHostPluginSettings,
|
||||||
|
loadHostPluginSettings,
|
||||||
|
withHostPluginSettings,
|
||||||
|
} from "./host-plugin-settings.js";
|
||||||
import { validateParentHostId } from "./host-parent-validation.js";
|
import { validateParentHostId } from "./host-parent-validation.js";
|
||||||
import { registerHostOpksshRoutes } from "./host-opkssh-routes.js";
|
import { registerHostOpksshRoutes } from "./host-opkssh-routes.js";
|
||||||
import { registerHostStepCaRoutes } from "./host-step-ca-routes.js";
|
import { registerHostStepCaRoutes } from "./host-step-ca-routes.js";
|
||||||
@@ -1701,6 +1706,18 @@ router.get(
|
|||||||
)
|
)
|
||||||
: stripSensitiveFields(host),
|
: stripSensitiveFields(host),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// After sanitizing: the connect-level projection reduces a shared host
|
||||||
|
// to an allowlist, which would drop this again. One query for the list.
|
||||||
|
attachHostPluginSettings(
|
||||||
|
sanitized,
|
||||||
|
await loadHostPluginSettings(
|
||||||
|
sanitized
|
||||||
|
.map((host) => Number(host.id))
|
||||||
|
.filter((id) => Number.isInteger(id)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
res.json(sanitized);
|
res.json(sanitized);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sshLogger.error("Failed to fetch SSH hosts from database", err, {
|
sshLogger.error("Failed to fetch SSH hosts from database", err, {
|
||||||
@@ -1767,7 +1784,9 @@ router.get(
|
|||||||
const resolved =
|
const resolved =
|
||||||
(await resolveHostCredentials(result, userId)) || result;
|
(await resolveHostCredentials(result, userId)) || result;
|
||||||
|
|
||||||
return res.json(stripSensitiveFields(resolved));
|
return res.json(
|
||||||
|
await withHostPluginSettings(stripSensitiveFields(resolved)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not the owner: shared recipients get a sanitized view of the host.
|
// Not the owner: shared recipients get a sanitized view of the host.
|
||||||
@@ -1818,9 +1837,11 @@ router.get(
|
|||||||
(await resolveHostCredentials(sharedResult, userId)) || sharedResult;
|
(await resolveHostCredentials(sharedResult, userId)) || sharedResult;
|
||||||
|
|
||||||
res.json(
|
res.json(
|
||||||
sanitizeHostForRecipient(
|
await withHostPluginSettings(
|
||||||
resolvedSharedResult,
|
sanitizeHostForRecipient(
|
||||||
accessInfo.permissionLevel,
|
resolvedSharedResult,
|
||||||
|
accessInfo.permissionLevel,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -14,9 +14,29 @@ import {
|
|||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
import { getPluginRuntime } from "../../plugins/index.js";
|
import { getPluginRuntime } from "../../plugins/index.js";
|
||||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||||
|
import {
|
||||||
|
findField,
|
||||||
|
getAllSettings,
|
||||||
|
setSetting,
|
||||||
|
type PluginSettingsScope,
|
||||||
|
} from "../../plugins/settings.js";
|
||||||
|
import {
|
||||||
|
getAuditUsername,
|
||||||
|
getRequestMeta,
|
||||||
|
logAudit,
|
||||||
|
} from "../../utils/audit-logger.js";
|
||||||
|
import type {
|
||||||
|
PluginManifest,
|
||||||
|
PluginSettingsField,
|
||||||
|
} from "@termix/plugin-sdk/manifest";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
/** A settings body is a flat object; an array or null is a client bug. */
|
||||||
|
function isPlainBody(body: unknown): body is Record<string, unknown> {
|
||||||
|
return typeof body === "object" && body !== null && !Array.isArray(body);
|
||||||
|
}
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
const authenticateJWT = authManager.createAuthMiddleware();
|
const authenticateJWT = authManager.createAuthMiddleware();
|
||||||
const permissionManager = PermissionManager.getInstance();
|
const permissionManager = PermissionManager.getInstance();
|
||||||
@@ -79,15 +99,18 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
let contributes: unknown = null;
|
let contributes: unknown = null;
|
||||||
let capabilities: string[] = [];
|
let capabilities: string[] = [];
|
||||||
|
let icon: string | undefined;
|
||||||
try {
|
try {
|
||||||
const manifest = JSON.parse(record.manifestJson) as {
|
const manifest = JSON.parse(record.manifestJson) as {
|
||||||
contributes?: unknown;
|
contributes?: unknown;
|
||||||
capabilities?: unknown;
|
capabilities?: unknown;
|
||||||
|
icon?: unknown;
|
||||||
};
|
};
|
||||||
contributes = manifest?.contributes ?? null;
|
contributes = manifest?.contributes ?? null;
|
||||||
capabilities = Array.isArray(manifest?.capabilities)
|
capabilities = Array.isArray(manifest?.capabilities)
|
||||||
? (manifest.capabilities as string[])
|
? (manifest.capabilities as string[])
|
||||||
: [];
|
: [];
|
||||||
|
icon = typeof manifest?.icon === "string" ? manifest.icon : undefined;
|
||||||
} catch {
|
} catch {
|
||||||
contributes = null;
|
contributes = null;
|
||||||
}
|
}
|
||||||
@@ -99,6 +122,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
enabled: record.state === "enabled",
|
enabled: record.state === "enabled",
|
||||||
state: loaded?.state ?? record.state,
|
state: loaded?.state ?? record.state,
|
||||||
contributes,
|
contributes,
|
||||||
|
icon,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!canManage) return summary;
|
if (!canManage) return summary;
|
||||||
@@ -528,4 +552,535 @@ router.delete(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a plugin's manifest for the settings routes.
|
||||||
|
*
|
||||||
|
* Read from the loader rather than the database row so the fields a request is
|
||||||
|
* validated against are the ones the running build declares.
|
||||||
|
*/
|
||||||
|
async function loadManifestForSettings(
|
||||||
|
pluginId: string,
|
||||||
|
): Promise<PluginManifest | null> {
|
||||||
|
const { loader } = getPluginRuntime();
|
||||||
|
const live = loader.get(pluginId);
|
||||||
|
if (live?.manifest) return live.manifest;
|
||||||
|
|
||||||
|
const record = await createCurrentPluginRepository().findById(pluginId);
|
||||||
|
if (!record) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(record.manifestJson) as PluginManifest;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the caller may write a given field.
|
||||||
|
*
|
||||||
|
* Admin fields fall back to admin.plugins.manage, so a plugin that declares no
|
||||||
|
* permission of its own still cannot have its install-wide settings changed by
|
||||||
|
* an ordinary user. A field naming its own permission uses that instead.
|
||||||
|
*/
|
||||||
|
async function canWriteField(
|
||||||
|
userId: string,
|
||||||
|
manifest: PluginManifest,
|
||||||
|
scope: PluginSettingsScope,
|
||||||
|
field: PluginSettingsField,
|
||||||
|
): Promise<boolean> {
|
||||||
|
// Admin scope always needs admin.plugins.manage. A field-level permission
|
||||||
|
// narrows who may write it; it never widens the scope's own gate, so a
|
||||||
|
// plugin cannot hand an ordinary user install-wide configuration by naming
|
||||||
|
// a permission that user happens to hold.
|
||||||
|
if (
|
||||||
|
scope === "admin" &&
|
||||||
|
!(await permissionManager.hasPermission(userId, "admin.plugins.manage"))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.permission) {
|
||||||
|
const { resolvePermission } = await import("../../plugins/rbac.js");
|
||||||
|
return permissionManager.hasPermission(
|
||||||
|
userId,
|
||||||
|
resolvePermission(manifest, field.permission),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies a body of values to one scope, collecting per-field errors. */
|
||||||
|
async function applySettings(
|
||||||
|
userId: string,
|
||||||
|
manifest: PluginManifest,
|
||||||
|
scope: PluginSettingsScope,
|
||||||
|
scopeId: string | null,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Promise<Record<string, string>> {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(body)) {
|
||||||
|
const field = findField(manifest, scope, key);
|
||||||
|
if (!field) {
|
||||||
|
errors[key] = "Not a settings field this plugin declares";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!(await canWriteField(userId, manifest, scope, field))) {
|
||||||
|
errors[key] = "You do not have permission to change this setting";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = await setSetting(manifest, scope, scopeId, key, value);
|
||||||
|
if (error) errors[key] = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /plugins/{id}/settings/admin:
|
||||||
|
* get:
|
||||||
|
* summary: Read a plugin's install-wide settings
|
||||||
|
* description: >
|
||||||
|
* Returns every admin-scope field the plugin declares, with stored values
|
||||||
|
* over declared defaults. Secret fields come back as { set: boolean } and
|
||||||
|
* never carry their value, so a browser can render "configured" without
|
||||||
|
* ever holding the secret.
|
||||||
|
* tags:
|
||||||
|
* - Plugins
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The plugin's admin settings.
|
||||||
|
* 403:
|
||||||
|
* description: The caller lacks admin.plugins.manage.
|
||||||
|
* 404:
|
||||||
|
* description: No such plugin.
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/:id/settings/admin",
|
||||||
|
authenticateJWT,
|
||||||
|
requireManagePlugins,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const pluginId = String(req.params.id);
|
||||||
|
try {
|
||||||
|
const manifest = await loadManifestForSettings(pluginId);
|
||||||
|
if (!manifest) {
|
||||||
|
res.status(404).json({ error: "No such plugin" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const values = await getAllSettings(manifest, "admin", null, {
|
||||||
|
redactSecrets: true,
|
||||||
|
});
|
||||||
|
res.json({ id: pluginId, values });
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error(
|
||||||
|
`Failed to read admin settings for plugin ${pluginId}`,
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
{ operation: "plugin_settings_read" },
|
||||||
|
);
|
||||||
|
res.status(500).json({ error: "Failed to read the plugin's settings" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /plugins/{id}/settings/admin:
|
||||||
|
* put:
|
||||||
|
* summary: Update a plugin's install-wide settings
|
||||||
|
* description: >
|
||||||
|
* Writes the given keys. Every value is validated against the field the
|
||||||
|
* manifest declares, and a key the manifest never declared is rejected
|
||||||
|
* rather than stored. Validation failures come back per field so a form
|
||||||
|
* can show every problem at once. Sending a secret field back as
|
||||||
|
* { set: true } leaves the stored value alone, so saving a form does not
|
||||||
|
* clear a key it was never shown.
|
||||||
|
* tags:
|
||||||
|
* - Plugins
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The updated settings.
|
||||||
|
* 400:
|
||||||
|
* description: One or more fields were rejected.
|
||||||
|
* 403:
|
||||||
|
* description: The caller lacks admin.plugins.manage.
|
||||||
|
* 404:
|
||||||
|
* description: No such plugin.
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
"/:id/settings/admin",
|
||||||
|
authenticateJWT,
|
||||||
|
requireManagePlugins,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const pluginId = String(req.params.id);
|
||||||
|
const userId = (req as AuthenticatedRequest).userId as string;
|
||||||
|
|
||||||
|
if (!isPlainBody(req.body)) {
|
||||||
|
res.status(400).json({ error: "Body must be an object of settings" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manifest = await loadManifestForSettings(pluginId);
|
||||||
|
if (!manifest) {
|
||||||
|
res.status(404).json({ error: "No such plugin" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = await applySettings(
|
||||||
|
userId,
|
||||||
|
manifest,
|
||||||
|
"admin",
|
||||||
|
null,
|
||||||
|
req.body,
|
||||||
|
);
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
res.status(400).json({ error: "Some settings were rejected", errors });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||||
|
await logAudit({
|
||||||
|
userId,
|
||||||
|
username: await getAuditUsername(userId),
|
||||||
|
action: "update_plugin_settings",
|
||||||
|
resourceType: "setting",
|
||||||
|
resourceId: pluginId,
|
||||||
|
resourceName: manifest.name,
|
||||||
|
// Keys only: a value here could be the secret we just encrypted.
|
||||||
|
details: JSON.stringify({
|
||||||
|
scope: "admin",
|
||||||
|
keys: Object.keys(req.body),
|
||||||
|
}),
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const values = await getAllSettings(manifest, "admin", null, {
|
||||||
|
redactSecrets: true,
|
||||||
|
});
|
||||||
|
res.json({ id: pluginId, values });
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error(
|
||||||
|
`Failed to update admin settings for plugin ${pluginId}`,
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
{ operation: "plugin_settings_write" },
|
||||||
|
);
|
||||||
|
res.status(500).json({ error: "Failed to update the plugin's settings" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /plugins/{id}/settings/user:
|
||||||
|
* get:
|
||||||
|
* summary: Read the caller's own settings for a plugin
|
||||||
|
* description: Returns every user-scope field the plugin declares for the authenticated user. The scope is always the caller, never a user id from the request.
|
||||||
|
* tags:
|
||||||
|
* - Plugins
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The caller's settings for this plugin.
|
||||||
|
* 404:
|
||||||
|
* description: No such plugin.
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/:id/settings/user",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const pluginId = String(req.params.id);
|
||||||
|
const userId = (req as AuthenticatedRequest).userId as string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manifest = await loadManifestForSettings(pluginId);
|
||||||
|
if (!manifest) {
|
||||||
|
res.status(404).json({ error: "No such plugin" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const values = await getAllSettings(manifest, "user", userId, {
|
||||||
|
redactSecrets: true,
|
||||||
|
});
|
||||||
|
res.json({ id: pluginId, values });
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error(
|
||||||
|
`Failed to read user settings for plugin ${pluginId}`,
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
{ operation: "plugin_settings_read" },
|
||||||
|
);
|
||||||
|
res.status(500).json({ error: "Failed to read the plugin's settings" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /plugins/{id}/settings/user:
|
||||||
|
* put:
|
||||||
|
* summary: Update the caller's own settings for a plugin
|
||||||
|
* description: Writes user-scope values for the authenticated user. The scope is always the caller, so one user can never write another's settings.
|
||||||
|
* tags:
|
||||||
|
* - Plugins
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The updated settings.
|
||||||
|
* 400:
|
||||||
|
* description: One or more fields were rejected.
|
||||||
|
* 404:
|
||||||
|
* description: No such plugin.
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
"/:id/settings/user",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const pluginId = String(req.params.id);
|
||||||
|
const userId = (req as AuthenticatedRequest).userId as string;
|
||||||
|
|
||||||
|
if (!isPlainBody(req.body)) {
|
||||||
|
res.status(400).json({ error: "Body must be an object of settings" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manifest = await loadManifestForSettings(pluginId);
|
||||||
|
if (!manifest) {
|
||||||
|
res.status(404).json({ error: "No such plugin" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = await applySettings(
|
||||||
|
userId,
|
||||||
|
manifest,
|
||||||
|
"user",
|
||||||
|
userId,
|
||||||
|
req.body,
|
||||||
|
);
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
res.status(400).json({ error: "Some settings were rejected", errors });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = await getAllSettings(manifest, "user", userId, {
|
||||||
|
redactSecrets: true,
|
||||||
|
});
|
||||||
|
res.json({ id: pluginId, values });
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error(
|
||||||
|
`Failed to update user settings for plugin ${pluginId}`,
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
{ operation: "plugin_settings_write" },
|
||||||
|
);
|
||||||
|
res.status(500).json({ error: "Failed to update the plugin's settings" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /plugins/{id}/settings/host/{hostId}:
|
||||||
|
* get:
|
||||||
|
* summary: Read a plugin's settings for one host
|
||||||
|
* description: Returns every host-scope field the plugin declares for that host. Requires edit access to the host, the same check the host editor itself uses.
|
||||||
|
* tags:
|
||||||
|
* - Plugins
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: path
|
||||||
|
* name: hostId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The plugin's settings for that host.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid host id.
|
||||||
|
* 403:
|
||||||
|
* description: No edit access to that host.
|
||||||
|
* 404:
|
||||||
|
* description: No such plugin.
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/:id/settings/host/:hostId",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const pluginId = String(req.params.id);
|
||||||
|
const userId = (req as AuthenticatedRequest).userId as string;
|
||||||
|
const hostId = Number(req.params.hostId);
|
||||||
|
|
||||||
|
if (!Number.isInteger(hostId) || hostId <= 0) {
|
||||||
|
res.status(400).json({ error: "Invalid host ID" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manifest = await loadManifestForSettings(pluginId);
|
||||||
|
if (!manifest) {
|
||||||
|
res.status(404).json({ error: "No such plugin" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await permissionManager.canAccessHost(
|
||||||
|
userId,
|
||||||
|
hostId,
|
||||||
|
"edit",
|
||||||
|
);
|
||||||
|
if (!access.hasAccess) {
|
||||||
|
res.status(403).json({ error: "Access denied to host" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = await getAllSettings(manifest, "host", hostId, {
|
||||||
|
redactSecrets: true,
|
||||||
|
});
|
||||||
|
res.json({ id: pluginId, hostId, values });
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error(
|
||||||
|
`Failed to read host settings for plugin ${pluginId}`,
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
{ operation: "plugin_settings_read" },
|
||||||
|
);
|
||||||
|
res.status(500).json({ error: "Failed to read the plugin's settings" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /plugins/{id}/settings/host/{hostId}:
|
||||||
|
* put:
|
||||||
|
* summary: Update a plugin's settings for one host
|
||||||
|
* description: Writes host-scope values. Requires edit access to the host, so a user who may only connect to a shared host cannot change how a plugin treats it.
|
||||||
|
* tags:
|
||||||
|
* - Plugins
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: path
|
||||||
|
* name: hostId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The updated settings.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid host id, or one or more fields were rejected.
|
||||||
|
* 403:
|
||||||
|
* description: No edit access to that host.
|
||||||
|
* 404:
|
||||||
|
* description: No such plugin.
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
"/:id/settings/host/:hostId",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const pluginId = String(req.params.id);
|
||||||
|
const userId = (req as AuthenticatedRequest).userId as string;
|
||||||
|
const hostId = Number(req.params.hostId);
|
||||||
|
|
||||||
|
if (!Number.isInteger(hostId) || hostId <= 0) {
|
||||||
|
res.status(400).json({ error: "Invalid host ID" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isPlainBody(req.body)) {
|
||||||
|
res.status(400).json({ error: "Body must be an object of settings" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manifest = await loadManifestForSettings(pluginId);
|
||||||
|
if (!manifest) {
|
||||||
|
res.status(404).json({ error: "No such plugin" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await permissionManager.canAccessHost(
|
||||||
|
userId,
|
||||||
|
hostId,
|
||||||
|
"edit",
|
||||||
|
);
|
||||||
|
if (!access.hasAccess) {
|
||||||
|
res.status(403).json({ error: "Access denied to host" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = await applySettings(
|
||||||
|
userId,
|
||||||
|
manifest,
|
||||||
|
"host",
|
||||||
|
String(hostId),
|
||||||
|
req.body,
|
||||||
|
);
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
res.status(400).json({ error: "Some settings were rejected", errors });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = await getAllSettings(manifest, "host", hostId, {
|
||||||
|
redactSecrets: true,
|
||||||
|
});
|
||||||
|
res.json({ id: pluginId, hostId, values });
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error(
|
||||||
|
`Failed to update host settings for plugin ${pluginId}`,
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
{ operation: "plugin_settings_write" },
|
||||||
|
);
|
||||||
|
res.status(500).json({ error: "Failed to update the plugin's settings" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
@@ -338,120 +338,6 @@ export function registerUserSettingsRoutes(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /users/tailscale-settings:
|
|
||||||
* get:
|
|
||||||
* summary: Get Tailscale settings
|
|
||||||
* description: Returns whether a Tailscale API key is configured (value is masked).
|
|
||||||
* tags:
|
|
||||||
* - Users
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: Tailscale settings.
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* apiKey:
|
|
||||||
* type: string
|
|
||||||
* description: Masked API key or empty string if not set.
|
|
||||||
* hasApiKey:
|
|
||||||
* type: boolean
|
|
||||||
* apiBaseUrl:
|
|
||||||
* type: string
|
|
||||||
* description: Custom control-plane API base URL (e.g. a Headscale instance), or empty string for the Tailscale default.
|
|
||||||
*/
|
|
||||||
router.get("/tailscale-settings", authenticateJWT, async (_req, res) => {
|
|
||||||
try {
|
|
||||||
const settingsRepo = createCurrentSettingsRepository();
|
|
||||||
const apiKey = (await settingsRepo.get("tailscale_api_key")) ?? "";
|
|
||||||
const apiBaseUrl =
|
|
||||||
(await settingsRepo.get("tailscale_api_base_url")) ?? "";
|
|
||||||
res.json({
|
|
||||||
apiKey: apiKey
|
|
||||||
? `${apiKey.slice(0, 6)}${"*".repeat(Math.max(0, apiKey.length - 6))}`
|
|
||||||
: "",
|
|
||||||
hasApiKey: !!apiKey,
|
|
||||||
apiBaseUrl,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
authLogger.error("Failed to get Tailscale settings", err);
|
|
||||||
res.status(500).json({ error: "Failed to get Tailscale settings" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /users/tailscale-settings:
|
|
||||||
* patch:
|
|
||||||
* summary: Update Tailscale settings (admin only)
|
|
||||||
* description: Saves or clears the Tailscale API key used for device discovery.
|
|
||||||
* tags:
|
|
||||||
* - Users
|
|
||||||
* requestBody:
|
|
||||||
* required: true
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* apiKey:
|
|
||||||
* type: string
|
|
||||||
* apiBaseUrl:
|
|
||||||
* type: string
|
|
||||||
* description: Optional custom control-plane API base URL (e.g. a Headscale instance). Leave empty to use the Tailscale default.
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: Tailscale settings updated.
|
|
||||||
* 403:
|
|
||||||
* description: Not authorized.
|
|
||||||
* 500:
|
|
||||||
* description: Failed to update Tailscale settings.
|
|
||||||
*/
|
|
||||||
router.patch("/tailscale-settings", authenticateJWT, async (req, res) => {
|
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
|
||||||
try {
|
|
||||||
const actor = await getAdminActor(userId);
|
|
||||||
if (!actor) {
|
|
||||||
return res.status(403).json({ error: "Not authorized" });
|
|
||||||
}
|
|
||||||
const { apiKey, apiBaseUrl } = req.body;
|
|
||||||
if (typeof apiKey !== "string") {
|
|
||||||
return res.status(400).json({ error: "apiKey must be a string" });
|
|
||||||
}
|
|
||||||
if (apiBaseUrl !== undefined && typeof apiBaseUrl !== "string") {
|
|
||||||
return res.status(400).json({ error: "apiBaseUrl must be a string" });
|
|
||||||
}
|
|
||||||
const settingsRepo = createCurrentSettingsRepository();
|
|
||||||
await settingsRepo.set("tailscale_api_key", apiKey);
|
|
||||||
if (apiBaseUrl !== undefined) {
|
|
||||||
await settingsRepo.set(
|
|
||||||
"tailscale_api_base_url",
|
|
||||||
apiBaseUrl.trim().replace(/\/+$/, ""),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
|
||||||
await logAudit({
|
|
||||||
userId,
|
|
||||||
username: actor.username ?? userId,
|
|
||||||
action: "update_tailscale_settings",
|
|
||||||
resourceType: "setting",
|
|
||||||
details: JSON.stringify({ hasApiKey: !!apiKey }),
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
success: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ hasApiKey: !!apiKey });
|
|
||||||
} catch (err) {
|
|
||||||
authLogger.error("Failed to update Tailscale settings", err);
|
|
||||||
res.status(500).json({ error: "Failed to update Tailscale settings" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /users/command-history-enabled:
|
* /users/command-history-enabled:
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
} from "./http.js";
|
} from "./http.js";
|
||||||
import { registerPluginWsRoute, registerPluginWsUpgrade } from "./ws.js";
|
import { registerPluginWsRoute, registerPluginWsUpgrade } from "./ws.js";
|
||||||
import { resolvePermission } from "./rbac.js";
|
import { resolvePermission } from "./rbac.js";
|
||||||
|
import * as pluginSettings from "./settings.js";
|
||||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||||
import {
|
import {
|
||||||
PluginCapabilityError,
|
PluginCapabilityError,
|
||||||
@@ -257,6 +258,18 @@ export function createPluginContext(
|
|||||||
{ action: "db_client" },
|
{ action: "db_client" },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The one settings call that leaves the plugin's own namespace, so the one
|
||||||
|
// that needs a capability and an audit line.
|
||||||
|
const settingsReadCore = guarded(
|
||||||
|
manifest,
|
||||||
|
"settings:read-core",
|
||||||
|
async (key: string) => pluginSettings.readCoreSetting(key),
|
||||||
|
{
|
||||||
|
action: "settings_read_core",
|
||||||
|
details: () => "read a core server setting",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const dbRefs = guarded(
|
const dbRefs = guarded(
|
||||||
manifest,
|
manifest,
|
||||||
"db:own",
|
"db:own",
|
||||||
@@ -474,6 +487,66 @@ export function createPluginContext(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
settings: {
|
||||||
|
// Reading and writing a plugin's OWN settings is ungated. The manifest
|
||||||
|
// already declares every field, and a plugin that had to ask permission
|
||||||
|
// to read its own configuration would be useless. Only readCore, which
|
||||||
|
// reaches outside the plugin's namespace, is a capability.
|
||||||
|
get: (key) =>
|
||||||
|
pluginSettings.getSetting(manifest, "admin", null, key) as never,
|
||||||
|
set: async (key, value) => {
|
||||||
|
const error = await pluginSettings.setSetting(
|
||||||
|
manifest,
|
||||||
|
"admin",
|
||||||
|
null,
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
if (error) throw new Error(error);
|
||||||
|
},
|
||||||
|
|
||||||
|
getUser: (userId, key) =>
|
||||||
|
pluginSettings.getSetting(manifest, "user", userId, key) as never,
|
||||||
|
setUser: async (userId, key, value) => {
|
||||||
|
const error = await pluginSettings.setSetting(
|
||||||
|
manifest,
|
||||||
|
"user",
|
||||||
|
userId,
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
if (error) throw new Error(error);
|
||||||
|
},
|
||||||
|
|
||||||
|
getHost: (hostId, key) =>
|
||||||
|
pluginSettings.getSetting(manifest, "host", hostId, key) as never,
|
||||||
|
setHost: async (hostId, key, value) => {
|
||||||
|
const error = await pluginSettings.setSetting(
|
||||||
|
manifest,
|
||||||
|
"host",
|
||||||
|
hostId,
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
if (error) throw new Error(error);
|
||||||
|
},
|
||||||
|
|
||||||
|
getAll: (scope, scopeId) =>
|
||||||
|
pluginSettings.getAllSettings(manifest, scope, scopeId ?? null),
|
||||||
|
|
||||||
|
onChange: (key, listener) => {
|
||||||
|
const unsubscribe = pluginSettings.onSettingsChange(
|
||||||
|
pluginId,
|
||||||
|
key,
|
||||||
|
listener,
|
||||||
|
);
|
||||||
|
handle.bag.add(unsubscribe, `settings listener for "${key}"`);
|
||||||
|
return unsubscribe;
|
||||||
|
},
|
||||||
|
|
||||||
|
readCore: (key) => settingsReadCore(key),
|
||||||
|
},
|
||||||
|
|
||||||
rbac: {
|
rbac: {
|
||||||
// A short name takes this plugin's prefix; another plugin's id or a core
|
// A short name takes this plugin's prefix; another plugin's id or a core
|
||||||
// group is used as given, which is what makes a cross-plugin check
|
// group is used as given, which is what makes a cross-plugin check
|
||||||
@@ -623,6 +696,7 @@ export async function disposePluginHandle(
|
|||||||
// Belt and braces: an offer made outside ctx, or one whose registration was
|
// Belt and braces: an offer made outside ctx, or one whose registration was
|
||||||
// lost to a throw mid-activate, would otherwise outlive the plugin.
|
// lost to a throw mid-activate, would otherwise outlive the plugin.
|
||||||
secretRegistry.withdrawAllForPlugin(pluginId);
|
secretRegistry.withdrawAllForPlugin(pluginId);
|
||||||
|
pluginSettings.clearSettingsListeners(pluginId);
|
||||||
|
|
||||||
// Skipped when activate() never finished: a plugin's deactivate expects the
|
// Skipped when activate() never finished: a plugin's deactivate expects the
|
||||||
// state activate builds, and calling it on a half-built plugin tends to
|
// state activate builds, and calling it on a half-built plugin tends to
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
/**
|
||||||
|
* Plugin settings: validation, storage, encryption and change notification.
|
||||||
|
*
|
||||||
|
* One module behind both ctx.settings and the /plugins/:id/settings routes, so
|
||||||
|
* "what is a valid value", "which fields are secret" and "what a read hands
|
||||||
|
* back" are decided once. Two implementations would drift, and the one that
|
||||||
|
* drifted would be the one writing to the database.
|
||||||
|
*
|
||||||
|
* A field the manifest never declared is rejected rather than stored. That is
|
||||||
|
* what stops a PUT from filling a plugin's namespace with arbitrary keys, and
|
||||||
|
* it is why every entry point resolves the field first and the value second.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
PluginManifest,
|
||||||
|
PluginSettingsField,
|
||||||
|
} from "@termix/plugin-sdk/manifest";
|
||||||
|
import {
|
||||||
|
coerceSettingValue,
|
||||||
|
isRedactedSecret,
|
||||||
|
validateSettingValue,
|
||||||
|
} from "@termix/plugin-sdk/settings";
|
||||||
|
import { createCurrentPluginSettingsRepository } from "../database/repositories/factory.js";
|
||||||
|
import type { PluginSettingsScope } from "../database/repositories/plugin-settings-repository.js";
|
||||||
|
import {
|
||||||
|
decryptSystemSecret,
|
||||||
|
encryptSystemSecret,
|
||||||
|
} from "../utils/system-secret-crypto.js";
|
||||||
|
import { pluginLogger } from "../utils/logger.js";
|
||||||
|
|
||||||
|
export type { PluginSettingsScope };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core settings a plugin may read through ctx.settings.readCore.
|
||||||
|
*
|
||||||
|
* Deliberately short. Everything here is either already visible in the UI to
|
||||||
|
* every user, or a switch a plugin has to honour to behave correctly. Adding
|
||||||
|
* to it means deciding that every plugin holding settings:read-core may see
|
||||||
|
* that value, so it is a review decision rather than a convenience.
|
||||||
|
*/
|
||||||
|
export const CORE_SETTINGS_ALLOWLIST: readonly string[] = [
|
||||||
|
"app_name",
|
||||||
|
"guac_enabled",
|
||||||
|
"global_metrics_interval",
|
||||||
|
"global_status_check_interval",
|
||||||
|
];
|
||||||
|
|
||||||
|
type ChangeListener = (value: unknown) => void;
|
||||||
|
|
||||||
|
/** pluginId -> key -> listeners. In-process only; settings are not clustered. */
|
||||||
|
const listeners = new Map<string, Map<string, Set<ChangeListener>>>();
|
||||||
|
|
||||||
|
export function onSettingsChange(
|
||||||
|
pluginId: string,
|
||||||
|
key: string,
|
||||||
|
listener: ChangeListener,
|
||||||
|
): () => void {
|
||||||
|
let byKey = listeners.get(pluginId);
|
||||||
|
if (!byKey) {
|
||||||
|
byKey = new Map();
|
||||||
|
listeners.set(pluginId, byKey);
|
||||||
|
}
|
||||||
|
let set = byKey.get(key);
|
||||||
|
if (!set) {
|
||||||
|
set = new Set();
|
||||||
|
byKey.set(key, set);
|
||||||
|
}
|
||||||
|
set.add(listener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
set!.delete(listener);
|
||||||
|
if (set!.size === 0) byKey!.delete(key);
|
||||||
|
if (byKey!.size === 0) listeners.delete(pluginId);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify(pluginId: string, key: string, value: unknown): void {
|
||||||
|
for (const listener of listeners.get(pluginId)?.get(key) ?? []) {
|
||||||
|
try {
|
||||||
|
listener(value);
|
||||||
|
} catch (error) {
|
||||||
|
// A listener throwing must not fail the write that already happened.
|
||||||
|
pluginLogger.error(
|
||||||
|
`Plugin ${pluginId} settings listener for "${key}" failed`,
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
{ operation: "plugin_settings_change" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops every listener a plugin registered. Called when it deactivates. */
|
||||||
|
export function clearSettingsListeners(pluginId: string): void {
|
||||||
|
listeners.delete(pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The fields a manifest declares for one scope. */
|
||||||
|
export function declaredFields(
|
||||||
|
manifest: PluginManifest,
|
||||||
|
scope: PluginSettingsScope,
|
||||||
|
): PluginSettingsField[] {
|
||||||
|
const settings = manifest.contributes?.settings;
|
||||||
|
if (!settings) return [];
|
||||||
|
|
||||||
|
if (scope === "admin") return settings.admin ?? [];
|
||||||
|
if (scope === "user") return settings.user ?? [];
|
||||||
|
|
||||||
|
const host = settings.host;
|
||||||
|
if (!host) return [];
|
||||||
|
|
||||||
|
// The enable switch is a real stored key, so it has to behave like a field
|
||||||
|
// everywhere: validated on write, defaulted on read, rendered in the editor.
|
||||||
|
const enableField: PluginSettingsField[] = host.enableKey
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: host.enableKey,
|
||||||
|
type: "boolean",
|
||||||
|
labelKey: host.enableLabelKey,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return [...enableField, ...host.fields];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findField(
|
||||||
|
manifest: PluginManifest,
|
||||||
|
scope: PluginSettingsScope,
|
||||||
|
key: string,
|
||||||
|
): PluginSettingsField | undefined {
|
||||||
|
return declaredFields(manifest, scope).find((field) => field.key === key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeScopeId(
|
||||||
|
scope: PluginSettingsScope,
|
||||||
|
scopeId: string | number | undefined | null,
|
||||||
|
): string | null {
|
||||||
|
if (scope === "admin") return null;
|
||||||
|
if (scopeId === undefined || scopeId === null) {
|
||||||
|
throw new Error(`Plugin settings scope "${scope}" requires a scope id`);
|
||||||
|
}
|
||||||
|
return String(scopeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stored values are JSON so a field keeps its declared type on the way back. */
|
||||||
|
function encode(value: unknown): string {
|
||||||
|
return JSON.stringify(value ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decode(raw: string | null): unknown {
|
||||||
|
if (raw === null) return undefined;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSetting(
|
||||||
|
manifest: PluginManifest,
|
||||||
|
scope: PluginSettingsScope,
|
||||||
|
scopeId: string | number | null | undefined,
|
||||||
|
key: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const field = findField(manifest, scope, key);
|
||||||
|
if (!field) return undefined;
|
||||||
|
|
||||||
|
const row = await createCurrentPluginSettingsRepository().get(
|
||||||
|
manifest.id,
|
||||||
|
scope,
|
||||||
|
normalizeScopeId(scope, scopeId),
|
||||||
|
key,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!row || row.value === null) return field.default;
|
||||||
|
|
||||||
|
const decoded = decode(row.value);
|
||||||
|
if (decoded === undefined || decoded === null) return field.default;
|
||||||
|
|
||||||
|
if (row.encrypted && typeof decoded === "string") {
|
||||||
|
return decryptSystemSecret(decoded);
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetSettingOptions {
|
||||||
|
/** Skips validation for a value core itself produced, such as a migration. */
|
||||||
|
trusted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes one value, after validating it against the declared field.
|
||||||
|
*
|
||||||
|
* Returns the error message when the value is rejected, or null on success,
|
||||||
|
* rather than throwing: a PUT reports every bad field at once and a throw per
|
||||||
|
* field would only ever surface the first.
|
||||||
|
*/
|
||||||
|
export async function setSetting(
|
||||||
|
manifest: PluginManifest,
|
||||||
|
scope: PluginSettingsScope,
|
||||||
|
scopeId: string | number | null | undefined,
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
options: SetSettingOptions = {},
|
||||||
|
): Promise<string | null> {
|
||||||
|
const field = findField(manifest, scope, key);
|
||||||
|
if (!field) {
|
||||||
|
return `"${key}" is not a settings field this plugin declares`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Echoing a redacted secret back means "leave it alone", so a form can save
|
||||||
|
// without clearing a key it was never shown.
|
||||||
|
if (field.type === "secret" && isRedactedSecret(value)) return null;
|
||||||
|
|
||||||
|
const coerced = options.trusted ? value : coerceSettingValue(field, value);
|
||||||
|
if (!options.trusted) {
|
||||||
|
const error = validateSettingValue(field, coerced);
|
||||||
|
if (error) return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clearing a secret writes null rather than an encrypted empty string, so a
|
||||||
|
// read reports it as unset instead of "set to nothing".
|
||||||
|
const clearingSecret = field.type === "secret" && coerced === "";
|
||||||
|
const shouldEncrypt =
|
||||||
|
field.type === "secret" && typeof coerced === "string" && !clearingSecret;
|
||||||
|
const stored = clearingSecret
|
||||||
|
? null
|
||||||
|
: shouldEncrypt
|
||||||
|
? encode(await encryptSystemSecret(coerced as string))
|
||||||
|
: encode(coerced);
|
||||||
|
|
||||||
|
await createCurrentPluginSettingsRepository().set(
|
||||||
|
manifest.id,
|
||||||
|
scope,
|
||||||
|
normalizeScopeId(scope, scopeId),
|
||||||
|
key,
|
||||||
|
stored,
|
||||||
|
shouldEncrypt,
|
||||||
|
);
|
||||||
|
|
||||||
|
notify(manifest.id, key, coerced);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetAllOptions {
|
||||||
|
/** Replaces every secret with { set } so the value never leaves the server. */
|
||||||
|
redactSecrets?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every declared field for one scope, with stored values over defaults.
|
||||||
|
*
|
||||||
|
* Driven by the manifest rather than by the rows, so a field that was never
|
||||||
|
* written still comes back with its default and a stale row for a field the
|
||||||
|
* plugin has since dropped is ignored instead of being handed to the UI.
|
||||||
|
*/
|
||||||
|
export async function getAllSettings(
|
||||||
|
manifest: PluginManifest,
|
||||||
|
scope: PluginSettingsScope,
|
||||||
|
scopeId: string | number | null | undefined,
|
||||||
|
options: GetAllOptions = {},
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const fields = declaredFields(manifest, scope);
|
||||||
|
if (fields.length === 0) return {};
|
||||||
|
|
||||||
|
const rows = await createCurrentPluginSettingsRepository().getAll(
|
||||||
|
manifest.id,
|
||||||
|
scope,
|
||||||
|
normalizeScopeId(scope, scopeId),
|
||||||
|
);
|
||||||
|
const byKey = new Map(rows.map((row) => [row.key, row]));
|
||||||
|
|
||||||
|
const values: Record<string, unknown> = {};
|
||||||
|
for (const field of fields) {
|
||||||
|
values[field.key] = await resolveFieldValue(
|
||||||
|
field,
|
||||||
|
byKey.get(field.key),
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared by getAllSettings and the host-list projection. */
|
||||||
|
export async function resolveFieldValue(
|
||||||
|
field: PluginSettingsField,
|
||||||
|
row: { value: string | null; encrypted: boolean } | undefined,
|
||||||
|
options: GetAllOptions = {},
|
||||||
|
): Promise<unknown> {
|
||||||
|
const decoded = row && row.value !== null ? decode(row.value) : undefined;
|
||||||
|
const hasValue = decoded !== undefined && decoded !== null && decoded !== "";
|
||||||
|
|
||||||
|
if (field.type === "secret") {
|
||||||
|
if (options.redactSecrets) return { set: hasValue };
|
||||||
|
if (!hasValue) return field.default;
|
||||||
|
return row!.encrypted && typeof decoded === "string"
|
||||||
|
? decryptSystemSecret(decoded)
|
||||||
|
: decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasValue ? decoded : field.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a core server setting. The caller has already checked
|
||||||
|
* settings:read-core; this only enforces the allowlist.
|
||||||
|
*/
|
||||||
|
export async function readCoreSetting(key: string): Promise<string | null> {
|
||||||
|
if (!CORE_SETTINGS_ALLOWLIST.includes(key)) {
|
||||||
|
throw new Error(
|
||||||
|
`Core setting "${key}" is not readable by plugins. Readable keys: ${CORE_SETTINGS_ALLOWLIST.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { createCurrentSettingsRepository } =
|
||||||
|
await import("../database/repositories/factory.js");
|
||||||
|
return createCurrentSettingsRepository().get(key);
|
||||||
|
}
|
||||||
@@ -344,6 +344,12 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
|||||||
operation: "plugin_init",
|
operation: "plugin_init",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After seeding: plugin_settings has a foreign key to plugins, so there
|
||||||
|
// is nothing to migrate into until the plugin row exists.
|
||||||
|
const { runTailscaleSettingsMigration } =
|
||||||
|
await import("./utils/crypto-migration/tailscale-settings-migration.js");
|
||||||
|
await runTailscaleSettingsMigration();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
systemLogger.warn("Plugin runtime failed to initialize", {
|
systemLogger.warn("Plugin runtime failed to initialize", {
|
||||||
operation: "plugin_init",
|
operation: "plugin_init",
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* plugin_settings cleanup when a user or host goes.
|
||||||
|
*
|
||||||
|
* scope_id is polymorphic, so it carries no foreign key and the engine will
|
||||||
|
* not cascade it. The repositories delete these rows explicitly, and these
|
||||||
|
* tests exist so a later refactor that drops those calls fails here rather
|
||||||
|
* than leaving orphaned settings behind forever.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { asc } from "drizzle-orm";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { TestSqliteDatabase } from "./repositories/test-support.js";
|
||||||
|
import { HostRepository } from "../../database/repositories/host-repository.js";
|
||||||
|
import { UserRepository } from "../../database/repositories/user-repository.js";
|
||||||
|
import type { DatabaseContext } from "../../database/repositories/database-context.js";
|
||||||
|
import { pluginSettings } from "../../database/db/schema.js";
|
||||||
|
|
||||||
|
let adapter: TestSqliteDatabase | null = null;
|
||||||
|
let ctx: DatabaseContext | null = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (adapter) {
|
||||||
|
await adapter.close();
|
||||||
|
adapter = null;
|
||||||
|
ctx = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function seed(): Promise<DatabaseContext> {
|
||||||
|
adapter = new TestSqliteDatabase();
|
||||||
|
const context = await adapter.connect();
|
||||||
|
ctx = context;
|
||||||
|
|
||||||
|
await adapter.exec(`
|
||||||
|
INSERT INTO users (id, username, password_hash)
|
||||||
|
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||||
|
|
||||||
|
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
|
||||||
|
VALUES
|
||||||
|
(7, 'user-1', 'seven', '10.0.0.7', 22, 'root', 'password'),
|
||||||
|
(8, 'user-1', 'eight', '10.0.0.8', 22, 'root', 'password'),
|
||||||
|
(9, 'user-2', 'nine', '10.0.0.9', 22, 'root', 'password');
|
||||||
|
|
||||||
|
INSERT INTO plugins (id, name, version, manifest_json)
|
||||||
|
VALUES ('sample', 'Sample', '1.0.0', '{}');
|
||||||
|
`);
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addSetting(
|
||||||
|
scope: string,
|
||||||
|
scopeId: string | null,
|
||||||
|
key = "k",
|
||||||
|
): Promise<void> {
|
||||||
|
await adapter!.exec(
|
||||||
|
`INSERT INTO plugin_settings (plugin_id, scope, scope_id, key, value)
|
||||||
|
VALUES ('sample', '${scope}', ${scopeId === null ? "NULL" : `'${scopeId}'`}, '${key}', '"v"')`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remaining(): Promise<
|
||||||
|
{ scope: string; scopeId: string | null }[]
|
||||||
|
> {
|
||||||
|
const rows = await ctx!.drizzle
|
||||||
|
.select({
|
||||||
|
scope: pluginSettings.scope,
|
||||||
|
scopeId: pluginSettings.scopeId,
|
||||||
|
})
|
||||||
|
.from(pluginSettings)
|
||||||
|
.orderBy(asc(pluginSettings.scope), asc(pluginSettings.scopeId));
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("user deletion", () => {
|
||||||
|
it("removes that user's plugin settings", async () => {
|
||||||
|
const context = await seed();
|
||||||
|
await addSetting("user", "user-1");
|
||||||
|
|
||||||
|
await new UserRepository(context).delete("user-1");
|
||||||
|
|
||||||
|
expect(await remaining()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves another user's settings alone", async () => {
|
||||||
|
const context = await seed();
|
||||||
|
await addSetting("user", "user-1");
|
||||||
|
await addSetting("user", "user-2");
|
||||||
|
|
||||||
|
await new UserRepository(context).delete("user-1");
|
||||||
|
|
||||||
|
expect(await remaining()).toEqual([{ scope: "user", scopeId: "user-2" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves admin-scope settings alone", async () => {
|
||||||
|
const context = await seed();
|
||||||
|
await addSetting("admin", null);
|
||||||
|
await addSetting("user", "user-1");
|
||||||
|
|
||||||
|
await new UserRepository(context).delete("user-1");
|
||||||
|
|
||||||
|
expect(await remaining()).toEqual([{ scope: "admin", scopeId: null }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("host deletion", () => {
|
||||||
|
it("removes that host's plugin settings", async () => {
|
||||||
|
const context = await seed();
|
||||||
|
await addSetting("host", "7");
|
||||||
|
|
||||||
|
await new HostRepository(context).deleteForUser("user-1", 7);
|
||||||
|
|
||||||
|
expect(await remaining()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves another host's settings alone", async () => {
|
||||||
|
const context = await seed();
|
||||||
|
await addSetting("host", "7");
|
||||||
|
await addSetting("host", "8");
|
||||||
|
|
||||||
|
await new HostRepository(context).deleteForUser("user-1", 7);
|
||||||
|
|
||||||
|
expect(await remaining()).toEqual([{ scope: "host", scopeId: "8" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears settings for every host in the bulk delete path", async () => {
|
||||||
|
const context = await seed();
|
||||||
|
await addSetting("host", "7");
|
||||||
|
await addSetting("host", "8");
|
||||||
|
await addSetting("host", "9");
|
||||||
|
|
||||||
|
await new HostRepository(context).deleteByUserId("user-1");
|
||||||
|
|
||||||
|
// Host 9 belongs to another user and keeps its settings.
|
||||||
|
expect(await remaining()).toEqual([{ scope: "host", scopeId: "9" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not confuse a host id with a user id", async () => {
|
||||||
|
const context = await seed();
|
||||||
|
await addSetting("host", "7");
|
||||||
|
await addSetting("user", "7");
|
||||||
|
|
||||||
|
await new HostRepository(context).deleteForUser("user-1", 7);
|
||||||
|
|
||||||
|
expect(await remaining()).toEqual([{ scope: "user", scopeId: "7" }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
/**
|
||||||
|
* The pluginSettings map attached to host responses.
|
||||||
|
*
|
||||||
|
* The host editor needs these with the host, not one request per plugin per
|
||||||
|
* host, so the shape and the query count both matter.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
pluginId: string;
|
||||||
|
scope: string;
|
||||||
|
scopeId: string | null;
|
||||||
|
key: string;
|
||||||
|
value: string | null;
|
||||||
|
encrypted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: Row[] = [];
|
||||||
|
const loaded: { id: string; manifest: PluginManifest; state: string }[] = [];
|
||||||
|
const getAllForScopeIds = vi.fn(async (scope: string, scopeIds: string[]) =>
|
||||||
|
rows.filter(
|
||||||
|
(row) => row.scope === scope && scopeIds.includes(row.scopeId ?? ""),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock("../../../utils/logger.js", () => {
|
||||||
|
const logger = {
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
|
};
|
||||||
|
return { sshLogger: logger, pluginLogger: logger, databaseLogger: logger };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentPluginSettingsRepository: () => ({ getAllForScopeIds }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../plugins/index.js", () => ({
|
||||||
|
getPluginRuntime: () => ({ loader: { list: () => loaded } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../utils/system-secret-crypto.js", () => ({
|
||||||
|
isSystemEncrypted: (value: string) => value.startsWith("sysenc:v1:"),
|
||||||
|
encryptSystemSecret: async (plaintext: string) => plaintext,
|
||||||
|
decryptSystemSecret: async (stored: string) => stored,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
attachHostPluginSettings,
|
||||||
|
loadHostPluginSettings,
|
||||||
|
withHostPluginSettings,
|
||||||
|
} = await import("../../../database/routes/host-plugin-settings.js");
|
||||||
|
|
||||||
|
function manifest(id: string, host: unknown): PluginManifest {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
version: "1.0.0",
|
||||||
|
description: "",
|
||||||
|
author: { name: "t" },
|
||||||
|
license: "MIT",
|
||||||
|
category: "Productivity",
|
||||||
|
engine: { termix: ">=2.9.0", api: "1" },
|
||||||
|
capabilities: [],
|
||||||
|
contributes: { settings: { host } },
|
||||||
|
} as PluginManifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOCKER = manifest("docker", {
|
||||||
|
enableKey: "enableDocker",
|
||||||
|
enableLabelKey: "k",
|
||||||
|
fields: [{ key: "socketPath", type: "string", labelKey: "k" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
rows.length = 0;
|
||||||
|
loaded.length = 0;
|
||||||
|
getAllForScopeIds.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadHostPluginSettings", () => {
|
||||||
|
it("returns nothing when no plugin declares host settings", async () => {
|
||||||
|
loaded.push({
|
||||||
|
id: "other",
|
||||||
|
manifest: manifest("other", undefined),
|
||||||
|
state: "active",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await loadHostPluginSettings([1, 2]);
|
||||||
|
|
||||||
|
expect(result.size).toBe(0);
|
||||||
|
expect(getAllForScopeIds).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a plugin that is not running", async () => {
|
||||||
|
loaded.push({ id: "docker", manifest: DOCKER, state: "disabled" });
|
||||||
|
|
||||||
|
const result = await loadHostPluginSettings([1]);
|
||||||
|
|
||||||
|
expect(result.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses one query for the whole list", async () => {
|
||||||
|
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
||||||
|
|
||||||
|
await loadHostPluginSettings([1, 2, 3, 4, 5]);
|
||||||
|
|
||||||
|
expect(getAllForScopeIds).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getAllForScopeIds).toHaveBeenCalledWith("host", [
|
||||||
|
"1",
|
||||||
|
"2",
|
||||||
|
"3",
|
||||||
|
"4",
|
||||||
|
"5",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges stored values over declared defaults", async () => {
|
||||||
|
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
||||||
|
rows.push({
|
||||||
|
pluginId: "docker",
|
||||||
|
scope: "host",
|
||||||
|
scopeId: "1",
|
||||||
|
key: "enableDocker",
|
||||||
|
value: JSON.stringify(true),
|
||||||
|
encrypted: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await loadHostPluginSettings([1, 2]);
|
||||||
|
|
||||||
|
expect(result.get(1)).toEqual({
|
||||||
|
docker: { enableDocker: true, socketPath: undefined },
|
||||||
|
});
|
||||||
|
// Host 2 has nothing stored, so it falls back to the declared default.
|
||||||
|
expect(result.get(2)).toEqual({
|
||||||
|
docker: { enableDocker: false, socketPath: undefined },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps one host's values out of another's", async () => {
|
||||||
|
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
||||||
|
rows.push({
|
||||||
|
pluginId: "docker",
|
||||||
|
scope: "host",
|
||||||
|
scopeId: "1",
|
||||||
|
key: "socketPath",
|
||||||
|
value: JSON.stringify("/var/run/docker.sock"),
|
||||||
|
encrypted: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await loadHostPluginSettings([1, 2]);
|
||||||
|
|
||||||
|
expect(result.get(1)!.docker.socketPath).toBe("/var/run/docker.sock");
|
||||||
|
expect(result.get(2)!.docker.socketPath).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts a host-scope secret", async () => {
|
||||||
|
loaded.push({
|
||||||
|
id: "vault",
|
||||||
|
manifest: manifest("vault", {
|
||||||
|
fields: [{ key: "token", type: "secret", labelKey: "k" }],
|
||||||
|
}),
|
||||||
|
state: "active",
|
||||||
|
});
|
||||||
|
rows.push({
|
||||||
|
pluginId: "vault",
|
||||||
|
scope: "host",
|
||||||
|
scopeId: "1",
|
||||||
|
key: "token",
|
||||||
|
value: JSON.stringify("s.verysecret"),
|
||||||
|
encrypted: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await loadHostPluginSettings([1]);
|
||||||
|
|
||||||
|
expect(result.get(1)!.vault.token).toEqual({ set: true });
|
||||||
|
expect(JSON.stringify(result.get(1))).not.toContain("verysecret");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives a failed settings read rather than failing the host list", async () => {
|
||||||
|
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
||||||
|
getAllForScopeIds.mockRejectedValueOnce(new Error("database is away"));
|
||||||
|
|
||||||
|
await expect(loadHostPluginSettings([1])).resolves.toEqual(new Map());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing for an empty host list", async () => {
|
||||||
|
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
||||||
|
|
||||||
|
expect((await loadHostPluginSettings([])).size).toBe(0);
|
||||||
|
expect(getAllForScopeIds).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("attachHostPluginSettings", () => {
|
||||||
|
it("attaches values to the matching host", () => {
|
||||||
|
const hosts = [{ id: 1 }, { id: 2 }] as Record<string, unknown>[];
|
||||||
|
const settings = new Map([[1, { docker: { enableDocker: true } }]]);
|
||||||
|
|
||||||
|
attachHostPluginSettings(hosts, settings);
|
||||||
|
|
||||||
|
expect(hosts[0].pluginSettings).toEqual({ docker: { enableDocker: true } });
|
||||||
|
expect(hosts[1].pluginSettings).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves hosts alone when there is nothing to attach", () => {
|
||||||
|
const hosts = [{ id: 1 }] as Record<string, unknown>[];
|
||||||
|
|
||||||
|
attachHostPluginSettings(hosts, new Map());
|
||||||
|
|
||||||
|
expect(hosts[0]).toEqual({ id: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("withHostPluginSettings", () => {
|
||||||
|
it("adds the map to a single host", async () => {
|
||||||
|
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
||||||
|
|
||||||
|
const result = await withHostPluginSettings({ id: 1, name: "web" });
|
||||||
|
|
||||||
|
expect(result.name).toBe("web");
|
||||||
|
expect(result.pluginSettings).toEqual({
|
||||||
|
docker: { enableDocker: false, socketPath: undefined },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the host unchanged when nothing contributes", async () => {
|
||||||
|
const host = { id: 1, name: "web" };
|
||||||
|
|
||||||
|
expect(await withHostPluginSettings(host)).toBe(host);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the host unchanged when its id is not a number", async () => {
|
||||||
|
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
||||||
|
const host = { name: "web" };
|
||||||
|
|
||||||
|
expect(await withHostPluginSettings(host)).toBe(host);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
/**
|
||||||
|
* contributes.settings validation.
|
||||||
|
*
|
||||||
|
* A settings field is the one thing in a manifest that core writes to the
|
||||||
|
* database on a plugin's behalf, so the schema has to reject anything it would
|
||||||
|
* not know how to store or render.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
parseManifest,
|
||||||
|
validateManifest,
|
||||||
|
SUPPORTED_PLUGIN_API_VERSION,
|
||||||
|
} from "../../plugins/manifest.js";
|
||||||
|
|
||||||
|
function base(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: "sample-plugin",
|
||||||
|
name: "Sample Plugin",
|
||||||
|
version: "1.0.0",
|
||||||
|
description: "A fixture.",
|
||||||
|
author: { name: "Termix Tests" },
|
||||||
|
license: "MIT",
|
||||||
|
category: "Productivity",
|
||||||
|
engine: { termix: ">=2.9.0", api: SUPPORTED_PLUGIN_API_VERSION },
|
||||||
|
capabilities: ["kv:own"],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function withSettings(settings: unknown) {
|
||||||
|
return base({ contributes: { settings } });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("contributes.settings", () => {
|
||||||
|
it("accepts a manifest with every field type", () => {
|
||||||
|
const errors = validateManifest(
|
||||||
|
withSettings({
|
||||||
|
admin: [
|
||||||
|
{ key: "apiKey", type: "secret", labelKey: "k" },
|
||||||
|
{ key: "baseUrl", type: "string", labelKey: "k", default: "" },
|
||||||
|
{ key: "retries", type: "number", labelKey: "k", min: 0, max: 5 },
|
||||||
|
{ key: "enabled", type: "boolean", labelKey: "k" },
|
||||||
|
{
|
||||||
|
key: "mode",
|
||||||
|
type: "select",
|
||||||
|
labelKey: "k",
|
||||||
|
options: [{ value: "a", labelKey: "k" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "tags",
|
||||||
|
type: "multiselect",
|
||||||
|
labelKey: "k",
|
||||||
|
options: [{ value: "a", labelKey: "k" }],
|
||||||
|
},
|
||||||
|
{ key: "notes", type: "textarea", labelKey: "k" },
|
||||||
|
{ key: "raw", type: "json", labelKey: "k" },
|
||||||
|
{ key: "browser", type: "custom", component: "devices" },
|
||||||
|
],
|
||||||
|
user: [{ key: "theme", type: "string", labelKey: "k" }],
|
||||||
|
host: {
|
||||||
|
enableKey: "enableThing",
|
||||||
|
enableLabelKey: "k",
|
||||||
|
fields: [{ key: "port", type: "number", labelKey: "k" }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a manifest with no settings at all", () => {
|
||||||
|
expect(validateManifest(base())).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown field property", () => {
|
||||||
|
const errors = validateManifest(
|
||||||
|
withSettings({
|
||||||
|
admin: [{ key: "a", type: "string", labelKey: "k", wat: 1 }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errors.join(" ")).toContain('Unknown field "wat"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown scope", () => {
|
||||||
|
const errors = validateManifest(
|
||||||
|
withSettings({ global: [{ key: "a", type: "string", labelKey: "k" }] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errors.join(" ")).toContain('Unknown field "global"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown field type", () => {
|
||||||
|
const errors = validateManifest(
|
||||||
|
withSettings({ admin: [{ key: "a", type: "colour", labelKey: "k" }] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errors.join(" ")).toContain("must be one of");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a duplicate key within one scope", () => {
|
||||||
|
const errors = validateManifest(
|
||||||
|
withSettings({
|
||||||
|
admin: [
|
||||||
|
{ key: "a", type: "string", labelKey: "k" },
|
||||||
|
{ key: "a", type: "number", labelKey: "k" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errors.join(" ")).toContain('duplicates "a"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows the same key in two different scopes", () => {
|
||||||
|
const errors = validateManifest(
|
||||||
|
withSettings({
|
||||||
|
admin: [{ key: "shared", type: "string", labelKey: "k" }],
|
||||||
|
user: [{ key: "shared", type: "string", labelKey: "k" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires labelKey for everything but a custom field", () => {
|
||||||
|
expect(
|
||||||
|
validateManifest(
|
||||||
|
withSettings({ admin: [{ key: "a", type: "string" }] }),
|
||||||
|
).join(" "),
|
||||||
|
).toContain("labelKey");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
validateManifest(
|
||||||
|
withSettings({ admin: [{ key: "a", type: "custom", component: "x" }] }),
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires component on a custom field and forbids it elsewhere", () => {
|
||||||
|
expect(
|
||||||
|
validateManifest(
|
||||||
|
withSettings({ admin: [{ key: "a", type: "custom" }] }),
|
||||||
|
).join(" "),
|
||||||
|
).toContain("component");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
validateManifest(
|
||||||
|
withSettings({
|
||||||
|
admin: [{ key: "a", type: "string", labelKey: "k", component: "x" }],
|
||||||
|
}),
|
||||||
|
).join(" "),
|
||||||
|
).toContain('only valid when type is "custom"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires non-empty options on select and multiselect", () => {
|
||||||
|
for (const type of ["select", "multiselect"]) {
|
||||||
|
expect(
|
||||||
|
validateManifest(
|
||||||
|
withSettings({ admin: [{ key: "a", type, labelKey: "k" }] }),
|
||||||
|
).join(" "),
|
||||||
|
).toContain("options must be a non-empty array");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects options on a field that has no options", () => {
|
||||||
|
const errors = validateManifest(
|
||||||
|
withSettings({
|
||||||
|
admin: [
|
||||||
|
{
|
||||||
|
key: "a",
|
||||||
|
type: "string",
|
||||||
|
labelKey: "k",
|
||||||
|
options: [{ value: "x", labelKey: "k" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errors.join(" ")).toContain("only valid for select and multiselect");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects min greater than max", () => {
|
||||||
|
const errors = validateManifest(
|
||||||
|
withSettings({
|
||||||
|
admin: [{ key: "a", type: "number", labelKey: "k", min: 10, max: 1 }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errors.join(" ")).toContain("must not be greater than");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects min or max on a non-number field", () => {
|
||||||
|
const errors = validateManifest(
|
||||||
|
withSettings({
|
||||||
|
admin: [{ key: "a", type: "string", labelKey: "k", min: 1 }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errors.join(" ")).toContain("only valid for number fields");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects requires that names no boolean in the same scope", () => {
|
||||||
|
const errors = validateManifest(
|
||||||
|
withSettings({
|
||||||
|
admin: [
|
||||||
|
{ key: "a", type: "string", labelKey: "k", requires: "missing" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errors.join(" ")).toContain("must name a boolean field");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects requires that names a non-boolean field", () => {
|
||||||
|
const errors = validateManifest(
|
||||||
|
withSettings({
|
||||||
|
admin: [
|
||||||
|
{ key: "flag", type: "string", labelKey: "k" },
|
||||||
|
{ key: "a", type: "string", labelKey: "k", requires: "flag" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errors.join(" ")).toContain("must name a boolean field");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts requires pointing at a boolean declared later", () => {
|
||||||
|
const errors = validateManifest(
|
||||||
|
withSettings({
|
||||||
|
admin: [
|
||||||
|
{ key: "a", type: "string", labelKey: "k", requires: "flag" },
|
||||||
|
{ key: "flag", type: "boolean", labelKey: "k" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a host field require the section's enable key", () => {
|
||||||
|
const errors = validateManifest(
|
||||||
|
withSettings({
|
||||||
|
host: {
|
||||||
|
enableKey: "enableThing",
|
||||||
|
enableLabelKey: "k",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: "port",
|
||||||
|
type: "number",
|
||||||
|
labelKey: "k",
|
||||||
|
requires: "enableThing",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a host field colliding with the enable key", () => {
|
||||||
|
const errors = validateManifest(
|
||||||
|
withSettings({
|
||||||
|
host: {
|
||||||
|
enableKey: "enableThing",
|
||||||
|
enableLabelKey: "k",
|
||||||
|
fields: [{ key: "enableThing", type: "boolean", labelKey: "k" }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errors.join(" ")).toContain("already the section's enableKey");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires enableLabelKey alongside enableKey", () => {
|
||||||
|
const errors = validateManifest(
|
||||||
|
withSettings({
|
||||||
|
host: { enableKey: "enableThing", fields: [] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errors.join(" ")).toContain("enableLabelKey");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a key that would not be index-safe", () => {
|
||||||
|
const errors = validateManifest(
|
||||||
|
withSettings({
|
||||||
|
admin: [{ key: "a".repeat(80), type: "string", labelKey: "k" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errors.join(" ")).toContain("short alphanumeric key");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("settings field permissions", () => {
|
||||||
|
it("rejects a short permission the plugin does not declare", () => {
|
||||||
|
const { errors } = parseManifest(
|
||||||
|
base({
|
||||||
|
contributes: {
|
||||||
|
settings: {
|
||||||
|
user: [
|
||||||
|
{ key: "a", type: "string", labelKey: "k", permission: "use" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errors.join(" ")).toContain(
|
||||||
|
"not declared in contributes.permissions",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a short permission the plugin declares", () => {
|
||||||
|
const { errors, manifest } = parseManifest(
|
||||||
|
base({
|
||||||
|
contributes: {
|
||||||
|
permissions: [{ name: "use", titleKey: "k", descriptionKey: "k" }],
|
||||||
|
settings: {
|
||||||
|
user: [
|
||||||
|
{ key: "a", type: "string", labelKey: "k", permission: "use" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
expect(manifest?.contributes?.settings?.user?.[0].permission).toBe("use");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a fully qualified permission from another namespace", () => {
|
||||||
|
// Cross-namespace ids cannot be checked here: the validator has no idea
|
||||||
|
// which other plugins exist. Taken as given, exactly like ctx.rbac does.
|
||||||
|
const { errors } = parseManifest(
|
||||||
|
base({
|
||||||
|
contributes: {
|
||||||
|
settings: {
|
||||||
|
user: [
|
||||||
|
{
|
||||||
|
key: "a",
|
||||||
|
type: "string",
|
||||||
|
labelKey: "k",
|
||||||
|
permission: "hosts.view",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
/**
|
||||||
|
* The /plugins/:id/settings routes.
|
||||||
|
*
|
||||||
|
* These decide who may change what, so the tests are mostly about refusal: a
|
||||||
|
* non-admin must not write install-wide configuration, a user must not write
|
||||||
|
* another user's settings or a host they cannot edit, and a secret must never
|
||||||
|
* come back over the wire.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import type { Server } from "node:http";
|
||||||
|
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
userId: "user-1",
|
||||||
|
permissions: new Set<string>(),
|
||||||
|
hostEditAccess: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/logger.js", () => {
|
||||||
|
const logger = {
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
|
};
|
||||||
|
return { pluginLogger: logger, databaseLogger: logger, apiLogger: logger };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../../utils/auth-manager.js", () => ({
|
||||||
|
AuthManager: {
|
||||||
|
getInstance: () => ({
|
||||||
|
createAuthMiddleware:
|
||||||
|
() =>
|
||||||
|
(
|
||||||
|
req: express.Request,
|
||||||
|
res: express.Response,
|
||||||
|
next: express.NextFunction,
|
||||||
|
) => {
|
||||||
|
(req as express.Request & { userId?: string }).userId = state.userId;
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/permission-manager.js", () => ({
|
||||||
|
PermissionManager: {
|
||||||
|
getInstance: () => ({
|
||||||
|
hasPermission: async (_userId: string, permission: string) =>
|
||||||
|
state.permissions.has(permission),
|
||||||
|
requirePermission:
|
||||||
|
(permission: string) =>
|
||||||
|
(
|
||||||
|
_req: express.Request,
|
||||||
|
res: express.Response,
|
||||||
|
next: express.NextFunction,
|
||||||
|
) => {
|
||||||
|
if (!state.permissions.has(permission)) {
|
||||||
|
res.status(403).json({ error: "Insufficient permissions" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
canAccessHost: async () => ({
|
||||||
|
hasAccess: state.hostEditAccess,
|
||||||
|
isOwner: state.hostEditAccess,
|
||||||
|
isShared: false,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/audit-logger.js", () => ({
|
||||||
|
logAudit: vi.fn(async () => {}),
|
||||||
|
getAuditUsername: vi.fn(async (id: string) => id),
|
||||||
|
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const MANIFEST: PluginManifest = {
|
||||||
|
id: "sample",
|
||||||
|
name: "Sample",
|
||||||
|
version: "1.0.0",
|
||||||
|
description: "",
|
||||||
|
author: { name: "t" },
|
||||||
|
license: "MIT",
|
||||||
|
category: "Productivity",
|
||||||
|
engine: { termix: ">=2.9.0", api: "1" },
|
||||||
|
capabilities: [],
|
||||||
|
contributes: {
|
||||||
|
permissions: [{ name: "tweak", titleKey: "k", descriptionKey: "k" }],
|
||||||
|
settings: {
|
||||||
|
admin: [
|
||||||
|
{ key: "apiKey", type: "secret", labelKey: "k" },
|
||||||
|
{ key: "retries", type: "number", labelKey: "k", min: 0, max: 5 },
|
||||||
|
{
|
||||||
|
key: "narrowed",
|
||||||
|
type: "string",
|
||||||
|
labelKey: "k",
|
||||||
|
permission: "tweak",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
user: [
|
||||||
|
{ key: "theme", type: "string", labelKey: "k", default: "dark" },
|
||||||
|
{
|
||||||
|
key: "advanced",
|
||||||
|
type: "string",
|
||||||
|
labelKey: "k",
|
||||||
|
permission: "tweak",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
host: {
|
||||||
|
enableKey: "enableThing",
|
||||||
|
enableLabelKey: "k",
|
||||||
|
fields: [{ key: "port", type: "number", labelKey: "k" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as PluginManifest;
|
||||||
|
|
||||||
|
vi.mock("../../plugins/index.js", () => ({
|
||||||
|
getPluginRuntime: () => ({
|
||||||
|
loader: {
|
||||||
|
get: (id: string) =>
|
||||||
|
id === "sample"
|
||||||
|
? { id, manifest: MANIFEST, state: "active" }
|
||||||
|
: undefined,
|
||||||
|
list: () => [{ id: "sample", manifest: MANIFEST, state: "active" }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
pluginId: string;
|
||||||
|
scope: string;
|
||||||
|
scopeId: string | null;
|
||||||
|
key: string;
|
||||||
|
value: string | null;
|
||||||
|
encrypted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: Row[] = [];
|
||||||
|
|
||||||
|
function find(scope: string, scopeId: string | null, key: string) {
|
||||||
|
return rows.find(
|
||||||
|
(row) => row.scope === scope && row.scopeId === scopeId && row.key === key,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock("../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentPluginRepository: () => ({
|
||||||
|
findById: async (id: string) =>
|
||||||
|
id === "sample"
|
||||||
|
? { id, manifestJson: JSON.stringify(MANIFEST), state: "enabled" }
|
||||||
|
: null,
|
||||||
|
listAll: async () => [],
|
||||||
|
}),
|
||||||
|
createCurrentPluginPermissionGrantRepository: () => ({
|
||||||
|
listByPlugin: async () => [],
|
||||||
|
}),
|
||||||
|
createCurrentPluginSettingsRepository: () => ({
|
||||||
|
get: async (
|
||||||
|
_pluginId: string,
|
||||||
|
scope: string,
|
||||||
|
scopeId: string | null,
|
||||||
|
key: string,
|
||||||
|
) => find(scope, scopeId, key) ?? null,
|
||||||
|
getAll: async (_pluginId: string, scope: string, scopeId: string | null) =>
|
||||||
|
rows.filter((row) => row.scope === scope && row.scopeId === scopeId),
|
||||||
|
set: async (
|
||||||
|
pluginId: string,
|
||||||
|
scope: string,
|
||||||
|
scopeId: string | null,
|
||||||
|
key: string,
|
||||||
|
value: string | null,
|
||||||
|
encrypted = false,
|
||||||
|
) => {
|
||||||
|
const existing = find(scope, scopeId, key);
|
||||||
|
if (existing) {
|
||||||
|
existing.value = value;
|
||||||
|
existing.encrypted = encrypted;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rows.push({ pluginId, scope, scopeId, key, value, encrypted });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
createCurrentSettingsRepository: () => ({ get: async () => null }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/system-secret-crypto.js", () => ({
|
||||||
|
isSystemEncrypted: (value: string) => value.startsWith("sysenc:v1:"),
|
||||||
|
encryptSystemSecret: async (plaintext: string) =>
|
||||||
|
!plaintext || plaintext.startsWith("sysenc:v1:")
|
||||||
|
? plaintext
|
||||||
|
: `sysenc:v1:${Buffer.from(plaintext, "utf8").toString("base64")}`,
|
||||||
|
decryptSystemSecret: async (stored: string) =>
|
||||||
|
stored.startsWith("sysenc:v1:")
|
||||||
|
? Buffer.from(stored.slice("sysenc:v1:".length), "base64").toString(
|
||||||
|
"utf8",
|
||||||
|
)
|
||||||
|
: stored,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pluginRoutes = (await import("../../database/routes/plugins.js")).default;
|
||||||
|
|
||||||
|
let server: Server;
|
||||||
|
let baseUrl: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
rows.length = 0;
|
||||||
|
state.userId = "user-1";
|
||||||
|
state.permissions = new Set<string>();
|
||||||
|
state.hostEditAccess = true;
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/plugins", pluginRoutes);
|
||||||
|
|
||||||
|
server = await new Promise<Server>((resolve) => {
|
||||||
|
const created = app.listen(0, "127.0.0.1", () => resolve(created));
|
||||||
|
});
|
||||||
|
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
});
|
||||||
|
|
||||||
|
function call(path: string, init?: RequestInit) {
|
||||||
|
return fetch(`${baseUrl}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: { "content-type": "application/json", ...(init?.headers ?? {}) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("admin scope", () => {
|
||||||
|
it("refuses a reader without admin.plugins.manage", async () => {
|
||||||
|
const response = await call("/plugins/sample/settings/admin");
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a writer without admin.plugins.manage", async () => {
|
||||||
|
const response = await call("/plugins/sample/settings/admin", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ retries: 2 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(rows).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets an admin read and write", async () => {
|
||||||
|
state.permissions.add("admin.plugins.manage");
|
||||||
|
|
||||||
|
const write = await call("/plugins/sample/settings/admin", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ retries: 3 }),
|
||||||
|
});
|
||||||
|
expect(write.status).toBe(200);
|
||||||
|
|
||||||
|
const read = await call("/plugins/sample/settings/admin");
|
||||||
|
expect((await read.json()).values.retries).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a rejected value per field", async () => {
|
||||||
|
state.permissions.add("admin.plugins.manage");
|
||||||
|
|
||||||
|
const response = await call("/plugins/sample/settings/admin", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ retries: 99 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect((await response.json()).errors.retries).toContain("at most 5");
|
||||||
|
expect(rows).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a key the manifest never declared", async () => {
|
||||||
|
state.permissions.add("admin.plugins.manage");
|
||||||
|
|
||||||
|
const response = await call("/plugins/sample/settings/admin", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ somethingElse: "x" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect((await response.json()).errors.somethingElse).toBeDefined();
|
||||||
|
expect(rows).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never returns a secret's value", async () => {
|
||||||
|
state.permissions.add("admin.plugins.manage");
|
||||||
|
|
||||||
|
await call("/plugins/sample/settings/admin", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ apiKey: "tskey-very-secret" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await call("/plugins/sample/settings/admin");
|
||||||
|
const body = await response.text();
|
||||||
|
|
||||||
|
expect(body).not.toContain("tskey-very-secret");
|
||||||
|
expect(JSON.parse(body).values.apiKey).toEqual({ set: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a redacted echo as no change", async () => {
|
||||||
|
state.permissions.add("admin.plugins.manage");
|
||||||
|
|
||||||
|
await call("/plugins/sample/settings/admin", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ apiKey: "tskey-original" }),
|
||||||
|
});
|
||||||
|
await call("/plugins/sample/settings/admin", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ apiKey: { set: true }, retries: 1 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const row = rows.find((entry) => entry.key === "apiKey")!;
|
||||||
|
expect(
|
||||||
|
Buffer.from(
|
||||||
|
JSON.parse(row.value!).slice("sysenc:v1:".length),
|
||||||
|
"base64",
|
||||||
|
).toString("utf8"),
|
||||||
|
).toBe("tskey-original");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the admin gate even for a field naming its own permission", async () => {
|
||||||
|
// Holding the field's permission is not enough: admin scope is admin
|
||||||
|
// scope, so a plugin cannot hand install-wide config to an ordinary user.
|
||||||
|
state.permissions.add("sample.tweak");
|
||||||
|
|
||||||
|
const response = await call("/plugins/sample/settings/admin", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ narrowed: "x" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(rows).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("narrows an admin field to holders of its permission", async () => {
|
||||||
|
state.permissions.add("admin.plugins.manage");
|
||||||
|
|
||||||
|
const denied = await call("/plugins/sample/settings/admin", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ narrowed: "x" }),
|
||||||
|
});
|
||||||
|
expect(denied.status).toBe(400);
|
||||||
|
expect((await denied.json()).errors.narrowed).toContain("permission");
|
||||||
|
|
||||||
|
state.permissions.add("sample.tweak");
|
||||||
|
const allowed = await call("/plugins/sample/settings/admin", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ narrowed: "x" }),
|
||||||
|
});
|
||||||
|
expect(allowed.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("404s for a plugin that does not exist", async () => {
|
||||||
|
state.permissions.add("admin.plugins.manage");
|
||||||
|
|
||||||
|
const response = await call("/plugins/nope/settings/admin");
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a body that is not an object", async () => {
|
||||||
|
state.permissions.add("admin.plugins.manage");
|
||||||
|
|
||||||
|
const response = await call("/plugins/sample/settings/admin", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify([1, 2, 3]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("user scope", () => {
|
||||||
|
it("lets any authenticated user read and write their own", async () => {
|
||||||
|
const write = await call("/plugins/sample/settings/user", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ theme: "light" }),
|
||||||
|
});
|
||||||
|
expect(write.status).toBe(200);
|
||||||
|
|
||||||
|
const read = await call("/plugins/sample/settings/user");
|
||||||
|
expect((await read.json()).values.theme).toBe("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes writes to the caller, never to a user id in the body", async () => {
|
||||||
|
await call("/plugins/sample/settings/user", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ theme: "light", userId: "someone-else" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// The bogus key is rejected and the row belongs to the caller.
|
||||||
|
expect(rows.every((row) => row.scopeId === "user-1")).toBe(true);
|
||||||
|
|
||||||
|
state.userId = "user-2";
|
||||||
|
const other = await call("/plugins/sample/settings/user");
|
||||||
|
expect((await other.json()).values.theme).toBe("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a user field gated on a permission the caller lacks", async () => {
|
||||||
|
const response = await call("/plugins/sample/settings/user", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ advanced: "x" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect((await response.json()).errors.advanced).toContain("permission");
|
||||||
|
expect(rows).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows it once the caller holds that permission", async () => {
|
||||||
|
state.permissions.add("sample.tweak");
|
||||||
|
|
||||||
|
const response = await call("/plugins/sample/settings/user", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ advanced: "x" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an admin field through the user route", async () => {
|
||||||
|
const response = await call("/plugins/sample/settings/user", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ apiKey: "nope" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(rows).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("host scope", () => {
|
||||||
|
it("lets a user with edit access read and write", async () => {
|
||||||
|
const write = await call("/plugins/sample/settings/host/7", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ enableThing: true, port: 22 }),
|
||||||
|
});
|
||||||
|
expect(write.status).toBe(200);
|
||||||
|
|
||||||
|
const read = await call("/plugins/sample/settings/host/7");
|
||||||
|
const values = (await read.json()).values;
|
||||||
|
expect(values.enableThing).toBe(true);
|
||||||
|
expect(values.port).toBe(22);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a host the caller cannot edit", async () => {
|
||||||
|
state.hostEditAccess = false;
|
||||||
|
|
||||||
|
const read = await call("/plugins/sample/settings/host/7");
|
||||||
|
expect(read.status).toBe(403);
|
||||||
|
|
||||||
|
const write = await call("/plugins/sample/settings/host/7", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ port: 22 }),
|
||||||
|
});
|
||||||
|
expect(write.status).toBe(403);
|
||||||
|
expect(rows).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid host id", async () => {
|
||||||
|
const response = await call("/plugins/sample/settings/host/not-a-number");
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps each host's values apart", async () => {
|
||||||
|
await call("/plugins/sample/settings/host/7", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ port: 22 }),
|
||||||
|
});
|
||||||
|
await call("/plugins/sample/settings/host/8", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ port: 2222 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const seven = await (await call("/plugins/sample/settings/host/7")).json();
|
||||||
|
const eight = await (await call("/plugins/sample/settings/host/8")).json();
|
||||||
|
|
||||||
|
expect(seven.values.port).toBe(22);
|
||||||
|
expect(eight.values.port).toBe(2222);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the declared default for an untouched host", async () => {
|
||||||
|
const response = await call("/plugins/sample/settings/host/9");
|
||||||
|
|
||||||
|
expect((await response.json()).values.enableThing).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
/**
|
||||||
|
* The plugin settings service.
|
||||||
|
*
|
||||||
|
* Storage is faked at the repository boundary; everything above it - schema
|
||||||
|
* lookup, validation, encryption, redaction and change notification - is the
|
||||||
|
* real implementation, because that is where the behaviour worth protecting
|
||||||
|
* lives.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
pluginId: string;
|
||||||
|
scope: string;
|
||||||
|
scopeId: string | null;
|
||||||
|
key: string;
|
||||||
|
value: string | null;
|
||||||
|
encrypted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: Row[] = [];
|
||||||
|
|
||||||
|
function find(
|
||||||
|
pluginId: string,
|
||||||
|
scope: string,
|
||||||
|
scopeId: string | null,
|
||||||
|
key: string,
|
||||||
|
) {
|
||||||
|
return rows.find(
|
||||||
|
(row) =>
|
||||||
|
row.pluginId === pluginId &&
|
||||||
|
row.scope === scope &&
|
||||||
|
row.scopeId === scopeId &&
|
||||||
|
row.key === key,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsRepository = {
|
||||||
|
get: vi.fn(
|
||||||
|
async (
|
||||||
|
pluginId: string,
|
||||||
|
scope: string,
|
||||||
|
scopeId: string | null,
|
||||||
|
key: string,
|
||||||
|
) => find(pluginId, scope, scopeId, key) ?? null,
|
||||||
|
),
|
||||||
|
getAll: vi.fn(
|
||||||
|
async (pluginId: string, scope: string, scopeId: string | null) =>
|
||||||
|
rows.filter(
|
||||||
|
(row) =>
|
||||||
|
row.pluginId === pluginId &&
|
||||||
|
row.scope === scope &&
|
||||||
|
row.scopeId === scopeId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
set: vi.fn(
|
||||||
|
async (
|
||||||
|
pluginId: string,
|
||||||
|
scope: string,
|
||||||
|
scopeId: string | null,
|
||||||
|
key: string,
|
||||||
|
value: string | null,
|
||||||
|
encrypted = false,
|
||||||
|
) => {
|
||||||
|
const existing = find(pluginId, scope, scopeId, key);
|
||||||
|
if (existing) {
|
||||||
|
existing.value = value;
|
||||||
|
existing.encrypted = encrypted;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rows.push({ pluginId, scope, scopeId, key, value, encrypted });
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const coreSettings = new Map<string, string>();
|
||||||
|
|
||||||
|
vi.mock("../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentPluginSettingsRepository: () => settingsRepository,
|
||||||
|
createCurrentSettingsRepository: () => ({
|
||||||
|
get: async (key: string) => coreSettings.get(key) ?? null,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Real AES would need the system key on disk. The prefix and the idempotence
|
||||||
|
// are what the service depends on, so the double keeps both.
|
||||||
|
vi.mock("../../utils/system-secret-crypto.js", () => ({
|
||||||
|
isSystemEncrypted: (value: string) => value.startsWith("sysenc:v1:"),
|
||||||
|
encryptSystemSecret: async (plaintext: string) =>
|
||||||
|
!plaintext || plaintext.startsWith("sysenc:v1:")
|
||||||
|
? plaintext
|
||||||
|
: `sysenc:v1:${Buffer.from(plaintext, "utf8").toString("base64")}`,
|
||||||
|
decryptSystemSecret: async (stored: string) =>
|
||||||
|
stored.startsWith("sysenc:v1:")
|
||||||
|
? Buffer.from(stored.slice("sysenc:v1:".length), "base64").toString(
|
||||||
|
"utf8",
|
||||||
|
)
|
||||||
|
: stored,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
CORE_SETTINGS_ALLOWLIST,
|
||||||
|
clearSettingsListeners,
|
||||||
|
declaredFields,
|
||||||
|
findField,
|
||||||
|
getAllSettings,
|
||||||
|
getSetting,
|
||||||
|
onSettingsChange,
|
||||||
|
readCoreSetting,
|
||||||
|
setSetting,
|
||||||
|
} = await import("../../plugins/settings.js");
|
||||||
|
|
||||||
|
function manifest(settings: unknown): PluginManifest {
|
||||||
|
return {
|
||||||
|
id: "sample",
|
||||||
|
name: "Sample",
|
||||||
|
version: "1.0.0",
|
||||||
|
description: "",
|
||||||
|
author: { name: "t" },
|
||||||
|
license: "MIT",
|
||||||
|
category: "Productivity",
|
||||||
|
engine: { termix: ">=2.9.0", api: "1" },
|
||||||
|
capabilities: [],
|
||||||
|
contributes: { settings },
|
||||||
|
} as PluginManifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADMIN = manifest({
|
||||||
|
admin: [
|
||||||
|
{ key: "apiKey", type: "secret", labelKey: "k" },
|
||||||
|
{ key: "baseUrl", type: "string", labelKey: "k", default: "https://x" },
|
||||||
|
{ key: "retries", type: "number", labelKey: "k", min: 0, max: 5 },
|
||||||
|
{ key: "enabled", type: "boolean", labelKey: "k", default: false },
|
||||||
|
{
|
||||||
|
key: "mode",
|
||||||
|
type: "select",
|
||||||
|
labelKey: "k",
|
||||||
|
options: [
|
||||||
|
{ value: "fast", labelKey: "k" },
|
||||||
|
{ value: "slow", labelKey: "k" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
rows.length = 0;
|
||||||
|
coreSettings.clear();
|
||||||
|
clearSettingsListeners("sample");
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("declaredFields", () => {
|
||||||
|
it("returns the fields for each scope", () => {
|
||||||
|
expect(declaredFields(ADMIN, "admin").map((f) => f.key)).toEqual([
|
||||||
|
"apiKey",
|
||||||
|
"baseUrl",
|
||||||
|
"retries",
|
||||||
|
"enabled",
|
||||||
|
"mode",
|
||||||
|
]);
|
||||||
|
expect(declaredFields(ADMIN, "user")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats the host enable key as a real boolean field", () => {
|
||||||
|
const withHost = manifest({
|
||||||
|
host: {
|
||||||
|
enableKey: "enableThing",
|
||||||
|
enableLabelKey: "k",
|
||||||
|
fields: [{ key: "port", type: "number", labelKey: "k" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const fields = declaredFields(withHost, "host");
|
||||||
|
expect(fields.map((f) => f.key)).toEqual(["enableThing", "port"]);
|
||||||
|
expect(fields[0]).toMatchObject({ type: "boolean", default: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds a field by key", () => {
|
||||||
|
expect(findField(ADMIN, "admin", "baseUrl")?.type).toBe("string");
|
||||||
|
expect(findField(ADMIN, "admin", "nope")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setSetting", () => {
|
||||||
|
it("writes a valid value and reads it back", async () => {
|
||||||
|
expect(
|
||||||
|
await setSetting(ADMIN, "admin", null, "baseUrl", "https://y"),
|
||||||
|
).toBeNull();
|
||||||
|
expect(await getSetting(ADMIN, "admin", null, "baseUrl")).toBe("https://y");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a key the manifest never declared", async () => {
|
||||||
|
const error = await setSetting(ADMIN, "admin", null, "wat", "x");
|
||||||
|
|
||||||
|
expect(error).toContain("not a settings field this plugin declares");
|
||||||
|
expect(rows).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a value that fails the declared constraint", async () => {
|
||||||
|
expect(await setSetting(ADMIN, "admin", null, "retries", 99)).toContain(
|
||||||
|
"at most 5",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await setSetting(ADMIN, "admin", null, "mode", "sideways"),
|
||||||
|
).toContain("must be one of");
|
||||||
|
expect(rows).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("coerces the strings a form actually sends", async () => {
|
||||||
|
expect(await setSetting(ADMIN, "admin", null, "retries", "3")).toBeNull();
|
||||||
|
expect(await getSetting(ADMIN, "admin", null, "retries")).toBe(3);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await setSetting(ADMIN, "admin", null, "enabled", "true"),
|
||||||
|
).toBeNull();
|
||||||
|
expect(await getSetting(ADMIN, "admin", null, "enabled")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps each scope separate", async () => {
|
||||||
|
const scoped = manifest({
|
||||||
|
admin: [{ key: "v", type: "string", labelKey: "k" }],
|
||||||
|
user: [{ key: "v", type: "string", labelKey: "k" }],
|
||||||
|
host: { fields: [{ key: "v", type: "string", labelKey: "k" }] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await setSetting(scoped, "admin", null, "v", "a");
|
||||||
|
await setSetting(scoped, "user", "user-1", "v", "u");
|
||||||
|
await setSetting(scoped, "host", 7, "v", "h");
|
||||||
|
|
||||||
|
expect(await getSetting(scoped, "admin", null, "v")).toBe("a");
|
||||||
|
expect(await getSetting(scoped, "user", "user-1", "v")).toBe("u");
|
||||||
|
expect(await getSetting(scoped, "host", 7, "v")).toBe("h");
|
||||||
|
// A different user sees nothing of the first user's value.
|
||||||
|
expect(await getSetting(scoped, "user", "user-2", "v")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores a host id as text, matching the polymorphic scope column", async () => {
|
||||||
|
const scoped = manifest({
|
||||||
|
host: { fields: [{ key: "v", type: "string", labelKey: "k" }] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await setSetting(scoped, "host", 12, "v", "x");
|
||||||
|
|
||||||
|
expect(rows[0].scopeId).toBe("12");
|
||||||
|
// Either spelling resolves to the same row.
|
||||||
|
expect(await getSetting(scoped, "host", "12", "v")).toBe("x");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("defaults", () => {
|
||||||
|
it("falls back to the declared default when nothing is stored", async () => {
|
||||||
|
expect(await getSetting(ADMIN, "admin", null, "baseUrl")).toBe("https://x");
|
||||||
|
expect(await getSetting(ADMIN, "admin", null, "enabled")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for a field with no default and no value", async () => {
|
||||||
|
expect(await getSetting(ADMIN, "admin", null, "retries")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for a key the manifest does not declare", async () => {
|
||||||
|
expect(await getSetting(ADMIN, "admin", null, "wat")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("secrets", () => {
|
||||||
|
it("encrypts on the way in and decrypts on the way out", async () => {
|
||||||
|
await setSetting(ADMIN, "admin", null, "apiKey", "tskey-secret");
|
||||||
|
|
||||||
|
const row = rows.find((entry) => entry.key === "apiKey")!;
|
||||||
|
expect(row.encrypted).toBe(true);
|
||||||
|
expect(row.value).not.toContain("tskey-secret");
|
||||||
|
expect(JSON.parse(row.value!)).toMatch(/^sysenc:v1:/);
|
||||||
|
|
||||||
|
expect(await getSetting(ADMIN, "admin", null, "apiKey")).toBe(
|
||||||
|
"tskey-secret",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts a secret rather than returning it", async () => {
|
||||||
|
await setSetting(ADMIN, "admin", null, "apiKey", "tskey-secret");
|
||||||
|
|
||||||
|
const values = await getAllSettings(ADMIN, "admin", null, {
|
||||||
|
redactSecrets: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(values.apiKey).toEqual({ set: true });
|
||||||
|
expect(JSON.stringify(values)).not.toContain("tskey-secret");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an unset secret as set:false", async () => {
|
||||||
|
const values = await getAllSettings(ADMIN, "admin", null, {
|
||||||
|
redactSecrets: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(values.apiKey).toEqual({ set: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a redacted echo as leave-it-alone", async () => {
|
||||||
|
await setSetting(ADMIN, "admin", null, "apiKey", "original");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await setSetting(ADMIN, "admin", null, "apiKey", { set: true }),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
expect(await getSetting(ADMIN, "admin", null, "apiKey")).toBe("original");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a secret be replaced and cleared", async () => {
|
||||||
|
await setSetting(ADMIN, "admin", null, "apiKey", "first");
|
||||||
|
await setSetting(ADMIN, "admin", null, "apiKey", "second");
|
||||||
|
expect(await getSetting(ADMIN, "admin", null, "apiKey")).toBe("second");
|
||||||
|
|
||||||
|
await setSetting(ADMIN, "admin", null, "apiKey", "");
|
||||||
|
const values = await getAllSettings(ADMIN, "admin", null, {
|
||||||
|
redactSecrets: true,
|
||||||
|
});
|
||||||
|
expect(values.apiKey).toEqual({ set: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands a plugin its own secret in the clear", async () => {
|
||||||
|
await setSetting(ADMIN, "admin", null, "apiKey", "tskey-secret");
|
||||||
|
|
||||||
|
const values = await getAllSettings(ADMIN, "admin", null);
|
||||||
|
|
||||||
|
expect(values.apiKey).toBe("tskey-secret");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAllSettings", () => {
|
||||||
|
it("is driven by the manifest, not by the stored rows", async () => {
|
||||||
|
await setSetting(ADMIN, "admin", null, "baseUrl", "https://y");
|
||||||
|
// A row for a field the plugin has since dropped.
|
||||||
|
rows.push({
|
||||||
|
pluginId: "sample",
|
||||||
|
scope: "admin",
|
||||||
|
scopeId: null,
|
||||||
|
key: "removedField",
|
||||||
|
value: JSON.stringify("stale"),
|
||||||
|
encrypted: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const values = await getAllSettings(ADMIN, "admin", null);
|
||||||
|
|
||||||
|
expect(values.baseUrl).toBe("https://y");
|
||||||
|
expect(values.enabled).toBe(false);
|
||||||
|
expect(values).not.toHaveProperty("removedField");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing for a scope the plugin declares no fields in", async () => {
|
||||||
|
expect(await getAllSettings(ADMIN, "user", "user-1")).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("onSettingsChange", () => {
|
||||||
|
it("fires on a successful write", async () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
onSettingsChange("sample", "baseUrl", listener);
|
||||||
|
|
||||||
|
await setSetting(ADMIN, "admin", null, "baseUrl", "https://z");
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledWith("https://z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fire for a rejected write", async () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
onSettingsChange("sample", "retries", listener);
|
||||||
|
|
||||||
|
await setSetting(ADMIN, "admin", null, "retries", 99);
|
||||||
|
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops firing once unsubscribed", async () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
const unsubscribe = onSettingsChange("sample", "baseUrl", listener);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
await setSetting(ADMIN, "admin", null, "baseUrl", "https://z");
|
||||||
|
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only notifies listeners for that key and plugin", async () => {
|
||||||
|
const other = vi.fn();
|
||||||
|
const another = vi.fn();
|
||||||
|
onSettingsChange("sample", "enabled", other);
|
||||||
|
onSettingsChange("different-plugin", "baseUrl", another);
|
||||||
|
|
||||||
|
await setSetting(ADMIN, "admin", null, "baseUrl", "https://z");
|
||||||
|
|
||||||
|
expect(other).not.toHaveBeenCalled();
|
||||||
|
expect(another).not.toHaveBeenCalled();
|
||||||
|
clearSettingsListeners("different-plugin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives a listener that throws", async () => {
|
||||||
|
const good = vi.fn();
|
||||||
|
onSettingsChange("sample", "baseUrl", () => {
|
||||||
|
throw new Error("listener blew up");
|
||||||
|
});
|
||||||
|
onSettingsChange("sample", "baseUrl", good);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
setSetting(ADMIN, "admin", null, "baseUrl", "https://z"),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
expect(good).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops every listener when the plugin deactivates", async () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
onSettingsChange("sample", "baseUrl", listener);
|
||||||
|
|
||||||
|
clearSettingsListeners("sample");
|
||||||
|
await setSetting(ADMIN, "admin", null, "baseUrl", "https://z");
|
||||||
|
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readCoreSetting", () => {
|
||||||
|
it("returns an allowlisted key", async () => {
|
||||||
|
coreSettings.set("app_name", "Termix");
|
||||||
|
|
||||||
|
expect(await readCoreSetting("app_name")).toBe("Termix");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a key that is not on the allowlist", async () => {
|
||||||
|
coreSettings.set("jwt_secret", "nope");
|
||||||
|
|
||||||
|
await expect(readCoreSetting("jwt_secret")).rejects.toThrow(
|
||||||
|
/not readable by plugins/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not expose anything sensitive through the allowlist", () => {
|
||||||
|
for (const key of CORE_SETTINGS_ALLOWLIST) {
|
||||||
|
expect(key).not.toMatch(/secret|password|token|key$/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
/**
|
||||||
|
* The Tailscale settings migration.
|
||||||
|
*
|
||||||
|
* An upgrade must be lossless: an install that had a working API key must keep
|
||||||
|
* working without anyone re-entering it. That is the whole point of the test,
|
||||||
|
* and running it twice proves a restart cannot corrupt what it moved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
pluginId: string;
|
||||||
|
scope: string;
|
||||||
|
scopeId: string | null;
|
||||||
|
key: string;
|
||||||
|
value: string | null;
|
||||||
|
encrypted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coreSettings = new Map<string, string>();
|
||||||
|
const pluginRows: Row[] = [];
|
||||||
|
let pluginInstalled = true;
|
||||||
|
|
||||||
|
const pluginSettingsRepository = {
|
||||||
|
get: async (
|
||||||
|
pluginId: string,
|
||||||
|
scope: string,
|
||||||
|
scopeId: string | null,
|
||||||
|
key: string,
|
||||||
|
) =>
|
||||||
|
pluginRows.find(
|
||||||
|
(row) =>
|
||||||
|
row.pluginId === pluginId &&
|
||||||
|
row.scope === scope &&
|
||||||
|
row.scopeId === scopeId &&
|
||||||
|
row.key === key,
|
||||||
|
) ?? null,
|
||||||
|
set: async (
|
||||||
|
pluginId: string,
|
||||||
|
scope: string,
|
||||||
|
scopeId: string | null,
|
||||||
|
key: string,
|
||||||
|
value: string | null,
|
||||||
|
encrypted = false,
|
||||||
|
) => {
|
||||||
|
const existing = pluginRows.find(
|
||||||
|
(row) =>
|
||||||
|
row.pluginId === pluginId &&
|
||||||
|
row.scope === scope &&
|
||||||
|
row.scopeId === scopeId &&
|
||||||
|
row.key === key,
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
existing.value = value;
|
||||||
|
existing.encrypted = encrypted;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pluginRows.push({ pluginId, scope, scopeId, key, value, encrypted });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentPluginRepository: () => ({
|
||||||
|
findById: async (id: string) =>
|
||||||
|
pluginInstalled && id === "tailscale" ? { id } : null,
|
||||||
|
}),
|
||||||
|
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
|
||||||
|
createCurrentSettingsRepository: () => ({
|
||||||
|
get: async (key: string) => coreSettings.get(key) ?? null,
|
||||||
|
delete: async (key: string) => coreSettings.delete(key),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/system-secret-crypto.js", () => ({
|
||||||
|
isSystemEncrypted: (value: string) => value.startsWith("sysenc:v1:"),
|
||||||
|
encryptSystemSecret: async (plaintext: string) =>
|
||||||
|
!plaintext || plaintext.startsWith("sysenc:v1:")
|
||||||
|
? plaintext
|
||||||
|
: `sysenc:v1:${Buffer.from(plaintext, "utf8").toString("base64")}`,
|
||||||
|
decryptSystemSecret: async (stored: string) =>
|
||||||
|
stored.startsWith("sysenc:v1:")
|
||||||
|
? Buffer.from(stored.slice("sysenc:v1:".length), "base64").toString(
|
||||||
|
"utf8",
|
||||||
|
)
|
||||||
|
: stored,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { runTailscaleSettingsMigration } =
|
||||||
|
await import("../../utils/crypto-migration/tailscale-settings-migration.js");
|
||||||
|
|
||||||
|
function storedValue(key: string): unknown {
|
||||||
|
const row = pluginRows.find((entry) => entry.key === key);
|
||||||
|
return row?.value === null || row?.value === undefined
|
||||||
|
? undefined
|
||||||
|
: JSON.parse(row.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
coreSettings.clear();
|
||||||
|
pluginRows.length = 0;
|
||||||
|
pluginInstalled = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runTailscaleSettingsMigration", () => {
|
||||||
|
it("moves an existing key and base URL into plugin settings", async () => {
|
||||||
|
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
|
||||||
|
coreSettings.set("tailscale_api_base_url", "https://headscale.example");
|
||||||
|
|
||||||
|
const result = await runTailscaleSettingsMigration();
|
||||||
|
|
||||||
|
expect(result.moved).toEqual([
|
||||||
|
"tailscale_api_key",
|
||||||
|
"tailscale_api_base_url",
|
||||||
|
]);
|
||||||
|
expect(storedValue("apiKey")).toMatch(/^sysenc:v1:/);
|
||||||
|
expect(storedValue("apiBaseUrl")).toBe("https://headscale.example");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encrypts the key, which was stored in plaintext before", async () => {
|
||||||
|
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
|
||||||
|
|
||||||
|
await runTailscaleSettingsMigration();
|
||||||
|
|
||||||
|
const row = pluginRows.find((entry) => entry.key === "apiKey")!;
|
||||||
|
expect(row.encrypted).toBe(true);
|
||||||
|
expect(row.value).not.toContain("tskey-api-legacy");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the base URL readable, since it is not a secret", async () => {
|
||||||
|
coreSettings.set("tailscale_api_base_url", "https://headscale.example");
|
||||||
|
|
||||||
|
await runTailscaleSettingsMigration();
|
||||||
|
|
||||||
|
const row = pluginRows.find((entry) => entry.key === "apiBaseUrl")!;
|
||||||
|
expect(row.encrypted).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the legacy rows so no plaintext key is left behind", async () => {
|
||||||
|
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
|
||||||
|
coreSettings.set("tailscale_api_base_url", "https://headscale.example");
|
||||||
|
|
||||||
|
await runTailscaleSettingsMigration();
|
||||||
|
|
||||||
|
expect(coreSettings.has("tailscale_api_key")).toBe(false);
|
||||||
|
expect(coreSettings.has("tailscale_api_base_url")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent: a second run changes nothing", async () => {
|
||||||
|
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
|
||||||
|
coreSettings.set("tailscale_api_base_url", "https://headscale.example");
|
||||||
|
|
||||||
|
await runTailscaleSettingsMigration();
|
||||||
|
const afterFirst = JSON.parse(JSON.stringify(pluginRows));
|
||||||
|
|
||||||
|
const second = await runTailscaleSettingsMigration();
|
||||||
|
|
||||||
|
expect(second.moved).toEqual([]);
|
||||||
|
expect(pluginRows).toEqual(afterFirst);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot double-encrypt the key across runs", async () => {
|
||||||
|
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
|
||||||
|
|
||||||
|
await runTailscaleSettingsMigration();
|
||||||
|
const first = storedValue("apiKey");
|
||||||
|
|
||||||
|
await runTailscaleSettingsMigration();
|
||||||
|
|
||||||
|
expect(storedValue("apiKey")).toBe(first);
|
||||||
|
expect(String(storedValue("apiKey")).match(/sysenc:v1:/g)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a stale legacy row when the value was already moved", async () => {
|
||||||
|
// An interrupted run, or a downgrade that rewrote the old key.
|
||||||
|
await pluginSettingsRepository.set(
|
||||||
|
"tailscale",
|
||||||
|
"admin",
|
||||||
|
null,
|
||||||
|
"apiKey",
|
||||||
|
JSON.stringify("sysenc:v1:already"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
coreSettings.set("tailscale_api_key", "tskey-plaintext-leftover");
|
||||||
|
|
||||||
|
const result = await runTailscaleSettingsMigration();
|
||||||
|
|
||||||
|
expect(result.skipped).toContain("tailscale_api_key");
|
||||||
|
expect(storedValue("apiKey")).toBe("sysenc:v1:already");
|
||||||
|
expect(coreSettings.has("tailscale_api_key")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing on a fresh install with no legacy rows", async () => {
|
||||||
|
const result = await runTailscaleSettingsMigration();
|
||||||
|
|
||||||
|
expect(result.moved).toEqual([]);
|
||||||
|
expect(pluginRows).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips an empty legacy value rather than storing one", async () => {
|
||||||
|
coreSettings.set("tailscale_api_key", "");
|
||||||
|
|
||||||
|
const result = await runTailscaleSettingsMigration();
|
||||||
|
|
||||||
|
expect(result.moved).toEqual([]);
|
||||||
|
expect(pluginRows).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for the plugin row, because of the foreign key", async () => {
|
||||||
|
pluginInstalled = false;
|
||||||
|
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
|
||||||
|
|
||||||
|
const result = await runTailscaleSettingsMigration();
|
||||||
|
|
||||||
|
expect(result.moved).toEqual([]);
|
||||||
|
expect(pluginRows).toEqual([]);
|
||||||
|
// Still there for the next boot, once the plugin is seeded.
|
||||||
|
expect(coreSettings.get("tailscale_api_key")).toBe("tskey-api-legacy");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves each key independently", async () => {
|
||||||
|
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
|
||||||
|
|
||||||
|
const result = await runTailscaleSettingsMigration();
|
||||||
|
|
||||||
|
expect(result.moved).toEqual(["tailscale_api_key"]);
|
||||||
|
expect(storedValue("apiBaseUrl")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Moves the Tailscale API key and base URL out of core settings.
|
||||||
|
*
|
||||||
|
* Both lived in the core `settings` table, written by a core route that existed
|
||||||
|
* only to serve one plugin. A6 gives plugins their own settings, so they belong
|
||||||
|
* in plugin_settings under the tailscale plugin.
|
||||||
|
*
|
||||||
|
* The key was stored in plaintext, and the route that read it back masked only
|
||||||
|
* its tail while being open to any authenticated user. Landing it as a secret
|
||||||
|
* field encrypts it at rest, so this migration is a fix as much as a move.
|
||||||
|
*
|
||||||
|
* Idempotent in two ways: it stops when a value is already present in the new
|
||||||
|
* home, and encryptSystemSecret returns an already-encrypted value unchanged,
|
||||||
|
* so a second pass can neither duplicate nor double-encrypt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { databaseLogger } from "../logger.js";
|
||||||
|
import {
|
||||||
|
createCurrentPluginRepository,
|
||||||
|
createCurrentPluginSettingsRepository,
|
||||||
|
createCurrentSettingsRepository,
|
||||||
|
} from "../../database/repositories/factory.js";
|
||||||
|
import { encryptSystemSecret } from "../system-secret-crypto.js";
|
||||||
|
|
||||||
|
const PLUGIN_ID = "tailscale";
|
||||||
|
|
||||||
|
/** Legacy core key -> the field the plugin now declares. */
|
||||||
|
const MOVES: { legacyKey: string; field: string; secret: boolean }[] = [
|
||||||
|
{ legacyKey: "tailscale_api_key", field: "apiKey", secret: true },
|
||||||
|
{ legacyKey: "tailscale_api_base_url", field: "apiBaseUrl", secret: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface TailscaleSettingsMigrationResult {
|
||||||
|
moved: string[];
|
||||||
|
skipped: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runTailscaleSettingsMigration(): Promise<TailscaleSettingsMigrationResult> {
|
||||||
|
const moved: string[] = [];
|
||||||
|
const skipped: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// The FK to plugins means there is nothing to migrate into until the
|
||||||
|
// plugin has been seeded. Not an error: a build without it just has no
|
||||||
|
// tailscale rows.
|
||||||
|
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
|
||||||
|
if (!plugin) return { moved, skipped };
|
||||||
|
|
||||||
|
const settingsRepository = createCurrentSettingsRepository();
|
||||||
|
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
|
||||||
|
|
||||||
|
for (const move of MOVES) {
|
||||||
|
const legacyValue = await settingsRepository.get(move.legacyKey);
|
||||||
|
if (legacyValue === null || legacyValue === "") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await pluginSettingsRepository.get(
|
||||||
|
PLUGIN_ID,
|
||||||
|
"admin",
|
||||||
|
null,
|
||||||
|
move.field,
|
||||||
|
);
|
||||||
|
if (existing && existing.value !== null) {
|
||||||
|
// Already moved. Drop the stale copy rather than leaving a plaintext
|
||||||
|
// key behind for a reader that predates this change.
|
||||||
|
await settingsRepository.delete(move.legacyKey);
|
||||||
|
skipped.push(move.legacyKey);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = move.secret
|
||||||
|
? await encryptSystemSecret(legacyValue)
|
||||||
|
: legacyValue;
|
||||||
|
|
||||||
|
await pluginSettingsRepository.set(
|
||||||
|
PLUGIN_ID,
|
||||||
|
"admin",
|
||||||
|
null,
|
||||||
|
move.field,
|
||||||
|
JSON.stringify(stored),
|
||||||
|
move.secret,
|
||||||
|
);
|
||||||
|
|
||||||
|
await settingsRepository.delete(move.legacyKey);
|
||||||
|
moved.push(move.legacyKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moved.length > 0) {
|
||||||
|
databaseLogger.info(
|
||||||
|
`Moved ${moved.length} Tailscale setting(s) into plugin settings`,
|
||||||
|
{ operation: "tailscale_settings_migration", moved: moved.join(", ") },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// A failed migration must not stop the backend. The plugin falls back to
|
||||||
|
// "no key configured", which is a visible, recoverable state.
|
||||||
|
databaseLogger.warn("Tailscale settings migration failed", {
|
||||||
|
operation: "tailscale_settings_migration",
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { moved, skipped };
|
||||||
|
}
|
||||||
@@ -185,6 +185,8 @@ export type Host = {
|
|||||||
hasTelnetPassword?: boolean;
|
hasTelnetPassword?: boolean;
|
||||||
|
|
||||||
guacamoleConfig?: GuacamoleConfig;
|
guacamoleConfig?: GuacamoleConfig;
|
||||||
|
/** Host-scope plugin settings, keyed by plugin id. Secrets are redacted. */
|
||||||
|
pluginSettings?: Record<string, Record<string, unknown>>;
|
||||||
forceKeyboardInteractive?: boolean;
|
forceKeyboardInteractive?: boolean;
|
||||||
|
|
||||||
isShared?: boolean;
|
isShared?: boolean;
|
||||||
@@ -372,8 +374,7 @@ export type AdminSection =
|
|||||||
| "audit-log"
|
| "audit-log"
|
||||||
| "ssl"
|
| "ssl"
|
||||||
| "plugins"
|
| "plugins"
|
||||||
| "touch-input"
|
| "touch-input";
|
||||||
| "tailscale";
|
|
||||||
export type ThemeId =
|
export type ThemeId =
|
||||||
| "dark"
|
| "dark"
|
||||||
| "light"
|
| "light"
|
||||||
|
|||||||
+13
-18
@@ -126,14 +126,11 @@ const SessionLogsPanel = lazy(() =>
|
|||||||
default: m.SessionLogsPanel,
|
default: m.SessionLogsPanel,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
const UserProfilePanel = lazy(() =>
|
// One screen behind both rail entries. It lazy-loads the profile and admin
|
||||||
import("@/sidebar/UserProfilePanel").then((m) => ({
|
// panels itself, so the shell no longer imports either directly.
|
||||||
default: m.UserProfilePanel,
|
const SettingsScreen = lazy(() =>
|
||||||
})),
|
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(() =>
|
||||||
@@ -2882,9 +2879,14 @@ export function AppShell({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{railView === "user-profile" && (
|
{(railView === "user-profile" ||
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
(railView === "admin-settings" && showAdminUI)) && (
|
||||||
<UserProfilePanel
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
|
<SettingsScreen
|
||||||
|
initialSection={
|
||||||
|
railView === "admin-settings" ? "admin" : "profile"
|
||||||
|
}
|
||||||
|
isAdmin={showAdminUI}
|
||||||
username={username}
|
username={username}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
userPrefs={userPrefs}
|
userPrefs={userPrefs}
|
||||||
@@ -2896,13 +2898,6 @@ 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);
|
||||||
|
|||||||
@@ -7,8 +7,153 @@ export interface PluginTabContribution {
|
|||||||
openFrom: string[];
|
openFrom: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PluginSettingsFieldType =
|
||||||
|
| "boolean"
|
||||||
|
| "string"
|
||||||
|
| "number"
|
||||||
|
| "select"
|
||||||
|
| "multiselect"
|
||||||
|
| "secret"
|
||||||
|
| "textarea"
|
||||||
|
| "json"
|
||||||
|
| "custom";
|
||||||
|
|
||||||
|
export interface PluginSettingsField {
|
||||||
|
key: string;
|
||||||
|
type: PluginSettingsFieldType;
|
||||||
|
labelKey?: string;
|
||||||
|
descriptionKey?: string;
|
||||||
|
placeholderKey?: string;
|
||||||
|
default?: unknown;
|
||||||
|
options?: { value: string; labelKey: string }[];
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
requires?: string;
|
||||||
|
permission?: string;
|
||||||
|
group?: string;
|
||||||
|
component?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginHostSettingsContribution {
|
||||||
|
enableKey?: string;
|
||||||
|
enableLabelKey?: string;
|
||||||
|
fields: PluginSettingsField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginSettingsContribution {
|
||||||
|
admin?: PluginSettingsField[];
|
||||||
|
user?: PluginSettingsField[];
|
||||||
|
host?: PluginHostSettingsContribution;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PluginContributions {
|
export interface PluginContributions {
|
||||||
tabs?: PluginTabContribution[];
|
tabs?: PluginTabContribution[];
|
||||||
|
settings?: PluginSettingsContribution;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A secret as it arrives from the server. The value never leaves the server. */
|
||||||
|
export interface RedactedSecret {
|
||||||
|
set: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRedactedSecret(value: unknown): value is RedactedSecret {
|
||||||
|
return (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
!Array.isArray(value) &&
|
||||||
|
typeof (value as RedactedSecret).set === "boolean"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginSettingsValues = Record<string, unknown>;
|
||||||
|
|
||||||
|
/** Per-field messages from a rejected PUT, keyed by field key. */
|
||||||
|
export interface PluginSettingsErrors {
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PluginSettingsValidationError extends Error {
|
||||||
|
readonly errors: PluginSettingsErrors;
|
||||||
|
|
||||||
|
constructor(errors: PluginSettingsErrors) {
|
||||||
|
super("Some settings were rejected");
|
||||||
|
this.name = "PluginSettingsValidationError";
|
||||||
|
this.errors = errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function settingsPath(pluginId: string, suffix: string): string {
|
||||||
|
return `/plugins/${encodeURIComponent(pluginId)}/settings/${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turns a 400 carrying per-field errors into something a form can render. */
|
||||||
|
async function putSettings(
|
||||||
|
path: string,
|
||||||
|
values: PluginSettingsValues,
|
||||||
|
): Promise<PluginSettingsValues> {
|
||||||
|
try {
|
||||||
|
const response = await rbacApi.put(path, values);
|
||||||
|
return response.data?.values ?? {};
|
||||||
|
} catch (error) {
|
||||||
|
const data = (
|
||||||
|
error as { response?: { status?: number; data?: { errors?: unknown } } }
|
||||||
|
).response;
|
||||||
|
if (data?.status === 400 && data.data?.errors) {
|
||||||
|
throw new PluginSettingsValidationError(
|
||||||
|
data.data.errors as PluginSettingsErrors,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPluginAdminSettings(
|
||||||
|
pluginId: string,
|
||||||
|
): Promise<PluginSettingsValues> {
|
||||||
|
const response = await rbacApi.get(settingsPath(pluginId, "admin"));
|
||||||
|
return response.data?.values ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePluginAdminSettings(
|
||||||
|
pluginId: string,
|
||||||
|
values: PluginSettingsValues,
|
||||||
|
): Promise<PluginSettingsValues> {
|
||||||
|
return putSettings(settingsPath(pluginId, "admin"), values);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPluginUserSettings(
|
||||||
|
pluginId: string,
|
||||||
|
): Promise<PluginSettingsValues> {
|
||||||
|
const response = await rbacApi.get(settingsPath(pluginId, "user"));
|
||||||
|
return response.data?.values ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePluginUserSettings(
|
||||||
|
pluginId: string,
|
||||||
|
values: PluginSettingsValues,
|
||||||
|
): Promise<PluginSettingsValues> {
|
||||||
|
return putSettings(settingsPath(pluginId, "user"), values);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPluginHostSettings(
|
||||||
|
pluginId: string,
|
||||||
|
hostId: number,
|
||||||
|
): Promise<PluginSettingsValues> {
|
||||||
|
const response = await rbacApi.get(
|
||||||
|
settingsPath(pluginId, `host/${encodeURIComponent(String(hostId))}`),
|
||||||
|
);
|
||||||
|
return response.data?.values ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePluginHostSettings(
|
||||||
|
pluginId: string,
|
||||||
|
hostId: number,
|
||||||
|
values: PluginSettingsValues,
|
||||||
|
): Promise<PluginSettingsValues> {
|
||||||
|
return putSettings(
|
||||||
|
settingsPath(pluginId, `host/${encodeURIComponent(String(hostId))}`),
|
||||||
|
values,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,6 +171,8 @@ export interface PluginSummary {
|
|||||||
/** enabled | disabled | blocked | failed, or the loader's live state. */
|
/** enabled | disabled | blocked | failed, or the loader's live state. */
|
||||||
state: string;
|
state: string;
|
||||||
contributes: PluginContributions | null;
|
contributes: PluginContributions | null;
|
||||||
|
/** Lucide icon name from the manifest. */
|
||||||
|
icon?: string;
|
||||||
|
|
||||||
tier?: string;
|
tier?: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
|
|||||||
+45
-8
@@ -3508,7 +3508,6 @@
|
|||||||
"sectionApiKeys": "API Keys",
|
"sectionApiKeys": "API Keys",
|
||||||
"sectionAuditLog": "Audit Log",
|
"sectionAuditLog": "Audit Log",
|
||||||
"sectionSsl": "SSL / Let's Encrypt",
|
"sectionSsl": "SSL / Let's Encrypt",
|
||||||
"sectionTailscale": "Tailscale",
|
|
||||||
"sectionTouchInput": "Touch Input",
|
"sectionTouchInput": "Touch Input",
|
||||||
"touchEnabled": "Enable Touch Input",
|
"touchEnabled": "Enable Touch Input",
|
||||||
"touchEnabledDesc": "Allow one-finger touch scrolling in terminals",
|
"touchEnabledDesc": "Allow one-finger touch scrolling in terminals",
|
||||||
@@ -3667,11 +3666,6 @@
|
|||||||
"enableRemoteDesktopDesc": "RDP/VNC remote desktop",
|
"enableRemoteDesktopDesc": "RDP/VNC remote desktop",
|
||||||
"enableRemoteDesktopDocsLink": "View docs",
|
"enableRemoteDesktopDocsLink": "View docs",
|
||||||
"guacdUrl": "guacd URL",
|
"guacdUrl": "guacd URL",
|
||||||
"tailscaleApiKey": "Tailscale API Key",
|
|
||||||
"tailscaleApiKeyDescription": "Used for device discovery in the host editor. Supports Tailscale (tskey-) and Headscale (hskey-) keys. Generate a key at tailscale.com/admin/settings/keys or from your Headscale instance.",
|
|
||||||
"tailscaleApiKeyDocsLink": "View docs",
|
|
||||||
"tailscaleApiBaseUrl": "API Base URL",
|
|
||||||
"tailscaleApiBaseUrlDescription": "Optional. Point to a self-hosted Headscale instance instead of api.tailscale.com. Leave empty to use the Tailscale default.",
|
|
||||||
"oidcDescription": "Configure OpenID Connect for SSO. Fields marked * are required.",
|
"oidcDescription": "Configure OpenID Connect for SSO. Fields marked * are required.",
|
||||||
"oidcDocsLink": "View docs",
|
"oidcDocsLink": "View docs",
|
||||||
"oidcClientId": "Client ID",
|
"oidcClientId": "Client ID",
|
||||||
@@ -3822,8 +3816,6 @@
|
|||||||
"remoteDesktopSaved": "Remote Desktop settings saved",
|
"remoteDesktopSaved": "Remote Desktop settings saved",
|
||||||
"remoteDesktopSaveFailed": "Failed to save Remote Desktop settings",
|
"remoteDesktopSaveFailed": "Failed to save Remote Desktop settings",
|
||||||
"remoteDesktopUpdateFailed": "Failed to update Remote Desktop setting",
|
"remoteDesktopUpdateFailed": "Failed to update Remote Desktop setting",
|
||||||
"tailscaleSettingsSaved": "Tailscale settings saved",
|
|
||||||
"tailscaleSettingsSaveFailed": "Failed to save Tailscale settings",
|
|
||||||
"logLevelUpdateFailed": "Failed to update log level",
|
"logLevelUpdateFailed": "Failed to update log level",
|
||||||
"oidcSaved": "OIDC configuration saved",
|
"oidcSaved": "OIDC configuration saved",
|
||||||
"oidcSaveFailed": "Failed to save OIDC config",
|
"oidcSaveFailed": "Failed to save OIDC config",
|
||||||
@@ -5261,7 +5253,52 @@
|
|||||||
"title": "View tailnet devices",
|
"title": "View tailnet devices",
|
||||||
"description": "See the devices on the connected tailnet."
|
"description": "See the devices on the connected tailnet."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"group": {
|
||||||
|
"api": "Tailscale API",
|
||||||
|
"devices": "Tailnet"
|
||||||
|
},
|
||||||
|
"apiKey": {
|
||||||
|
"label": "API key",
|
||||||
|
"description": "Used to list the devices on your tailnet. Works with a Tailscale or Headscale key.",
|
||||||
|
"placeholder": "tskey-api-... / hskey-api-..."
|
||||||
|
},
|
||||||
|
"apiBaseUrl": {
|
||||||
|
"label": "API base URL",
|
||||||
|
"description": "Leave empty for Tailscale. Set this to point at a Headscale instance.",
|
||||||
|
"placeholder": "https://api.tailscale.com/api/v2"
|
||||||
|
},
|
||||||
|
"devices": {
|
||||||
|
"label": "Device list",
|
||||||
|
"checking": "Checking the tailnet...",
|
||||||
|
"noKey": "No API key set yet.",
|
||||||
|
"error": "Could not reach the tailnet. Check the API key.",
|
||||||
|
"count": "{{count}} device reachable",
|
||||||
|
"count_other": "{{count}} devices reachable",
|
||||||
|
"refresh": "Check"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Settings",
|
||||||
|
"bandYou": "You",
|
||||||
|
"bandPlugins": "Plugins",
|
||||||
|
"bandInstance": "Instance",
|
||||||
|
"account": "Account",
|
||||||
|
"secretSet": "Set",
|
||||||
|
"secretNotSet": "Not set",
|
||||||
|
"secretReplace": "Replace",
|
||||||
|
"pluginGroupAdmin": "Server settings",
|
||||||
|
"pluginGroupUser": "Your settings",
|
||||||
|
"pluginNoSettings": "{{name}} has no settings you can change.",
|
||||||
|
"pluginStoppedNotice": "{{name}} is installed but not running. These settings are saved but will not take effect.",
|
||||||
|
"pluginSettingsSaved": "Settings saved",
|
||||||
|
"pluginSettingsInvalid": "Some settings could not be saved. Check the highlighted fields.",
|
||||||
|
"pluginSettingsSaveFailed": "Failed to save settings",
|
||||||
|
"pluginSettingsLoadFailed": "Failed to load settings",
|
||||||
|
"pluginsGroupLabel": "Plugins",
|
||||||
|
"hostPluginNotRunning": "{{name}} is installed but not running. These settings are saved but will not take effect."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Host editor sections contributed by plugins.
|
||||||
|
*
|
||||||
|
* A plugin declares host-scope fields in its manifest and core draws them, so
|
||||||
|
* a plugin cannot ship its own form styling and a section disappears when its
|
||||||
|
* plugin does. The enable switch comes first and gates the rest, which is the
|
||||||
|
* shape the hardcoded feature tabs already have.
|
||||||
|
*
|
||||||
|
* Nothing is rendered when no enabled plugin declares host settings, so the
|
||||||
|
* group does not appear as an empty tab. Phase B fills it as each feature
|
||||||
|
* moves its columns out of ssh_data.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { SectionCard } from "@/components/section-card";
|
||||||
|
import { PluginIcon } from "@/lib/plugin-icon";
|
||||||
|
import { getPlugins, type PluginSummary } from "@/api/plugins-api";
|
||||||
|
import { SettingsFieldRow } from "./SettingsFields";
|
||||||
|
import { isFieldActive } from "./settings-fields-util";
|
||||||
|
|
||||||
|
/** Values for every plugin on one host: { [pluginId]: { [key]: value } }. */
|
||||||
|
export type HostPluginSettings = Record<string, Record<string, unknown>>;
|
||||||
|
|
||||||
|
/** Enabled plugins declaring host-scope settings. */
|
||||||
|
export function usePluginHostSections(): PluginSummary[] {
|
||||||
|
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void getPlugins()
|
||||||
|
.then((loaded) => {
|
||||||
|
if (!cancelled) setPlugins(loaded);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Additive: the built-in host tabs do not depend on this.
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() =>
|
||||||
|
plugins.filter((plugin) => {
|
||||||
|
if (!plugin.enabled) return false;
|
||||||
|
const host = plugin.contributes?.settings?.host;
|
||||||
|
return !!host && (host.fields.length > 0 || !!host.enableKey);
|
||||||
|
}),
|
||||||
|
[plugins],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostPluginSectionsProps {
|
||||||
|
plugins: PluginSummary[];
|
||||||
|
values: HostPluginSettings;
|
||||||
|
setValue: (pluginId: string, key: string, value: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HostPluginSections({
|
||||||
|
plugins,
|
||||||
|
values,
|
||||||
|
setValue,
|
||||||
|
}: HostPluginSectionsProps) {
|
||||||
|
if (plugins.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{plugins.map((plugin) => (
|
||||||
|
<HostPluginSection
|
||||||
|
key={plugin.id}
|
||||||
|
plugin={plugin}
|
||||||
|
values={values[plugin.id] ?? {}}
|
||||||
|
setValue={(key, value) => setValue(plugin.id, key, value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HostPluginSection({
|
||||||
|
plugin,
|
||||||
|
values,
|
||||||
|
setValue,
|
||||||
|
}: {
|
||||||
|
plugin: PluginSummary;
|
||||||
|
values: Record<string, unknown>;
|
||||||
|
setValue: (key: string, value: unknown) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const host = plugin.contributes?.settings?.host;
|
||||||
|
if (!host) return null;
|
||||||
|
|
||||||
|
const running = plugin.enabled && plugin.state !== "failed";
|
||||||
|
const enableKey = host.enableKey;
|
||||||
|
// With no enable switch the section is always on, which is what a plugin
|
||||||
|
// declaring only plain fields means.
|
||||||
|
const enabled = enableKey ? values[enableKey] === true : true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard
|
||||||
|
title={plugin.name}
|
||||||
|
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
|
||||||
|
>
|
||||||
|
{!running && (
|
||||||
|
<div className="my-3 border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-500">
|
||||||
|
{t("settings.hostPluginNotRunning", { name: plugin.name })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{enableKey && (
|
||||||
|
<SettingsFieldRow
|
||||||
|
pluginId={plugin.id}
|
||||||
|
field={{
|
||||||
|
key: enableKey,
|
||||||
|
type: "boolean",
|
||||||
|
labelKey: host.enableLabelKey,
|
||||||
|
}}
|
||||||
|
values={values}
|
||||||
|
setValue={setValue}
|
||||||
|
running={running}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{enabled &&
|
||||||
|
host.fields
|
||||||
|
.filter((field) => isFieldActive(field, values))
|
||||||
|
.map((field) => (
|
||||||
|
<SettingsFieldRow
|
||||||
|
key={field.key}
|
||||||
|
pluginId={plugin.id}
|
||||||
|
field={field}
|
||||||
|
values={values}
|
||||||
|
setValue={setValue}
|
||||||
|
running={running}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
/**
|
||||||
|
* One plugin's settings page, drawn from its manifest.
|
||||||
|
*
|
||||||
|
* The plugin declares groups and fields; Termix renders them with the same
|
||||||
|
* SectionCard and SettingRow everything else uses. A plugin cannot ship its
|
||||||
|
* own form styling and drift from the rest of the app, and a page disappears
|
||||||
|
* cleanly when the plugin is uninstalled.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/button";
|
||||||
|
import { SectionCard } from "@/components/section-card";
|
||||||
|
import { pluginKey } from "@/lib/plugin-i18n";
|
||||||
|
import { PluginIcon } from "@/lib/plugin-icon";
|
||||||
|
import {
|
||||||
|
getPluginAdminSettings,
|
||||||
|
getPluginUserSettings,
|
||||||
|
updatePluginAdminSettings,
|
||||||
|
updatePluginUserSettings,
|
||||||
|
PluginSettingsValidationError,
|
||||||
|
type PluginSettingsErrors,
|
||||||
|
type PluginSettingsField,
|
||||||
|
type PluginSummary,
|
||||||
|
} from "@/api/plugins-api";
|
||||||
|
import { SettingsFieldRow } from "./SettingsFields";
|
||||||
|
import { isFieldActive } from "./settings-fields-util";
|
||||||
|
|
||||||
|
interface PluginSettingsPageProps {
|
||||||
|
plugin: PluginSummary;
|
||||||
|
isAdmin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Scope = "admin" | "user";
|
||||||
|
|
||||||
|
export function PluginSettingsPage({
|
||||||
|
plugin,
|
||||||
|
isAdmin,
|
||||||
|
}: PluginSettingsPageProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const settings = plugin.contributes?.settings;
|
||||||
|
const running = plugin.enabled && plugin.state !== "failed";
|
||||||
|
|
||||||
|
// Admin fields are not merely disabled for a non-admin: the values behind
|
||||||
|
// them are install-wide configuration they have no business reading.
|
||||||
|
const scopes = useMemo(() => {
|
||||||
|
const entries: { scope: Scope; fields: PluginSettingsField[] }[] = [];
|
||||||
|
if (isAdmin && (settings?.admin?.length ?? 0) > 0) {
|
||||||
|
entries.push({ scope: "admin", fields: settings!.admin! });
|
||||||
|
}
|
||||||
|
if ((settings?.user?.length ?? 0) > 0) {
|
||||||
|
entries.push({ scope: "user", fields: settings!.user! });
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}, [settings, isAdmin]);
|
||||||
|
|
||||||
|
if (scopes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 p-2.5">
|
||||||
|
<p className="text-xs text-muted-foreground px-1">
|
||||||
|
{t("settings.pluginNoSettings", { name: plugin.name })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 p-2.5">
|
||||||
|
{!running && (
|
||||||
|
<div className="border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-500">
|
||||||
|
{t("settings.pluginStoppedNotice", { name: plugin.name })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scopes.map(({ scope, fields }) => (
|
||||||
|
<PluginScopeForm
|
||||||
|
key={scope}
|
||||||
|
plugin={plugin}
|
||||||
|
scope={scope}
|
||||||
|
fields={fields}
|
||||||
|
running={running}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One scope's form.
|
||||||
|
*
|
||||||
|
* Admin and user settings are separate requests with separate permissions, so
|
||||||
|
* they save separately rather than sharing one dirty state.
|
||||||
|
*/
|
||||||
|
function PluginScopeForm({
|
||||||
|
plugin,
|
||||||
|
scope,
|
||||||
|
fields,
|
||||||
|
running,
|
||||||
|
}: {
|
||||||
|
plugin: PluginSummary;
|
||||||
|
scope: Scope;
|
||||||
|
fields: PluginSettingsField[];
|
||||||
|
running: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||||
|
const [errors, setErrors] = useState<PluginSettingsErrors>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const loaded =
|
||||||
|
scope === "admin"
|
||||||
|
? await getPluginAdminSettings(plugin.id)
|
||||||
|
: await getPluginUserSettings(plugin.id);
|
||||||
|
setValues(loaded);
|
||||||
|
setErrors({});
|
||||||
|
setDirty(false);
|
||||||
|
} catch {
|
||||||
|
toast.error(t("settings.pluginSettingsLoadFailed"));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [plugin.id, scope, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const setValue = (key: string, value: unknown) => {
|
||||||
|
setValues((current) => ({ ...current, [key]: value }));
|
||||||
|
setErrors((current) => {
|
||||||
|
if (!current[key]) return current;
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[key];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setDirty(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const saved =
|
||||||
|
scope === "admin"
|
||||||
|
? await updatePluginAdminSettings(plugin.id, values)
|
||||||
|
: await updatePluginUserSettings(plugin.id, values);
|
||||||
|
setValues(saved);
|
||||||
|
setErrors({});
|
||||||
|
setDirty(false);
|
||||||
|
toast.success(t("settings.pluginSettingsSaved"));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PluginSettingsValidationError) {
|
||||||
|
setErrors(error.errors);
|
||||||
|
toast.error(t("settings.pluginSettingsInvalid"));
|
||||||
|
} else {
|
||||||
|
toast.error(t("settings.pluginSettingsSaveFailed"));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fields keep their declared order within a group, and groups appear in the
|
||||||
|
// order their first field does, so a manifest controls the whole layout.
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const ordered: {
|
||||||
|
key: string | undefined;
|
||||||
|
fields: PluginSettingsField[];
|
||||||
|
}[] = [];
|
||||||
|
for (const field of fields) {
|
||||||
|
const existing = ordered.find((group) => group.key === field.group);
|
||||||
|
if (existing) existing.fields.push(field);
|
||||||
|
else ordered.push({ key: field.group, fields: [field] });
|
||||||
|
}
|
||||||
|
return ordered;
|
||||||
|
}, [fields]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-1 py-4 text-xs text-muted-foreground">
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
{t("common.loading")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const icon = <PluginIcon name={plugin.icon} className="size-3.5" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{groups.map((group, index) => (
|
||||||
|
<SectionCard
|
||||||
|
key={group.key ?? `group-${index}`}
|
||||||
|
title={
|
||||||
|
group.key
|
||||||
|
? t(pluginKey(plugin.id, group.key))
|
||||||
|
: scope === "admin"
|
||||||
|
? t("settings.pluginGroupAdmin")
|
||||||
|
: t("settings.pluginGroupUser")
|
||||||
|
}
|
||||||
|
icon={icon}
|
||||||
|
>
|
||||||
|
{group.fields
|
||||||
|
.filter((field) => isFieldActive(field, values))
|
||||||
|
.map((field) => (
|
||||||
|
<SettingsFieldRow
|
||||||
|
key={field.key}
|
||||||
|
pluginId={plugin.id}
|
||||||
|
field={field}
|
||||||
|
values={values}
|
||||||
|
setValue={setValue}
|
||||||
|
running={running}
|
||||||
|
error={errors[field.key]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SectionCard>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="flex justify-end px-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
disabled={!dirty || saving}
|
||||||
|
onClick={() => void save()}
|
||||||
|
>
|
||||||
|
{saving && <Loader2 className="size-3 animate-spin mr-1.5" />}
|
||||||
|
{t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
/**
|
||||||
|
* One control per declared settings field type.
|
||||||
|
*
|
||||||
|
* A plugin supplies data; core draws the form. That is what keeps every
|
||||||
|
* plugin's settings page looking like the rest of the app, and what makes a
|
||||||
|
* page disappear cleanly when its plugin does.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Input } from "@/components/input";
|
||||||
|
import { Textarea } from "@/components/textarea";
|
||||||
|
import { Checkbox } from "@/components/checkbox";
|
||||||
|
import { PasswordInput } from "@/components/password-input";
|
||||||
|
import { Button } from "@/components/button";
|
||||||
|
import { FakeSwitch, SettingRow } from "@/components/section-card";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/select";
|
||||||
|
import { pluginKey } from "@/lib/plugin-i18n";
|
||||||
|
import { isRedactedSecret, type PluginSettingsField } from "@/api/plugins-api";
|
||||||
|
import { getSettingsComponent } from "./settings-components";
|
||||||
|
|
||||||
|
export interface SettingsFieldProps {
|
||||||
|
pluginId: string;
|
||||||
|
field: PluginSettingsField;
|
||||||
|
values: Record<string, unknown>;
|
||||||
|
setValue: (key: string, value: unknown) => void;
|
||||||
|
/** Whether the owning plugin is running. */
|
||||||
|
running: boolean;
|
||||||
|
/** Message from a rejected save, shown under the control. */
|
||||||
|
error?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsFieldRow({
|
||||||
|
pluginId,
|
||||||
|
field,
|
||||||
|
values,
|
||||||
|
setValue,
|
||||||
|
running,
|
||||||
|
error,
|
||||||
|
disabled = false,
|
||||||
|
}: SettingsFieldProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const label = (key?: string) => (key ? t(pluginKey(pluginId, key)) : "");
|
||||||
|
|
||||||
|
// A custom field draws its own row: it has no single control to put in the
|
||||||
|
// right-hand slot, and often no label of its own either.
|
||||||
|
if (field.type === "custom") {
|
||||||
|
const Component = getSettingsComponent(pluginId, field.component);
|
||||||
|
if (!Component) return null;
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
pluginId={pluginId}
|
||||||
|
values={values}
|
||||||
|
setValue={setValue}
|
||||||
|
running={running}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = values[field.key];
|
||||||
|
const readOnly = disabled || !running;
|
||||||
|
|
||||||
|
const description = field.descriptionKey ? (
|
||||||
|
<>
|
||||||
|
{label(field.descriptionKey)}
|
||||||
|
{error && <span className="block text-destructive mt-0.5">{error}</span>}
|
||||||
|
</>
|
||||||
|
) : error ? (
|
||||||
|
<span className="text-destructive">{error}</span>
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingRow
|
||||||
|
label={label(field.labelKey)}
|
||||||
|
description={description}
|
||||||
|
badge={error ? t("common.error") : undefined}
|
||||||
|
>
|
||||||
|
<FieldControl
|
||||||
|
pluginId={pluginId}
|
||||||
|
field={field}
|
||||||
|
value={value}
|
||||||
|
setValue={setValue}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldControl({
|
||||||
|
pluginId,
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
setValue,
|
||||||
|
readOnly,
|
||||||
|
}: {
|
||||||
|
pluginId: string;
|
||||||
|
field: PluginSettingsField;
|
||||||
|
value: unknown;
|
||||||
|
setValue: (key: string, value: unknown) => void;
|
||||||
|
readOnly: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const label = (key?: string) => (key ? t(pluginKey(pluginId, key)) : "");
|
||||||
|
const placeholder = field.placeholderKey
|
||||||
|
? label(field.placeholderKey)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
switch (field.type) {
|
||||||
|
case "boolean":
|
||||||
|
return (
|
||||||
|
<FakeSwitch
|
||||||
|
checked={value === true}
|
||||||
|
disabled={readOnly}
|
||||||
|
onChange={(next) => setValue(field.key, next)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "number":
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="h-7 w-40 text-sm"
|
||||||
|
disabled={readOnly}
|
||||||
|
min={field.min}
|
||||||
|
max={field.max}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value === undefined || value === null ? "" : String(value)}
|
||||||
|
onChange={(event) =>
|
||||||
|
setValue(
|
||||||
|
field.key,
|
||||||
|
event.target.value === "" ? null : Number(event.target.value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "select":
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
disabled={readOnly}
|
||||||
|
value={typeof value === "string" ? value : ""}
|
||||||
|
onValueChange={(next) => setValue(field.key, next)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 w-52 text-sm">
|
||||||
|
<SelectValue placeholder={placeholder} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(field.options ?? []).map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{label(option.labelKey)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "multiselect": {
|
||||||
|
const selected = Array.isArray(value) ? (value as string[]) : [];
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5 items-end">
|
||||||
|
{(field.options ?? []).map((option) => (
|
||||||
|
<label
|
||||||
|
key={option.value}
|
||||||
|
className="flex items-center gap-2 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
<span>{label(option.labelKey)}</span>
|
||||||
|
<Checkbox
|
||||||
|
disabled={readOnly}
|
||||||
|
checked={selected.includes(option.value)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setValue(
|
||||||
|
field.key,
|
||||||
|
checked
|
||||||
|
? [...selected, option.value]
|
||||||
|
: selected.filter((entry) => entry !== option.value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "secret":
|
||||||
|
return (
|
||||||
|
<SecretControl
|
||||||
|
field={field}
|
||||||
|
value={value}
|
||||||
|
setValue={setValue}
|
||||||
|
readOnly={readOnly}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "textarea":
|
||||||
|
case "json":
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
className="w-64 text-sm font-mono min-h-16"
|
||||||
|
disabled={readOnly}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={
|
||||||
|
typeof value === "string"
|
||||||
|
? value
|
||||||
|
: value === undefined || value === null
|
||||||
|
? ""
|
||||||
|
: JSON.stringify(value, null, 2)
|
||||||
|
}
|
||||||
|
onChange={(event) => setValue(field.key, event.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
className="h-7 w-52 text-sm"
|
||||||
|
disabled={readOnly}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={typeof value === "string" ? value : ""}
|
||||||
|
onChange={(event) => setValue(field.key, event.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A secret is never sent to the browser, so the control shows whether one is
|
||||||
|
* stored and offers to replace or clear it. Leaving it untouched sends the
|
||||||
|
* redacted marker straight back, which the server reads as "no change".
|
||||||
|
*/
|
||||||
|
function SecretControl({
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
setValue,
|
||||||
|
readOnly,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
field: PluginSettingsField;
|
||||||
|
value: unknown;
|
||||||
|
setValue: (key: string, value: unknown) => void;
|
||||||
|
readOnly: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const redacted = isRedactedSecret(value);
|
||||||
|
const stored = redacted && value.set;
|
||||||
|
|
||||||
|
if (redacted) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{stored ? t("settings.secretSet") : t("settings.secretNotSet")}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
disabled={readOnly}
|
||||||
|
onClick={() => setValue(field.key, "")}
|
||||||
|
>
|
||||||
|
{stored ? t("settings.secretReplace") : t("common.edit")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PasswordInput
|
||||||
|
className="h-7 w-52 text-sm"
|
||||||
|
disabled={readOnly}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={typeof value === "string" ? value : ""}
|
||||||
|
onChange={(event) => setValue(field.key, event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
disabled={readOnly}
|
||||||
|
onClick={() => setValue(field.key, { set: stored })}
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
/**
|
||||||
|
* 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";
|
||||||
|
import { registerLegacySettingsComponents } from "./legacy-settings-components";
|
||||||
|
|
||||||
|
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[]>([]);
|
||||||
|
|
||||||
|
// Registered here rather than at module scope so a custom field's component
|
||||||
|
// exists before any page that renders one. A7 moves this into the loader.
|
||||||
|
useEffect(() => {
|
||||||
|
registerLegacySettingsComponents();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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 @@
|
|||||||
|
/**
|
||||||
|
* The tailnet status shown on the Tailscale settings page.
|
||||||
|
*
|
||||||
|
* Confirms the saved key actually works, which is the question anyone has just
|
||||||
|
* after entering one. Connecting to a device stays in the Tailscale rail panel;
|
||||||
|
* a settings page is for configuring, not for operating.
|
||||||
|
*
|
||||||
|
* Registered by legacy-settings-components.ts until A7 lets the plugin's own
|
||||||
|
* bundle register it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Loader2, RefreshCw } from "lucide-react";
|
||||||
|
import { Button } from "@/components/button";
|
||||||
|
import { pluginKey } from "@/lib/plugin-i18n";
|
||||||
|
import type { SettingsComponentProps } from "../settings-components";
|
||||||
|
import { getTailscaleDevices } from "../../../../plugins/tailscale/src/frontend/tailscale-api";
|
||||||
|
|
||||||
|
type Status =
|
||||||
|
| { kind: "loading" }
|
||||||
|
| { kind: "no-key" }
|
||||||
|
| { kind: "error" }
|
||||||
|
| { kind: "ok"; count: number };
|
||||||
|
|
||||||
|
export function TailscaleDevicesStatus({ running }: SettingsComponentProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [status, setStatus] = useState<Status>({ kind: "loading" });
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!running) {
|
||||||
|
setStatus({ kind: "no-key" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus({ kind: "loading" });
|
||||||
|
try {
|
||||||
|
const result = await getTailscaleDevices();
|
||||||
|
if (!result.hasApiKey) {
|
||||||
|
setStatus({ kind: "no-key" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.error) {
|
||||||
|
setStatus({ kind: "error" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus({ kind: "ok", count: result.devices?.length ?? 0 });
|
||||||
|
} catch {
|
||||||
|
setStatus({ kind: "error" });
|
||||||
|
}
|
||||||
|
}, [running]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const label = (key: string) => t(pluginKey("tailscale", key));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3 py-3">
|
||||||
|
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
||||||
|
<span className="text-sm font-medium leading-snug">
|
||||||
|
{label("settings.devices.label")}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground leading-snug">
|
||||||
|
{status.kind === "loading" && label("settings.devices.checking")}
|
||||||
|
{status.kind === "no-key" && label("settings.devices.noKey")}
|
||||||
|
{status.kind === "error" && label("settings.devices.error")}
|
||||||
|
{status.kind === "ok" &&
|
||||||
|
t(pluginKey("tailscale", "settings.devices.count"), {
|
||||||
|
count: status.count,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs shrink-0"
|
||||||
|
disabled={!running || status.kind === "loading"}
|
||||||
|
onClick={() => void load()}
|
||||||
|
>
|
||||||
|
{status.kind === "loading" ? (
|
||||||
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="size-3" />
|
||||||
|
)}
|
||||||
|
<span className="ml-1.5">{label("settings.devices.refresh")}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Registers the host editor's "Plugins" tab.
|
||||||
|
*
|
||||||
|
* Goes through the existing registerHostEditorTab seam rather than adding a
|
||||||
|
* case to HostTabId, 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 {
|
||||||
|
registerHostEditorTab,
|
||||||
|
unregisterHostEditorTab,
|
||||||
|
} from "@/sidebar/HostManagerTabs";
|
||||||
|
import type { PluginSummary } from "@/api/plugins-api";
|
||||||
|
import {
|
||||||
|
HostPluginSections,
|
||||||
|
type HostPluginSettings,
|
||||||
|
} from "./HostPluginSections";
|
||||||
|
|
||||||
|
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 && (host.fields.length > 0 || !!host.enableKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (contributors.length === 0) {
|
||||||
|
unregisterHostEditorTab(HOST_PLUGINS_TAB_ID);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerHostEditorTab({
|
||||||
|
id: HOST_PLUGINS_TAB_ID,
|
||||||
|
labelKey: "settings.pluginsGroupLabel",
|
||||||
|
icon: <Puzzle className="size-3" />,
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Core-side registration of plugin settings components.
|
||||||
|
*
|
||||||
|
* TEMPORARY. A `type: "custom"` settings field names a component the plugin's
|
||||||
|
* own frontend bundle should register through `app.registerSettingsComponent`.
|
||||||
|
* There is no frontend plugin loader yet, so core registers the handful that
|
||||||
|
* exist, the same way pluginLoader.ts mirrors the first-party action
|
||||||
|
* contributions.
|
||||||
|
*
|
||||||
|
* A7 deletes this file. Each plugin registers its own component from its
|
||||||
|
* bundle, and nothing in core names a plugin id.
|
||||||
|
*
|
||||||
|
* Only register what is needed now. Everything else waits for its own step:
|
||||||
|
* the AI providers list stays where it is until B18.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { registerSettingsComponent } from "./settings-components.js";
|
||||||
|
import { TailscaleDevicesStatus } from "./components/TailscaleDevicesStatus.js";
|
||||||
|
|
||||||
|
let registered = false;
|
||||||
|
|
||||||
|
export function registerLegacySettingsComponents(): void {
|
||||||
|
if (registered) return;
|
||||||
|
registered = true;
|
||||||
|
|
||||||
|
registerSettingsComponent("tailscale", "devices", TailscaleDevicesStatus);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Components behind a `type: "custom"` settings field.
|
||||||
|
*
|
||||||
|
* Most settings are expressible as a schema, and those are rendered from the
|
||||||
|
* manifest so a plugin cannot ship its own form styling. A few are not: a
|
||||||
|
* device browser, a provider list. Those declare a component id here instead,
|
||||||
|
* which keeps the escape hatch narrow and named rather than letting a plugin
|
||||||
|
* hand core arbitrary markup.
|
||||||
|
*
|
||||||
|
* A7 lets a plugin's own frontend bundle register these through
|
||||||
|
* `app.registerSettingsComponent`. Until then core registers them, in
|
||||||
|
* legacy-settings-components.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ComponentType } from "react";
|
||||||
|
|
||||||
|
export interface SettingsComponentProps {
|
||||||
|
pluginId: string;
|
||||||
|
/** Current values for the scope this field sits in. */
|
||||||
|
values: Record<string, unknown>;
|
||||||
|
/** Writes one key in the same scope. */
|
||||||
|
setValue: (key: string, value: unknown) => void;
|
||||||
|
/** Whether the owning plugin is running. */
|
||||||
|
running: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SettingsComponent = ComponentType<SettingsComponentProps>;
|
||||||
|
|
||||||
|
/** Keyed "<pluginId>:<componentId>" so two plugins may use the same name. */
|
||||||
|
const registry = new Map<string, SettingsComponent>();
|
||||||
|
|
||||||
|
function registryKey(pluginId: string, componentId: string): string {
|
||||||
|
return `${pluginId}:${componentId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerSettingsComponent(
|
||||||
|
pluginId: string,
|
||||||
|
componentId: string,
|
||||||
|
component: SettingsComponent,
|
||||||
|
): () => void {
|
||||||
|
const key = registryKey(pluginId, componentId);
|
||||||
|
registry.set(key, component);
|
||||||
|
return () => {
|
||||||
|
if (registry.get(key) === component) registry.delete(key);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSettingsComponent(
|
||||||
|
pluginId: string,
|
||||||
|
componentId: string | undefined,
|
||||||
|
): SettingsComponent | undefined {
|
||||||
|
if (!componentId) return undefined;
|
||||||
|
return registry.get(registryKey(pluginId, componentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterSettingsComponents(pluginId: string): void {
|
||||||
|
for (const key of [...registry.keys()]) {
|
||||||
|
if (key.startsWith(`${pluginId}:`)) registry.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test helper. */
|
||||||
|
export function resetSettingsComponents(): void {
|
||||||
|
registry.clear();
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { PluginSettingsField } from "@/api/plugins-api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a field's `requires` gate is satisfied.
|
||||||
|
*
|
||||||
|
* Kept out of the component file so both the settings page and the host editor
|
||||||
|
* section can import it without dragging a component along.
|
||||||
|
*/
|
||||||
|
export function isFieldActive(
|
||||||
|
field: PluginSettingsField,
|
||||||
|
values: Record<string, unknown>,
|
||||||
|
): boolean {
|
||||||
|
if (!field.requires) return true;
|
||||||
|
return values[field.requires] === true;
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import { Bot } from "lucide-react";
|
|||||||
import { unregisterRailItem } from "@/sidebar/rail-items";
|
import { unregisterRailItem } from "@/sidebar/rail-items";
|
||||||
import { unregisterTabComponent } from "@/shell/tabUtils";
|
import { unregisterTabComponent } from "@/shell/tabUtils";
|
||||||
import { getPlugins, type PluginSummary } from "@/api/plugins-api";
|
import { getPlugins, type PluginSummary } from "@/api/plugins-api";
|
||||||
|
import { syncHostPluginsTab } from "@/settings/host-plugins-tab";
|
||||||
import {
|
import {
|
||||||
declareActionSlot,
|
declareActionSlot,
|
||||||
registerAction,
|
registerAction,
|
||||||
@@ -122,6 +123,10 @@ export function applyPluginState(plugins: PluginSummary[]): void {
|
|||||||
pluginEnabled = nextEnabled;
|
pluginEnabled = nextEnabled;
|
||||||
disabledTabTypes = nextDisabledTabs;
|
disabledTabTypes = nextDisabledTabs;
|
||||||
applyFirstPartyActions(nextEnabled);
|
applyFirstPartyActions(nextEnabled);
|
||||||
|
|
||||||
|
// The host editor's Plugins tab exists only while something fills it, so it
|
||||||
|
// is re-synced whenever the plugin list changes rather than declared once.
|
||||||
|
syncHostPluginsTab(plugins);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshPluginState(): Promise<PluginSummary[]> {
|
export async function refreshPluginState(): Promise<PluginSummary[]> {
|
||||||
|
|||||||
@@ -64,11 +64,6 @@ import {
|
|||||||
type TerminalImageStorageTestResult,
|
type TerminalImageStorageTestResult,
|
||||||
type BrandingSettings,
|
type BrandingSettings,
|
||||||
} from "@/api/settings-api";
|
} from "@/api/settings-api";
|
||||||
import {
|
|
||||||
getTailscaleSettings,
|
|
||||||
updateTailscaleSettings,
|
|
||||||
} from "../../../plugins/tailscale/src/frontend/tailscale-api";
|
|
||||||
import { AdminTailscaleSection } from "../../../plugins/tailscale/src/frontend/AdminTailscaleSection";
|
|
||||||
import {
|
import {
|
||||||
getSessionSharingGloballyEnabled,
|
getSessionSharingGloballyEnabled,
|
||||||
updateSessionSharingGloballyEnabled,
|
updateSessionSharingGloballyEnabled,
|
||||||
@@ -185,8 +180,6 @@ export function AdminSettingsPanel({
|
|||||||
const [guacEnabled, setGuacEnabled] = useState(false);
|
const [guacEnabled, setGuacEnabled] = useState(false);
|
||||||
const [guacUrl, setGuacUrl] = useState("guacd:4822");
|
const [guacUrl, setGuacUrl] = useState("guacd:4822");
|
||||||
const [logLevel, setLogLevel] = useState("info");
|
const [logLevel, setLogLevel] = useState("info");
|
||||||
const [tailscaleApiKey, setTailscaleApiKey] = useState("");
|
|
||||||
const [tailscaleApiBaseUrl, setTailscaleApiBaseUrl] = useState("");
|
|
||||||
const [commandHistoryEnabled, setCommandHistoryEnabled] = useState(true);
|
const [commandHistoryEnabled, setCommandHistoryEnabled] = useState(true);
|
||||||
const [analyticsEnabled, setAnalyticsEnabled] = useState(true);
|
const [analyticsEnabled, setAnalyticsEnabled] = useState(true);
|
||||||
const [analyticsLocked, setAnalyticsLocked] = useState(false);
|
const [analyticsLocked, setAnalyticsLocked] = useState(false);
|
||||||
@@ -413,7 +406,6 @@ export function AdminSettingsPanel({
|
|||||||
guac,
|
guac,
|
||||||
oidcProv,
|
oidcProv,
|
||||||
oidcSilent,
|
oidcSilent,
|
||||||
tailscale,
|
|
||||||
cmdHistory,
|
cmdHistory,
|
||||||
analytics,
|
analytics,
|
||||||
sessionSharingEnabled,
|
sessionSharingEnabled,
|
||||||
@@ -435,7 +427,6 @@ export function AdminSettingsPanel({
|
|||||||
getGuacamoleSettings(),
|
getGuacamoleSettings(),
|
||||||
getOidcAutoProvision(),
|
getOidcAutoProvision(),
|
||||||
getOidcSilentLoginDefault(),
|
getOidcSilentLoginDefault(),
|
||||||
getTailscaleSettings(),
|
|
||||||
getCommandHistoryEnabled(),
|
getCommandHistoryEnabled(),
|
||||||
getAnalyticsEnabled(),
|
getAnalyticsEnabled(),
|
||||||
getSessionSharingGloballyEnabled(),
|
getSessionSharingGloballyEnabled(),
|
||||||
@@ -474,10 +465,6 @@ export function AdminSettingsPanel({
|
|||||||
setGuacEnabled(guac.value.enabled);
|
setGuacEnabled(guac.value.enabled);
|
||||||
setGuacUrl(guac.value.url || "guacd:4822");
|
setGuacUrl(guac.value.url || "guacd:4822");
|
||||||
}
|
}
|
||||||
if (tailscale.status === "fulfilled") {
|
|
||||||
setTailscaleApiKey(tailscale.value.apiKey ?? "");
|
|
||||||
setTailscaleApiBaseUrl(tailscale.value.apiBaseUrl ?? "");
|
|
||||||
}
|
|
||||||
if (cmdHistory.status === "fulfilled") {
|
if (cmdHistory.status === "fulfilled") {
|
||||||
setCommandHistoryEnabled(cmdHistory.value.enabled);
|
setCommandHistoryEnabled(cmdHistory.value.enabled);
|
||||||
}
|
}
|
||||||
@@ -886,15 +873,6 @@ export function AdminSettingsPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSaveTailscaleApiKey() {
|
|
||||||
try {
|
|
||||||
await updateTailscaleSettings(tailscaleApiKey, tailscaleApiBaseUrl);
|
|
||||||
toast.success(t("admin.tailscaleSettingsSaved"));
|
|
||||||
} catch {
|
|
||||||
toast.error(t("admin.tailscaleSettingsSaveFailed"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSaveLogLevel(level: string) {
|
async function handleSaveLogLevel(level: string) {
|
||||||
setLogLevel(level);
|
setLogLevel(level);
|
||||||
try {
|
try {
|
||||||
@@ -1341,16 +1319,6 @@ export function AdminSettingsPanel({
|
|||||||
handleSaveLogLevel={handleSaveLogLevel}
|
handleSaveLogLevel={handleSaveLogLevel}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AdminTailscaleSection
|
|
||||||
open={openSections.has("tailscale")}
|
|
||||||
onToggle={() => toggle("tailscale")}
|
|
||||||
tailscaleApiKey={tailscaleApiKey}
|
|
||||||
setTailscaleApiKey={setTailscaleApiKey}
|
|
||||||
tailscaleApiBaseUrl={tailscaleApiBaseUrl}
|
|
||||||
setTailscaleApiBaseUrl={setTailscaleApiBaseUrl}
|
|
||||||
handleSaveTailscaleApiKey={handleSaveTailscaleApiKey}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AdminSSOSection
|
<AdminSSOSection
|
||||||
open={openSections.has("sso")}
|
open={openSections.has("sso")}
|
||||||
onToggle={() => toggle("sso")}
|
onToggle={() => toggle("sso")}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ import {
|
|||||||
import type { Host, VaultProfile } from "@/types/ui-types";
|
import type { Host, VaultProfile } from "@/types/ui-types";
|
||||||
import type { SSHHost, TunnelStatus } from "@/types";
|
import type { SSHHost, TunnelStatus } from "@/types";
|
||||||
import { useTabsSafe } from "@/shell/TabContext";
|
import { useTabsSafe } from "@/shell/TabContext";
|
||||||
|
import { updatePluginHostSettings } from "@/api/plugins-api";
|
||||||
import {
|
import {
|
||||||
buildHostEditorPayload,
|
buildHostEditorPayload,
|
||||||
createHostEditorForm,
|
createHostEditorForm,
|
||||||
@@ -333,6 +334,29 @@ export function HostEditor({
|
|||||||
.finally(() => setTailscaleLoading(false));
|
.finally(() => setTailscaleLoading(false));
|
||||||
}, [form.authType]);
|
}, [form.authType]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes each plugin's host-scope values through its own route.
|
||||||
|
*
|
||||||
|
* One request per plugin rather than one per field, and a plugin that
|
||||||
|
* rejects a value is reported without failing the host save that already
|
||||||
|
* succeeded.
|
||||||
|
*/
|
||||||
|
const savePluginHostSettings = async (
|
||||||
|
hostId: number,
|
||||||
|
values: Record<string, Record<string, unknown>> | undefined,
|
||||||
|
) => {
|
||||||
|
if (!values || !Number.isInteger(hostId)) return;
|
||||||
|
|
||||||
|
for (const [pluginId, fields] of Object.entries(values)) {
|
||||||
|
if (!fields || Object.keys(fields).length === 0) continue;
|
||||||
|
try {
|
||||||
|
await updatePluginHostSettings(pluginId, hostId, fields);
|
||||||
|
} catch {
|
||||||
|
toast.error(t("settings.pluginSettingsSaveFailed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -350,6 +374,10 @@ export function HostEditor({
|
|||||||
? await updateSSHHost(Number(host.id), data)
|
? await updateSSHHost(Number(host.id), data)
|
||||||
: await createSSHHost(data);
|
: await createSSHHost(data);
|
||||||
}
|
}
|
||||||
|
// After the host: a new one has no id to scope settings to until it
|
||||||
|
// exists. A failure here must not claim the host itself failed to save.
|
||||||
|
await savePluginHostSettings(Number(saved.id), form.pluginSettings);
|
||||||
|
|
||||||
toast.success(host ? t("hosts.hostUpdated") : t("hosts.hostCreated"));
|
toast.success(host ? t("hosts.hostUpdated") : t("hosts.hostCreated"));
|
||||||
setPreviewTerminalTheme(null);
|
setPreviewTerminalTheme(null);
|
||||||
onSave(saved);
|
onSave(saved);
|
||||||
|
|||||||
@@ -342,6 +342,13 @@ export function createHostEditorForm(
|
|||||||
excludedMounts: [] as string[],
|
excludedMounts: [] as string[],
|
||||||
monitoredMounts: [] as Array<{ path: string; label?: string }>,
|
monitoredMounts: [] as Array<{ path: string; label?: string }>,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Host-scope plugin settings, keyed by plugin id. Loaded with the host and
|
||||||
|
// saved through the plugin's own scoped route, not in the host payload.
|
||||||
|
pluginSettings: (host?.pluginSettings ?? {}) as Record<
|
||||||
|
string,
|
||||||
|
Record<string, unknown>
|
||||||
|
>,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
/**
|
||||||
|
* The schema-driven settings form.
|
||||||
|
*
|
||||||
|
* A plugin declares fields and core draws them, so the thing worth proving is
|
||||||
|
* that every declared type renders a usable control, reports what the user
|
||||||
|
* typed, and that a secret's value never appears in the DOM.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import type { PluginSettingsField } from "@/api/plugins-api";
|
||||||
|
import { SettingsFieldRow } from "@/settings/SettingsFields";
|
||||||
|
import { isFieldActive } from "@/settings/settings-fields-util";
|
||||||
|
import {
|
||||||
|
registerSettingsComponent,
|
||||||
|
resetSettingsComponents,
|
||||||
|
} from "@/settings/settings-components";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderField(
|
||||||
|
field: PluginSettingsField,
|
||||||
|
values: Record<string, unknown> = {},
|
||||||
|
options: { running?: boolean } = {},
|
||||||
|
) {
|
||||||
|
const setValue = vi.fn();
|
||||||
|
render(
|
||||||
|
<SettingsFieldRow
|
||||||
|
pluginId="sample"
|
||||||
|
field={field}
|
||||||
|
values={values}
|
||||||
|
setValue={setValue}
|
||||||
|
running={options.running ?? true}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return { setValue };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetSettingsComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("field types", () => {
|
||||||
|
it("renders a boolean as a switch and reports the toggle", () => {
|
||||||
|
const { setValue } = renderField(
|
||||||
|
{ key: "enabled", type: "boolean", labelKey: "enabled.label" },
|
||||||
|
{ enabled: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggle = screen.getByRole("button");
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
|
||||||
|
expect(setValue).toHaveBeenCalledWith("enabled", true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a string as a text box and reports what was typed", () => {
|
||||||
|
const { setValue } = renderField(
|
||||||
|
{ key: "baseUrl", type: "string", labelKey: "baseUrl.label" },
|
||||||
|
{ baseUrl: "https://a" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByDisplayValue("https://a");
|
||||||
|
fireEvent.change(input, { target: { value: "https://b" } });
|
||||||
|
|
||||||
|
expect(setValue).toHaveBeenCalledWith("baseUrl", "https://b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a number with its declared bounds", () => {
|
||||||
|
const { setValue } = renderField(
|
||||||
|
{ key: "retries", type: "number", labelKey: "k", min: 0, max: 5 },
|
||||||
|
{ retries: 2 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByDisplayValue("2") as HTMLInputElement;
|
||||||
|
expect(input.getAttribute("min")).toBe("0");
|
||||||
|
expect(input.getAttribute("max")).toBe("5");
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: "4" } });
|
||||||
|
expect(setValue).toHaveBeenCalledWith("retries", 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a number to null rather than to zero", () => {
|
||||||
|
const { setValue } = renderField(
|
||||||
|
{ key: "retries", type: "number", labelKey: "k" },
|
||||||
|
{ retries: 2 },
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByDisplayValue("2"), { target: { value: "" } });
|
||||||
|
|
||||||
|
expect(setValue).toHaveBeenCalledWith("retries", null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a textarea", () => {
|
||||||
|
const { setValue } = renderField(
|
||||||
|
{ key: "notes", type: "textarea", labelKey: "k" },
|
||||||
|
{ notes: "hello" },
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByDisplayValue("hello"), {
|
||||||
|
target: { value: "goodbye" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(setValue).toHaveBeenCalledWith("notes", "goodbye");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders json as editable text", () => {
|
||||||
|
renderField({ key: "raw", type: "json", labelKey: "k" }, { raw: { a: 1 } });
|
||||||
|
|
||||||
|
expect(screen.getByDisplayValue(/"a": 1/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a multiselect option per choice and reports a selection", () => {
|
||||||
|
const { setValue } = renderField(
|
||||||
|
{
|
||||||
|
key: "tags",
|
||||||
|
type: "multiselect",
|
||||||
|
labelKey: "k",
|
||||||
|
options: [
|
||||||
|
{ value: "a", labelKey: "opt.a" },
|
||||||
|
{ value: "b", labelKey: "opt.b" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ tags: ["a"] },
|
||||||
|
);
|
||||||
|
|
||||||
|
const boxes = screen.getAllByRole("checkbox");
|
||||||
|
expect(boxes).toHaveLength(2);
|
||||||
|
|
||||||
|
fireEvent.click(boxes[1]);
|
||||||
|
expect(setValue).toHaveBeenCalledWith("tags", ["a", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a multiselect value when unticked", () => {
|
||||||
|
const { setValue } = renderField(
|
||||||
|
{
|
||||||
|
key: "tags",
|
||||||
|
type: "multiselect",
|
||||||
|
labelKey: "k",
|
||||||
|
options: [
|
||||||
|
{ value: "a", labelKey: "opt.a" },
|
||||||
|
{ value: "b", labelKey: "opt.b" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ tags: ["a", "b"] },
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getAllByRole("checkbox")[0]);
|
||||||
|
|
||||||
|
expect(setValue).toHaveBeenCalledWith("tags", ["b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables every control while the plugin is not running", () => {
|
||||||
|
renderField(
|
||||||
|
{ key: "baseUrl", type: "string", labelKey: "k" },
|
||||||
|
{ baseUrl: "https://a" },
|
||||||
|
{ running: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(screen.getByDisplayValue("https://a") as HTMLInputElement).disabled,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("secrets", () => {
|
||||||
|
const field: PluginSettingsField = {
|
||||||
|
key: "apiKey",
|
||||||
|
type: "secret",
|
||||||
|
labelKey: "apiKey.label",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("shows a stored secret as set, without its value", () => {
|
||||||
|
renderField(field, { apiKey: { set: true } });
|
||||||
|
|
||||||
|
expect(screen.getByText("settings.secretSet")).toBeTruthy();
|
||||||
|
expect(document.body.textContent).not.toContain("tskey");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an unset secret as not set", () => {
|
||||||
|
renderField(field, { apiKey: { set: false } });
|
||||||
|
|
||||||
|
expect(screen.getByText("settings.secretNotSet")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches to an input when replacing", () => {
|
||||||
|
const { setValue } = renderField(field, { apiKey: { set: true } });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("settings.secretReplace"));
|
||||||
|
|
||||||
|
expect(setValue).toHaveBeenCalledWith("apiKey", "");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes a new value once editing", () => {
|
||||||
|
const { setValue } = renderField(field, { apiKey: "" });
|
||||||
|
|
||||||
|
// A password input has no textbox role, so it is found by its type.
|
||||||
|
const input = document.querySelector(
|
||||||
|
'input[type="password"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
fireEvent.change(input, { target: { value: "tskey-new" } });
|
||||||
|
|
||||||
|
expect(setValue).toHaveBeenCalledWith("apiKey", "tskey-new");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores the redacted marker on cancel, so saving changes nothing", () => {
|
||||||
|
const { setValue } = renderField(field, { apiKey: "half-typed" });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("common.cancel"));
|
||||||
|
|
||||||
|
expect(setValue).toHaveBeenCalledWith("apiKey", { set: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("custom fields", () => {
|
||||||
|
it("renders a registered component", () => {
|
||||||
|
registerSettingsComponent("sample", "devices", () => (
|
||||||
|
<div>custom-component-here</div>
|
||||||
|
));
|
||||||
|
|
||||||
|
renderField({ key: "devices", type: "custom", component: "devices" });
|
||||||
|
|
||||||
|
expect(screen.getByText("custom-component-here")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing when no component is registered", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<SettingsFieldRow
|
||||||
|
pluginId="sample"
|
||||||
|
field={{ key: "devices", type: "custom", component: "missing" }}
|
||||||
|
values={{}}
|
||||||
|
setValue={vi.fn()}
|
||||||
|
running
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.innerHTML).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps two plugins' components apart", () => {
|
||||||
|
registerSettingsComponent("sample", "shared", () => <div>mine</div>);
|
||||||
|
registerSettingsComponent("other", "shared", () => <div>theirs</div>);
|
||||||
|
|
||||||
|
renderField({ key: "shared", type: "custom", component: "shared" });
|
||||||
|
|
||||||
|
expect(screen.getByText("mine")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("theirs")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("errors", () => {
|
||||||
|
it("shows a per-field message from a rejected save", () => {
|
||||||
|
render(
|
||||||
|
<SettingsFieldRow
|
||||||
|
pluginId="sample"
|
||||||
|
field={{ key: "retries", type: "number", labelKey: "k" }}
|
||||||
|
values={{ retries: 99 }}
|
||||||
|
setValue={vi.fn()}
|
||||||
|
running
|
||||||
|
error='"retries" must be at most 5'
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('"retries" must be at most 5')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isFieldActive", () => {
|
||||||
|
const gated: PluginSettingsField = {
|
||||||
|
key: "socketPath",
|
||||||
|
type: "string",
|
||||||
|
labelKey: "k",
|
||||||
|
requires: "enableDocker",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("is inactive while the named boolean is off", () => {
|
||||||
|
expect(isFieldActive(gated, { enableDocker: false })).toBe(false);
|
||||||
|
expect(isFieldActive(gated, {})).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is active once the named boolean is on", () => {
|
||||||
|
expect(isFieldActive(gated, { enableDocker: true })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is always active without a requires", () => {
|
||||||
|
expect(isFieldActive({ key: "a", type: "string", labelKey: "k" }, {})).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
/**
|
||||||
|
* 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>,
|
||||||
|
}));
|
||||||
|
vi.mock("@/settings/legacy-settings-components", () => ({
|
||||||
|
registerLegacySettingsComponents: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* The host editor's Plugins group.
|
||||||
|
*
|
||||||
|
* No bundled plugin declares host settings yet, so a fixture stands in for one.
|
||||||
|
* The rule worth protecting is the empty case: with nothing to show, the tab
|
||||||
|
* must not be registered at all rather than appearing blank.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import type { PluginSummary } from "@/api/plugins-api";
|
||||||
|
import {
|
||||||
|
HOST_PLUGINS_TAB_ID,
|
||||||
|
syncHostPluginsTab,
|
||||||
|
} from "@/settings/host-plugins-tab";
|
||||||
|
import {
|
||||||
|
getRegisteredHostEditorTab,
|
||||||
|
unregisterHostEditorTab,
|
||||||
|
} from "@/sidebar/HostManagerTabs";
|
||||||
|
import { HostPluginSections } from "@/settings/HostPluginSections";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
|
||||||
|
return {
|
||||||
|
id: "docker",
|
||||||
|
name: "Docker",
|
||||||
|
version: "1.0.0",
|
||||||
|
enabled: true,
|
||||||
|
state: "active",
|
||||||
|
icon: "Box",
|
||||||
|
contributes: {
|
||||||
|
settings: {
|
||||||
|
host: {
|
||||||
|
enableKey: "enableDocker",
|
||||||
|
enableLabelKey: "enableDocker.label",
|
||||||
|
fields: [
|
||||||
|
{ key: "socketPath", type: "string", labelKey: "socket.label" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
} as PluginSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
unregisterHostEditorTab(HOST_PLUGINS_TAB_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
unregisterHostEditorTab(HOST_PLUGINS_TAB_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("syncHostPluginsTab", () => {
|
||||||
|
it("registers the tab when a plugin contributes host settings", () => {
|
||||||
|
expect(syncHostPluginsTab([plugin()])).toBe(true);
|
||||||
|
expect(getRegisteredHostEditorTab(HOST_PLUGINS_TAB_ID)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers nothing when no plugin contributes", () => {
|
||||||
|
expect(syncHostPluginsTab([])).toBe(false);
|
||||||
|
expect(getRegisteredHostEditorTab(HOST_PLUGINS_TAB_ID)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a plugin with no host settings", () => {
|
||||||
|
const other = plugin({
|
||||||
|
id: "ai",
|
||||||
|
contributes: { settings: { admin: [] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(syncHostPluginsTab([other])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a disabled plugin", () => {
|
||||||
|
expect(syncHostPluginsTab([plugin({ enabled: false })])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the tab once the last contributor goes", () => {
|
||||||
|
syncHostPluginsTab([plugin()]);
|
||||||
|
expect(getRegisteredHostEditorTab(HOST_PLUGINS_TAB_ID)).toBeDefined();
|
||||||
|
|
||||||
|
syncHostPluginsTab([plugin({ enabled: false })]);
|
||||||
|
|
||||||
|
expect(getRegisteredHostEditorTab(HOST_PLUGINS_TAB_ID)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers for a plugin declaring only an enable switch", () => {
|
||||||
|
const bare = plugin({
|
||||||
|
contributes: {
|
||||||
|
settings: {
|
||||||
|
host: { enableKey: "enableThing", enableLabelKey: "k", fields: [] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(syncHostPluginsTab([bare])).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HostPluginSections", () => {
|
||||||
|
it("shows the enable switch and hides the fields until it is on", () => {
|
||||||
|
const setValue = vi.fn();
|
||||||
|
render(
|
||||||
|
<HostPluginSections
|
||||||
|
plugins={[plugin()]}
|
||||||
|
values={{ docker: { enableDocker: false } }}
|
||||||
|
setValue={setValue}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("plugins.docker.enableDocker.label")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("plugins.docker.socket.label")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the fields once the enable switch is on", () => {
|
||||||
|
render(
|
||||||
|
<HostPluginSections
|
||||||
|
plugins={[plugin()]}
|
||||||
|
values={{ docker: { enableDocker: true } }}
|
||||||
|
setValue={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("plugins.docker.socket.label")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a change against the right plugin", () => {
|
||||||
|
const setValue = vi.fn();
|
||||||
|
render(
|
||||||
|
<HostPluginSections
|
||||||
|
plugins={[plugin()]}
|
||||||
|
values={{ docker: { enableDocker: false } }}
|
||||||
|
setValue={setValue}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getAllByRole("button")[0]);
|
||||||
|
|
||||||
|
expect(setValue).toHaveBeenCalledWith("docker", "enableDocker", true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns when the plugin is installed but not running", () => {
|
||||||
|
render(
|
||||||
|
<HostPluginSections
|
||||||
|
plugins={[plugin({ state: "failed" })]}
|
||||||
|
values={{ docker: {} }}
|
||||||
|
setValue={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("settings.hostPluginNotRunning")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing at all with no contributing plugins", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<HostPluginSections plugins={[]} values={{}} setValue={vi.fn()} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.innerHTML).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps two plugins' values apart", () => {
|
||||||
|
const vault = plugin({
|
||||||
|
id: "vault",
|
||||||
|
name: "Vault",
|
||||||
|
contributes: {
|
||||||
|
settings: {
|
||||||
|
host: {
|
||||||
|
fields: [{ key: "role", type: "string", labelKey: "role.label" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<HostPluginSections
|
||||||
|
plugins={[plugin(), vault]}
|
||||||
|
values={{
|
||||||
|
docker: { enableDocker: true, socketPath: "/var/run/docker.sock" },
|
||||||
|
vault: { role: "web" },
|
||||||
|
}}
|
||||||
|
setValue={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByDisplayValue("/var/run/docker.sock")).toBeTruthy();
|
||||||
|
expect(screen.getByDisplayValue("web")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user