From da8a835d70b0889d81ec08f46069a57efa572227 Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Wed, 29 Jul 2026 11:36:47 +0100 Subject: [PATCH] move per-user plugin grants to the user's page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Granting was a checklist of every user on each plugin's page, so "what may this person use?" meant opening every plugin in turn — and the answer lived on N pages while the connector half of it already lived on one. Both grant sections now sit together on #users/{id}: same row list, same disabled chip, same replace-the-whole-set save. The plugin's own page keeps a read-only roster of who holds it, linking back to each person. - db: plugin_access::set_for_user, the per-user twin of set_for_user on mcp_catalog_access; set_access stays as the inverse read model - PluginManager: list_grants_for_user / set_grants_for_user, which omit and reject manages_own_access plugins (a box that controls nothing is worse than no box) - GET/PUT /api/users/{id}/plugins, mounted next to /users/{id}/connectors; PUT /api/plugins/{id}/access is gone, GET remains as the roster No push after the write, unlike a connector grant: that one gates a runtime snapshotted at login, while a plugin grant is re-read from plugin_access on every request that depends on it (sidebar pages, /plugins/mine, and each inbound channel message), so a revoke lands with no bus event. Docs updated with where access is granted, and why mobile-connector is absent from that list. --- CLAUDE.md | 7 +- crates/skald-core/src/db/mod.rs | 15 ++++ crates/skald-core/src/db/plugin_access.rs | 35 +++++++- crates/skald-core/src/plugin/mod.rs | 60 ++++++++++++- docs/index.md | 1 + docs/plugins/honcho.md | 2 +- docs/plugins/mobile-connector.md | 2 +- docs/plugins/telegram.md | 2 +- src/frontend/api/mod.rs | 10 ++- src/frontend/api/plugins.rs | 64 +++++++++++--- web/components/plugin-detail.js | 87 ++++++++---------- web/components/users-page.js | 102 ++++++++++++++++++++-- web/i18n/en.js | 11 ++- web/i18n/fr.js | 11 ++- web/i18n/it.js | 11 ++- 15 files changed, 327 insertions(+), 93 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4efe978..97c16ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,7 +77,7 @@ Two rules keep the boundary real, and both are enforced by the compiler: - **The core never names a plugin.** A plugin contributes tools through `Plugin::tools(self: Arc)` — the sibling of `http_router()` — so nothing in the core has to downcast to a concrete type. Naming one would drag every plugin in the tree into the core, including a C build via `plugin-transcribe-whisper-local`. - **The core never learns about the process shell.** There is no in-core restart hook — the former `restart` tool and its `tools::restart::set_restart_handler` seam were removed. The only coupling to the supervisor is now the `run.sh` exit-code protocol (exit `255` ⇒ re-exec the same binary by path), a seam no code currently triggers (kept for a future admin-driven restart). The live expression of this principle is `skald_core::boot`, which emits startup lines each shell renders (`src/boot_format.rs` here). -**Plugin visibility & per-user config.** The admin surface is `#plugins` (`plugin-catalog.js`), a status board — one card per plugin with an enable toggle + health dot + a Configure button — plus `#plugin-detail?id=` (`plugin-detail.js`), which holds the instance-config form + per-user access checklist for one plugin (the plugin counterpart of `connector-detail.js`). There is **no generic per-user plugin page**: a plugin with per-user settings (Telegram's pairing, Honcho's opt-in) hosts them in its own sidebar page via `Plugin::web_pages()`, like mobile-connector. Enable/disable + instance config + access grants are gated by the `plugin.manage` capability (admin-only by construction). Visibility is **opt-in**: a row in `plugin_access(plugin_id, user_id)` grants a user sight of an enabled plugin (`plugin_id` is bare TEXT, never a FK — a `plugins` row exists only after the first toggle). Per-user values are stored in `plugin_user_configs` (**admin-readable system.db — never secrets**) and applied through the `Plugin::update_user_config` hook, whose default just stores the blob via the `PluginUserConfigApi` on `PluginContext.user_config`. Telegram is the reference impl: its pairing page (a `web_pages()` fragment with no backend of its own) reads the `{linked, chat_id}` status blob from `GET /api/plugins/mine` and submits the code through `PUT /api/plugins/{id}/my-config`; the override turns it into a `chat_id → user_id` binding (same write path as the `telegram_pairing` tool). Endpoints: admin `GET/PUT /api/plugins[/{id}]` + `GET/PUT /api/plugins/{id}/access`; user `GET /api/plugins/mine` + `PUT /api/plugins/{id}/my-config`. +**Plugin visibility & per-user config.** The admin surface is `#plugins` (`plugin-catalog.js`), a status board — one card per plugin with an enable toggle + health dot + a Configure button — plus `#plugin-detail?id=` (`plugin-detail.js`), which holds the instance-config form for one plugin (the plugin counterpart of `connector-detail.js`). **Granting is user-side, exactly like a connector grant**: the checkboxes live in the **Plugins** section of `#users/{id}` (`users-page.js`), right below that person's connectors, and the plugin's own page keeps only a read-only roster of who holds it, linking there. The question an admin asks is "what may this person use", and answering it plugin-by-plugin meant opening every plugin in turn; one write path also means the two surfaces cannot disagree. Unlike an MCP grant — which gates a runtime snapshotted at login and so needs a synchronous revoke — a plugin grant is re-read from `plugin_access` on every request that depends on it (sidebar pages, `/plugins/mine`, and each inbound channel message: Telegram checks it per message), so a revoke lands with no push and nothing on the bus. Binding-managed plugins (`Plugin::manages_own_access`, e.g. mobile-connector) are absent from the user-side list and rejected by its writer — a box that controls nothing is worse than no box. There is **no generic per-user plugin page**: a plugin with per-user settings (Telegram's pairing, Honcho's opt-in) hosts them in its own sidebar page via `Plugin::web_pages()`, like mobile-connector. Enable/disable + instance config + access grants are gated by the `plugin.manage` capability (admin-only by construction). Visibility is **opt-in**: a row in `plugin_access(plugin_id, user_id)` grants a user sight of an enabled plugin (`plugin_id` is bare TEXT, never a FK — a `plugins` row exists only after the first toggle). Per-user values are stored in `plugin_user_configs` (**admin-readable system.db — never secrets**) and applied through the `Plugin::update_user_config` hook, whose default just stores the blob via the `PluginUserConfigApi` on `PluginContext.user_config`. Telegram is the reference impl: its pairing page (a `web_pages()` fragment with no backend of its own) reads the `{linked, chat_id}` status blob from `GET /api/plugins/mine` and submits the code through `PUT /api/plugins/{id}/my-config`; the override turns it into a `chat_id → user_id` binding (same write path as the `telegram_pairing` tool). Endpoints: admin `GET/PUT /api/plugins[/{id}]`, `GET /api/plugins/{id}/access` (read-only roster) + **`GET/PUT /api/users/{id}/plugins`** (the grant write path, the twin of `/api/users/{id}/connectors`); user `GET /api/plugins/mine` + `PUT /api/plugins/{id}/my-config`. **Plugin HTTP routes & web pages.** Every plugin's `http_router()` mounts at boot under `/api/plugin//` — **enabled or not**: two shared gates wrap each router (`require_auth`, then `guard::plugin_enabled_gate`, which re-checks the DB flag per request and answers 404 while disabled), so enable/disable serves/stops routes immediately with no restart, and plugin responses carry `Cache-Control: no-cache`. The router contract: cheap and safe to build pre-start, handlers tolerant of the not-running state (resolve runtime state per request through a shared cell, as mobile-connector does). A plugin may also contribute **frontend pages** via `Plugin::web_pages()` (`PluginPage { page_id, title, icon, entry, admin_only, priority }`): `GET /api/plugins/pages` returns the caller's visible pages (admin: all; others: non-`admin_only` pages of granted, enabled plugins) with `entry_url` resolved, and the sidebar renders them as menu entries routed `#plugin//`. A single `` (`web/components/plugin-page-host.js`) dynamic-imports the fragment ES module the plugin serves from its own router, registers its default-exported HTMLElement class, and mounts it with the `plugin-id` attribute — the fragment talks to its backend only through `/api/plugin//…` and runs with full session privileges (plugins are trusted: they ship in the binary). The frontend knows nothing about plugin page contents or behavior. @@ -412,13 +412,14 @@ The role editor (`roles-page.js`) sets the default group + an allowed-groups che | `cron-jobs.js` | `` | Scheduled job management | | `connectors.js` | `` | MCP Connectors row list (one row per connector): user activate/deactivate + granted globals; admin also gets the **Add connector** dropdown (Marketplace / manual form at `#connectors/new`), per-row removal from the catalog, and the **Sign-in providers** modal (§7/§14/§15) | | `plugin-catalog.js` | `` | `#plugins` — admin status board: one card per plugin (enable toggle + health dot + Configure → `#plugin-detail`) | -| `plugin-detail.js` | `` | `#plugin-detail?id=` — one plugin's admin page: instance-config form (`config_schema`) + per-user access checklist (plugin twin of `connector-detail.js`) | +| `plugin-detail.js` | `` | `#plugin-detail?id=` — one plugin's admin page: instance-config form (`config_schema`) + a **read-only** roster of who holds it, linking to `#users/{id}` (plugin twin of `connector-detail.js`) | +| `users-page.js` | `` | `#users` list + `#users/{id}` one user's page: Profile, **Connectors**, **Plugins**, Security. Both grant sections are the single write path for "what may this person use" | | `plugin-page-host.js` | `` | Host for plugin-contributed pages (`#plugin//`): dynamic-imports the fragment module, registers its element, mounts it with `plugin-id` | | `system-agents.js` | `` | `#system-agents` — one tab per background agent (plus "All"): its description, its settings (admin only) and the caller's own run history. Everyone sees the page; only an admin gets the config half | | `shared/config-form.js` | `ConfigFormController` | The schema-driven settings form, shared by `config-page.js` and the System agents page — one renderer and one write path (`PUT /api/config/{key}`) for every `ConfigSet` | | `shared-folders.js` | `` | `#shared-folders` — admin-only CRUD for on-disk shared folders (§6): create/describe/delete + per-member read-only/read-write grants; description feeds the assistant's `__SHARED_FOLDERS__` context | | `projects/` | `` | `#projects` — host + list + board; the board is tabbed (**Files** explorer with live watcher + write actions, **Sharing** members), deep-linked `#projects/{id}[/sharing]`. See the Projects section | -| `connector-detail.js` | `` | A connector's own page (`#connector?name=X`): env/secret form + Test, the **OAuth login panel** (sign in → paste code → complete, §15), global enable. Access grants live **only** on the Users page (`users-page.js` — the `#users/{id}` page's connectors section), so "who has what" has a single surface | +| `connector-detail.js` | `` | A connector's own page (`#connector?name=X`): env/secret form + Test, the **OAuth login panel** (sign in → paste code → complete, §15), global enable. Access grants live **only** on the Users page (`users-page.js` — the `#users/{id}` page's connectors section, with the plugin grants right below it), so "who has what" has a single surface | | `shared/connector-common.js` | (helpers) | Shared Connectors vocabulary: `statusOf` (incl. `needs_login` for a pending OAuth row), `STATUS_LABEL`, schema normalization, `jf` fetch | | `llm-providers.js` | `` | LLM provider management | | `models-hub.js` | `` | Models hub landing (LLM / Transcription / Image) | diff --git a/crates/skald-core/src/db/mod.rs b/crates/skald-core/src/db/mod.rs index 77b531e..12d7b3d 100644 --- a/crates/skald-core/src/db/mod.rs +++ b/crates/skald-core/src/db/mod.rs @@ -1268,6 +1268,21 @@ mod tests { assert!(!plugin_access::has_access(&pool, "telegram", "u1").await.unwrap()); assert_eq!(plugin_access::users_for_plugin(&pool, "telegram").await.unwrap(), vec!["u2"]); + // The Users-page write path: one user's grants across every plugin. A + // blanket replace, and scoped to that user — u2's telegram grant stands. + plugin_access::set_for_user(&pool, "u1", &["comfyui".to_string(), "honcho".to_string()]) + .await.unwrap(); + assert_eq!( + plugin_access::plugin_ids_for_user(&pool, "u1").await.unwrap(), + vec!["comfyui", "honcho"], + ); + assert!(plugin_access::has_access(&pool, "telegram", "u2").await.unwrap()); + plugin_access::set_for_user(&pool, "u1", &["honcho".to_string()]).await.unwrap(); + assert_eq!(plugin_access::plugin_ids_for_user(&pool, "u1").await.unwrap(), vec!["honcho"]); + plugin_access::set_for_user(&pool, "u1", &[]).await.unwrap(); + assert!(plugin_access::plugin_ids_for_user(&pool, "u1").await.unwrap().is_empty()); + assert!(plugin_access::has_access(&pool, "telegram", "u2").await.unwrap()); + plugin_user_configs::set(&pool, "telegram", "u2", &serde_json::json!({"linked": true})).await.unwrap(); assert_eq!( plugin_user_configs::get(&pool, "telegram", "u2").await.unwrap(), diff --git a/crates/skald-core/src/db/plugin_access.rs b/crates/skald-core/src/db/plugin_access.rs index 19ef70c..861ce64 100644 --- a/crates/skald-core/src/db/plugin_access.rs +++ b/crates/skald-core/src/db/plugin_access.rs @@ -83,8 +83,39 @@ pub async fn revoke(pool: &SqlitePool, plugin_id: &str, user_id: &str) -> Result Ok(()) } -/// Replaces the full access list for a plugin in one shot (the admin UI's -/// "who can use this" checklist). +/// Replaces a user's full plugin-grant list in one shot — the Users-page form +/// ("which plugins may this person use"), the per-user twin of +/// [`super::mcp_catalog_access::set_for_user`]. +/// +/// A blanket replace is correct because the form is fed the **complete** set of +/// grantable plugins: every registered plugin except the binding-managed ones +/// (`Plugin::manages_own_access`), and those never read this table — their +/// access is their own pairing — so clearing a stale row for one is a no-op. +/// +/// Nothing has to be pushed after this write: unlike an MCP grant, which gates +/// a runtime snapshotted at login, a plugin grant is re-read from here on every +/// request and every inbound channel message, so a revoke takes effect at once. +pub async fn set_for_user(pool: &SqlitePool, user_id: &str, plugin_ids: &[String]) -> Result<()> { + let mut tx = pool.begin().await?; + sqlx::query("DELETE FROM plugin_access WHERE user_id = ?") + .bind(user_id) + .execute(&mut *tx) + .await?; + for plugin_id in plugin_ids { + sqlx::query("INSERT OR IGNORE INTO plugin_access (plugin_id, user_id) VALUES (?, ?)") + .bind(plugin_id) + .bind(user_id) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(()) +} + +/// Replaces the full access list for a plugin in one shot (the plugin-shaped +/// twin of [`set_for_user`]). No UI writes through this any more — "who may use +/// what" is edited on the user's page — but it is the honest inverse of the +/// read model and the cheapest way to set a plugin's audience from a test. pub async fn set_access(pool: &SqlitePool, plugin_id: &str, user_ids: &[String]) -> Result<()> { let mut tx = pool.begin().await?; sqlx::query("DELETE FROM plugin_access WHERE plugin_id = ?") diff --git a/crates/skald-core/src/plugin/mod.rs b/crates/skald-core/src/plugin/mod.rs index 7b1b22e..d2b5265 100644 --- a/crates/skald-core/src/plugin/mod.rs +++ b/crates/skald-core/src/plugin/mod.rs @@ -62,6 +62,22 @@ pub struct UserPluginView { pub user_config: Value, } +/// One plugin's grant state for one user — a row of the Users-page checklist +/// ("which plugins may this person use"), served by `GET /api/users/{id}/plugins`. +/// +/// Deliberately shaped like the connector rows next to it on that page: enough +/// to render name + description + a "disabled" chip, and the `granted` flag the +/// checkbox binds to. A *disabled* plugin is still listed — the grant can be set +/// ahead of the admin enabling it, exactly like a disabled global connector. +#[derive(Debug, Clone, Serialize)] +pub struct PluginGrantView { + pub id: String, + pub name: String, + pub description: String, + pub enabled: bool, + pub granted: bool, +} + /// A plugin-contributed web page as seen by one user — served by /// `GET /api/plugins/pages`. `entry_url` is already resolved against the /// plugin's router mount, so the frontend can `import()` it directly. @@ -522,15 +538,51 @@ impl PluginManager { Ok(db::get(&self.db, id).await?.map(|r| r.enabled).unwrap_or(false)) } - /// The user ids granted access to a plugin (admin UI checklist). + /// The user ids granted access to a plugin — the plugin-detail page's + /// read-only "who has this" list. Writing is the *user's* page (see + /// [`Self::set_grants_for_user`]), so there is no plugin-shaped setter. pub async fn list_grants(&self, id: &str) -> Result> { self.find(id)?; plugin_access::users_for_plugin(&self.db, id).await } - pub async fn set_grants(&self, id: &str, user_ids: &[String]) -> Result<()> { - self.find(id)?; - plugin_access::set_access(&self.db, id, user_ids).await + /// One user's grant state across every **grantable** plugin — the Users-page + /// checklist, the per-user twin of [`Self::list_grants`]. + /// + /// Binding-managed plugins (`Plugin::manages_own_access`) are omitted: their + /// access is their own pairing lifecycle, so a checkbox here would control + /// nothing. Disabled plugins are kept — a grant may be set before the admin + /// enables one, and the caller renders the state as a chip. + pub async fn list_grants_for_user(&self, user_id: &str) -> Result> { + let granted: std::collections::HashSet = + plugin_access::plugin_ids_for_user(&self.db, user_id).await?.into_iter().collect(); + let mut out = Vec::new(); + for plugin in &self.plugins { + if plugin.manages_own_access() { + continue; + } + out.push(PluginGrantView { + enabled: self.is_enabled(plugin.id()).await?, + granted: granted.contains(plugin.id()), + id: plugin.id().to_string(), + name: plugin.name().to_string(), + description: plugin.description().to_string(), + }); + } + Ok(out) + } + + /// Replaces one user's plugin grants (the Users-page save button). Every id + /// must name a registered, grantable plugin — a binding-managed one is + /// rejected rather than silently stored, since nothing would ever read it. + pub async fn set_grants_for_user(&self, user_id: &str, plugin_ids: &[String]) -> Result<()> { + for id in plugin_ids { + let plugin = self.find(id)?; + if plugin.manages_own_access() { + anyhow::bail!("plugin manages its own access: {id}"); + } + } + plugin_access::set_for_user(&self.db, user_id, plugin_ids).await } /// Applies a user's per-plugin config submission, received from the diff --git a/docs/index.md b/docs/index.md index d887a3e..411e75c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -35,5 +35,6 @@ General plugin mechanics that apply to all of them: - An admin enables/disables and configures each plugin from the **Plugins** page (sidebar → Plugins, admin-only): one card per plugin, an enable toggle, and a **Configure** button opening its settings form. - A plugin only becomes visible to a given user once the admin grants them access — being enabled instance-wide isn't enough by itself (Mobile Connector is the one exception: access there is the device-pairing itself, not a grant list). +- Access is granted **per person, from that person's own page**: sidebar → Users → click the user → the **Plugins** section, right below their Connectors. So "what may this person use?" is answered in one place, for plugins and connectors together. (The plugin's own page shows the reverse view — who currently holds it — but read-only.) Admins can use every enabled plugin without being granted anything. - A plugin with **per-user** settings (e.g. Telegram's pairing code, Honcho's memory opt-in) gives each granted user its own dedicated **sidebar page** to manage them — separate from the admin's instance-wide config. - A plugin can add tools the assistant calls directly (e.g. `set_secret`, `telegram_pairing`), a dedicated sidebar page, or both. diff --git a/docs/plugins/honcho.md b/docs/plugins/honcho.md index f4c7202..9fa2909 100644 --- a/docs/plugins/honcho.md +++ b/docs/plugins/honcho.md @@ -26,7 +26,7 @@ Streams a user's completed chat turns to an external [Honcho](https://honcho.dev ## Per-user setup -Long-term memory is **off for every user until they turn it on themselves**. Once the plugin is enabled and the user has been granted access, they'll see a **"Long-term memory"** page in their sidebar with a single opt-in toggle. If a user asks the assistant to "remember things long-term" or asks why it doesn't remember past conversations, and this plugin is enabled, point them to that page rather than trying to enable it on their behalf. +Long-term memory is **off for every user until they turn it on themselves**. Once the plugin is enabled and the user has been granted access (admin: Users → that person → **Plugins** → tick Honcho), they'll see a **"Long-term memory"** page in their sidebar with a single opt-in toggle. If a user asks the assistant to "remember things long-term" or asks why it doesn't remember past conversations, and this plugin is enabled, point them to that page rather than trying to enable it on their behalf. ## Notes diff --git a/docs/plugins/mobile-connector.md b/docs/plugins/mobile-connector.md index 741cb19..28c83e3 100644 --- a/docs/plugins/mobile-connector.md +++ b/docs/plugins/mobile-connector.md @@ -8,7 +8,7 @@ Bridges the assistant's Inbox — pending approvals, clarification questions, and MCP elicitations — to a companion mobile app on the user's phone, end-to-end encrypted so even the relay server can't read the content. The phone can also show the full web app UI over the same encrypted tunnel, without any port-forwarding or the server being reachable from the internet. -Unlike most plugins, per-user access here is **not** the usual grant checklist — it's the device↔user binding itself (see pairing below), so this plugin doesn't show the normal "user access" list in the admin UI. +Unlike most plugins, per-user access here is **not** the usual grant checklist — it's the device↔user binding itself (see pairing below). So this plugin is deliberately absent from the **Plugins** list on a user's page: there is no box to tick, and pairing a device is what grants access. ## The Mobile App page diff --git a/docs/plugins/telegram.md b/docs/plugins/telegram.md index 7755abc..4a06c2b 100644 --- a/docs/plugins/telegram.md +++ b/docs/plugins/telegram.md @@ -22,7 +22,7 @@ One bot serves everyone on the instance; each person pairs their **own** Telegra ## Per-user pairing (self-service) -Once the bot is enabled and a user has been granted access to the plugin: +Once the bot is enabled and a user has been granted access to the plugin (admin: Users → that person → **Plugins** → tick Telegram): 1. The user opens Telegram, finds the bot (by the username chosen in BotFather), and sends it any message. 2. The bot replies with a short pairing code. diff --git a/src/frontend/api/mod.rs b/src/frontend/api/mod.rs index f489564..d5d20f5 100644 --- a/src/frontend/api/mod.rs +++ b/src/frontend/api/mod.rs @@ -179,12 +179,13 @@ pub fn router() -> Router> { // Config properties .route("/config", get(config::list_properties)) .route("/config/{key}", put(config::set_property)) - // Plugins — admin: manage + access grants; user: own view + own config + // Plugins — admin: manage + read a plugin's audience; user: own view + own + // config. Granting is user-side, next to the connectors (see below). .route("/plugins", get(plugins::list)) .route("/plugins/mine", get(plugins::mine)) .route("/plugins/pages", get(plugins::pages)) .route("/plugins/{id}", put(plugins::update)) - .route("/plugins/{id}/access", get(plugins::get_access).put(plugins::set_access)) + .route("/plugins/{id}/access", get(plugins::get_access)) .route("/plugins/{id}/my-config", put(plugins::update_my_config)) // Roles .route("/roles", get(roles::list).post(roles::create)) @@ -193,9 +194,10 @@ pub fn router() -> Router> { .route("/users", get(users_mgmt::list).post(users_mgmt::create)) .route("/users/{id}", put(users_mgmt::update).delete(users_mgmt::delete)) .route("/users/{id}/password", post(users_mgmt::reset_password)) - // Per-user connector access (admin curates which registered MCP connectors - // each user may use — globals + per-user catalog, in one surface). + // Per-user access grants: what this person may use. Both live here — one + // page answers "what does Marco have?" instead of N connector/plugin pages. .route("/users/{id}/connectors", get(mcp::user_connectors_get).put(mcp::user_connectors_set)) + .route("/users/{id}/plugins", get(plugins::user_plugins_get).put(plugins::user_plugins_set)) // Shared on-disk folders (blueprint §6) — admin-curated, capability-gated. .route("/shared-folders", get(shared_folders::list).post(shared_folders::create)) diff --git a/src/frontend/api/plugins.rs b/src/frontend/api/plugins.rs index c72a37f..cc4dbb7 100644 --- a/src/frontend/api/plugins.rs +++ b/src/frontend/api/plugins.rs @@ -1,11 +1,18 @@ //! Plugin management API. //! //! Two audiences, mirroring the Connectors split: -//! - **Admin** (`plugin.manage` capability): enable/disable, instance-wide -//! config, and the per-user access grants (`plugin_access`). +//! - **Admin** (`plugin.manage` capability): enable/disable and instance-wide +//! config here; the per-user access grants (`plugin_access`) are **written** +//! from the user's own page (`PUT /api/users/{id}/plugins`, below), leaving +//! `GET /{id}/access` as the read-only "who has this" list. //! - **Any user**: sees the plugins granted to them (`/plugins/mine`, read by //! the plugins' own page fragments) and submits their own per-user config //! (`/{id}/my-config` — e.g. Telegram's pairing code from its sidebar page). +//! +//! Grants live on the user's page for the same reason connector grants do: the +//! question an admin actually asks is "what may this person use", and answering +//! it plugin-by-plugin meant opening every plugin in turn. One surface owns the +//! write, so "who has what" cannot drift between two forms. use axum::{ extract::{Extension, Path, State}, @@ -52,7 +59,7 @@ pub async fn update( Ok(()) } -// ── Admin: per-user access grants ───────────────────────────────────────────── +// ── Admin: access grants, read plugin-side / written user-side ─────────────── #[derive(Serialize)] pub struct AccessEntry { @@ -62,6 +69,8 @@ pub struct AccessEntry { pub granted: bool, } +/// Who currently holds a grant on this plugin — read-only, for the summary on +/// the plugin's page. The checkboxes that change it are on each user's page. pub async fn get_access( State(skald): State>, Extension(auth): Extension, @@ -83,19 +92,54 @@ pub async fn get_access( Ok(Json(entries)) } -#[derive(Deserialize)] -pub struct SetAccessBody { - pub user_ids: Vec, +// ── Admin: one user's grants across every plugin (the Users page) ──────────── +// +// The twin of `mcp::user_connectors_{get,set}`, and deliberately the same shape: +// one round-trip fills the checklist, one PUT replaces the whole grant set. +// +// Nothing is pushed after the write. A connector grant gates a runtime that was +// snapshotted at login, so revoking one has to reach into the live user; a +// plugin grant is re-read from `plugin_access` on every request that depends on +// it — the sidebar page list, `/plugins/mine`, and each inbound channel message +// (Telegram checks it per message) — so a revoke lands on the next interaction +// with no bus event and no synchronous refresh. + +/// Rejects the target user id when it names nobody, so the checklist cannot +/// write grants for a ghost. +async fn require_user(skald: &Skald, user_id: &str) -> Result<(), ApiError> { + users::get(skald.db(), user_id).await? + .ok_or_else(|| ApiError::not_found("no such user"))?; + Ok(()) } -pub async fn set_access( +pub async fn user_plugins_get( State(skald): State>, Extension(auth): Extension, - Path(id): Path, - Json(body): Json, + Path(target): Path, ) -> Result { require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_PLUGINS).await?; - skald.plugin_manager().set_grants(&id, &body.user_ids).await?; + require_user(&skald, &target).await?; + Ok(Json(skald.plugin_manager().list_grants_for_user(&target).await?)) +} + +#[derive(Deserialize)] +pub struct UserPluginsBody { + #[serde(default)] + pub plugin_ids: Vec, +} + +pub async fn user_plugins_set( + State(skald): State>, + Extension(auth): Extension, + Path(target): Path, + Json(body): Json, +) -> Result { + require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_PLUGINS).await?; + require_user(&skald, &target).await?; + skald.plugin_manager() + .set_grants_for_user(&target, &body.plugin_ids) + .await + .map_err(|e| ApiError::bad_request(e.to_string()))?; Ok(()) } diff --git a/web/components/plugin-detail.js b/web/components/plugin-detail.js index bd617e6..f4aa6c9 100644 --- a/web/components/plugin-detail.js +++ b/web/components/plugin-detail.js @@ -9,9 +9,15 @@ import { jf, schemaFields, pluginHealth } from './shared/plugin-common.js'; // // Hosts what was squeezed into the old combined page: the instance-wide // config form (`config_schema`, saved via `PUT /api/plugins/{id}`) and the -// per-user access checklist (`GET/PUT /api/plugins/{id}/access`). The enable -// toggle is repeated in the summary card so a full setup round-trip happens -// on one page. +// enable toggle, repeated in the summary card so a full setup round-trip +// happens on one page. +// +// Access used to be an editable checklist of every user here. It is now a +// read-only roster (`GET /api/plugins/{id}/access`) linking to each person's +// page: granting is done on the *user*, next to their connector grants, because +// "what may this person use" is the question an admin actually asks — and +// answering it plugin-by-plugin meant opening every plugin in turn. One write +// path, so the two surfaces cannot disagree about who has what. const PAGE_ID = 'plugin-detail'; @@ -32,10 +38,8 @@ export class PluginDetailPage extends LightElement { _error: { state: true }, _draft: { state: true }, // config form draft _status: { state: true }, // { ok?: string, err?: string } - _access: { state: true }, // AccessEntry[] - _accessSel: { state: true }, // Set of granted user ids + _access: { state: true }, // AccessEntry[] — read-only roster _accessErr: { state: true }, - _accessSaved: { state: true }, }; } @@ -53,9 +57,7 @@ export class PluginDetailPage extends LightElement { this._draft = null; this._status = {}; this._access = null; - this._accessSel = new Set(); this._accessErr = null; - this._accessSaved = false; } connectedCallback() { @@ -109,7 +111,7 @@ export class PluginDetailPage extends LightElement { // Keep whatever the admin has already typed across a reload triggered by a save. this._draft = { ...(p.config || {}), ...(this._draft || {}) }; // Binding-managed plugins (e.g. mobile-connector) gate access through - // their own pairing lifecycle — the generic checklist controls nothing. + // their own pairing lifecycle — there is no grant roster to show. if (!p.manages_own_access) await this._loadAccess(); } catch (e) { this._error = e.message; @@ -118,9 +120,7 @@ export class PluginDetailPage extends LightElement { async _loadAccess() { try { - const entries = await jf(`/api/plugins/${encodeURIComponent(this._id)}/access`); - this._access = entries; - this._accessSel = new Set(entries.filter(e => e.granted).map(e => e.user_id)); + this._access = await jf(`/api/plugins/${encodeURIComponent(this._id)}/access`); } catch (e) { this._accessErr = e.message; } @@ -161,25 +161,13 @@ export class PluginDetailPage extends LightElement { } } - _toggleAccessUser(userId, on) { - const next = new Set(this._accessSel); - if (on) next.add(userId); else next.delete(userId); - this._accessSel = next; - this._accessSaved = false; - } - - async _saveAccess() { - this._accessErr = null; - this._accessSaved = false; - try { - await jf(`/api/plugins/${encodeURIComponent(this._id)}/access`, { - method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ user_ids: [...this._accessSel] }), - }); - this._accessSaved = true; - } catch (e) { - this._accessErr = e.message; - } + // Opens a user's page — the surface that owns the grant. `#users/{id}` is the + // same route the Users list pushes, so Back behaves identically. + _openUser(e, userId) { + e.preventDefault(); + const hash = userId ? `#users/${encodeURIComponent(userId)}` : '#users'; + history.pushState({ page: 'users', user: userId ?? undefined }, '', hash); + window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page: 'users' } })); } // ── Render ───────────────────────────────────────────────────────────────── @@ -318,6 +306,7 @@ export class PluginDetailPage extends LightElement { } _renderAccess() { + const granted = (this._access ?? []).filter(u => u.granted); return html`
@@ -326,27 +315,25 @@ export class PluginDetailPage extends LightElement {
${t('plugins.access.desc')}
${this._accessErr ? html`
${this._accessErr}
` : nothing} - ${this._accessSaved ? html` -
${t('plugins.saved')}
` : nothing} ${this._access === null ? html`
` - : this._access.length === 0 - ? html`

${t('plugins.access.empty')}

` - : html` -
- ${this._access.map(u => html` -
- this._toggleAccessUser(u.user_id, e.target.checked)} /> - -
`)} -
- `} + : html` + ${granted.length === 0 + ? html`
+

${t('plugins.access.nobody')}

` + : html` +
+ ${granted.map(u => html` + `)} +
`} + this._openUser(e, null)}> + ${t('plugins.access.manage')} + `}
`; } } diff --git a/web/components/users-page.js b/web/components/users-page.js index 6215478..383ded6 100644 --- a/web/components/users-page.js +++ b/web/components/users-page.js @@ -11,12 +11,15 @@ import { connectorIconUrl } from './shared/connector-common.js'; // viewport with no scroll. Same failure the connector activation and manual-add // dialogs had, same fix — a page scrolls, and leaving it is a deliberate // navigation. Edit and password followed, so everything about one user lives in -// one place: Profile, Connectors, Security. Only **create** stays a modal — it is -// three fields and a role, it fits. +// one place: Profile, Connectors, Plugins, Security. Only **create** stays a modal +// — it is three fields and a role, it fits. // -// The connectors section mirrors the Connectors page's row list (icon, name, -// description) so the admin reads one vocabulary everywhere; saving replaces the -// whole grant set, like the plugin-detail access checklist. +// Both grant sections answer the same question — *what may this person use* — so +// they read the same way: the Connectors page's row list (icon, name, description), +// a chip for anything not enabled instance-wide, and a save that replaces the whole +// grant set. Plugins moved here from the plugin's own page for that reason: granted +// plugin-by-plugin, "what does this person have?" meant opening every plugin in +// turn, and the answer lived on N pages instead of one. // Stable per-user avatar color: same user, same hue, everywhere (same hash as the // topbar avatar — duplicated, it is three lines and the topbar does not export it). @@ -42,10 +45,12 @@ export class UsersPage extends LightElement { _conns: { state: true }, // working copy of the user's connector grants _connQ: { state: true }, _noIcon: { state: true }, // connector names whose icon failed to load + _plugs: { state: true }, // working copy of the user's plugin grants _busy: { state: true }, _dSaved: { state: true }, // "saved" ticks, one per section _pwSaved: { state: true }, _connSaved: { state: true }, + _plugSaved: { state: true }, }; } @@ -67,10 +72,12 @@ export class UsersPage extends LightElement { this._dPw = ''; this._conns = null; this._connQ = ''; + this._plugs = null; this._busy = false; this._dSaved = false; this._pwSaved = false; this._connSaved = false; + this._plugSaved = false; } connectedCallback() { @@ -137,9 +144,14 @@ export class UsersPage extends LightElement { }; this._dPw = ''; try { - const res = await fetch(`/api/users/${encodeURIComponent(u.id)}/connectors`); - if (!res.ok) throw new Error(await res.text()); - this._conns = await res.json(); + const [cRes, pRes] = await Promise.all([ + fetch(`/api/users/${encodeURIComponent(u.id)}/connectors`), + fetch(`/api/users/${encodeURIComponent(u.id)}/plugins`), + ]); + if (!cRes.ok) throw new Error(await cRes.text()); + if (!pRes.ok) throw new Error(await pRes.text()); + this._conns = await cRes.json(); + this._plugs = await pRes.json(); } catch (e) { this._error = e.message; } } @@ -259,6 +271,29 @@ export class UsersPage extends LightElement { finally { this._busy = false; } } + // ── Detail: plugins ────────────────────────────────────────────────────────── + + _togglePlug(idx) { + this._plugs = this._plugs.map((p, i) => i === idx ? { ...p, granted: !p.granted } : p); + this._plugSaved = false; + } + + async _savePlugins() { + const u = this._user; + const plugin_ids = this._plugs.filter(p => p.granted).map(p => p.id); + this._busy = true; this._error = null; + try { + const res = await fetch(`/api/users/${encodeURIComponent(u.id)}/plugins`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ plugin_ids }), + }); + if (!res.ok) throw new Error(await res.text()); + this._plugSaved = true; + } catch (e) { this._error = e.message; } + finally { this._busy = false; } + } + // ── Detail: security ────────────────────────────────────────────────────────── async _resetPassword() { @@ -390,6 +425,7 @@ export class UsersPage extends LightElement {
${this._error}
` : nothing} ${this._renderProfile(u)} ${this._renderConnectors(u)} + ${this._renderPlugins(u)} ${this._renderSecurity(u)}
`; @@ -546,6 +582,56 @@ export class UsersPage extends LightElement { `; } + // Deliberately unfiltered, unlike the connectors above: a plugin ships in the + // binary, so the list is short and a search box over it is furniture. Plugins + // that gate access through their own pairing (Mobile Connector) never reach + // here — the server omits them, since a checkbox would control nothing. + _renderPlugins(u) { + const plugs = this._plugs; + // An admin holds every enabled plugin implicitly (`list_accessible` short- + // circuits on the role), so unticked boxes here would read as "no access". + const isAdmin = u.role_id === 'admin'; + return html` +
+

${t('users.detail.plugins')}

+ ${isAdmin ? html` +
+ ${t('users.plug.admin_note')} +
` : nothing} + ${plugs === null + ? html`
${t('users.loading')}
` + : plugs.length === 0 + ? html`

${t('users.plug.empty')}

` + : html` +
${t('users.plug.hint')}
+
+ ${plugs.map((p, i) => html` + `)} +
+
+ + ${this._plugSaved ? html`${t('users.detail.saved')}` : nothing} +
`} +
`; + } + _renderSecurity(u) { return html`
diff --git a/web/i18n/en.js b/web/i18n/en.js index 0471293..0c85173 100644 --- a/web/i18n/en.js +++ b/web/i18n/en.js @@ -892,9 +892,9 @@ export default { 'plugin_page.loading': 'Loading…', 'plugin_page.unavailable': 'This page is not available (plugin disabled or page not granted).', 'plugins.badge.user_page': 'user page', - 'plugins.access.desc': 'Tick a box to let that person see and configure this plugin. Saving replaces the whole list.', - 'plugins.access.empty': 'No users.', - 'plugins.access.save': 'Save access', + 'plugins.access.desc': 'Who can see and use this plugin. Access is granted on each person\'s own page, next to their connectors. Admins always have access.', + 'plugins.access.nobody': 'Nobody has been granted this plugin yet.', + 'plugins.access.manage': 'Manage access from Users', 'plugins.error.required': '"{field}" is required.', 'plugins.catalog.configure': 'Configure', 'plugins.health.ok': 'active', @@ -1065,6 +1065,7 @@ export default { 'users.detail.back': 'Users', 'users.detail.profile': 'Profile', 'users.detail.connectors': 'Connectors', + 'users.detail.plugins': 'Plugins', 'users.detail.security': 'Security', 'users.detail.saved': 'Saved', 'users.detail.delete_hint': 'Deletes this user, their database and all conversation history.', @@ -1077,6 +1078,10 @@ export default { 'users.conn.empty': 'No connectors registered yet.', 'users.conn.disabled': 'disabled', + 'users.plug.hint': 'Tick a plugin to let this user see and use it. A disabled plugin can be granted now and will appear once you enable it.', + 'users.plug.empty': 'No plugins available.', + 'users.plug.admin_note': 'Admins can use every enabled plugin, whatever is ticked here.', + 'users.modal.create_title': 'New user', 'users.modal.username': 'Username', 'users.modal.display_name': 'Display name', diff --git a/web/i18n/fr.js b/web/i18n/fr.js index 1f5d6c7..c865872 100644 --- a/web/i18n/fr.js +++ b/web/i18n/fr.js @@ -882,9 +882,9 @@ export default { 'plugin_page.loading': 'Chargement…', 'plugin_page.unavailable': 'Page non disponible (plugin désactivé ou page non accordée).', 'plugins.badge.user_page': 'page utilisateur', - 'plugins.access.desc': "Cochez qui peut voir et configurer ce plugin. L'enregistrement remplace toute la liste.", - 'plugins.access.empty': 'Aucun utilisateur.', - 'plugins.access.save': 'Enregistrer les accès', + 'plugins.access.desc': "Qui peut voir et utiliser ce plugin. L'accès se donne depuis la page de chaque personne, à côté de ses connecteurs. Les administrateurs y ont toujours accès.", + 'plugins.access.nobody': "Personne n'a encore accès à ce plugin.", + 'plugins.access.manage': 'Gérer les accès depuis Utilisateurs', 'plugins.error.required': '« {field} » est requis.', 'plugins.catalog.configure': 'Configurer', 'plugins.health.ok': 'actif', @@ -1052,6 +1052,7 @@ export default { 'users.detail.back': 'Utilisateurs', 'users.detail.profile': 'Profil', 'users.detail.connectors': 'Connecteurs', + 'users.detail.plugins': 'Plugins', 'users.detail.security': 'Sécurité', 'users.detail.saved': 'Enregistré', 'users.detail.delete_hint': 'Supprime cet utilisateur, sa base de données et tout l\'historique des conversations.', @@ -1064,6 +1065,10 @@ export default { 'users.conn.empty': 'Aucun connecteur enregistré pour le moment.', 'users.conn.disabled': 'désactivé', + 'users.plug.hint': "Cochez les plugins que cet utilisateur peut voir et utiliser. Un plugin désactivé peut être accordé dès maintenant : il apparaîtra quand vous l'activerez.", + 'users.plug.empty': 'Aucun plugin disponible.', + 'users.plug.admin_note': "Les administrateurs peuvent utiliser tout plugin activé, quelles que soient les cases cochées ici.", + 'users.modal.create_title': 'Nouvel utilisateur', 'users.modal.username': 'Nom d\'utilisateur', 'users.modal.display_name': 'Nom d\'affichage', diff --git a/web/i18n/it.js b/web/i18n/it.js index 3bcbfa2..8401645 100644 --- a/web/i18n/it.js +++ b/web/i18n/it.js @@ -882,9 +882,9 @@ export default { 'plugin_page.loading': 'Caricamento…', 'plugin_page.unavailable': 'Pagina non disponibile (plugin disabilitato o pagina non concessa).', 'plugins.badge.user_page': 'pagina utente', - 'plugins.access.desc': "Seleziona chi può vedere e configurare questo plugin. Il salvataggio sostituisce l'intera lista.", - 'plugins.access.empty': 'Nessun utente.', - 'plugins.access.save': 'Salva accesso', + 'plugins.access.desc': "Chi può vedere e usare questo plugin. L'accesso si concede dalla pagina della singola persona, accanto ai suoi connettori. Gli amministratori hanno sempre accesso.", + 'plugins.access.nobody': 'Nessuno ha ancora accesso a questo plugin.', + 'plugins.access.manage': 'Gestisci gli accessi da Utenti', 'plugins.error.required': '"{field}" è obbligatorio.', 'plugins.catalog.configure': 'Configura', 'plugins.health.ok': 'attivo', @@ -1052,6 +1052,7 @@ export default { 'users.detail.back': 'Utenti', 'users.detail.profile': 'Profilo', 'users.detail.connectors': 'Connettori', + 'users.detail.plugins': 'Plugin', 'users.detail.security': 'Sicurezza', 'users.detail.saved': 'Salvato', 'users.detail.delete_hint': 'Elimina questo utente, il suo database e tutta la cronologia delle conversazioni.', @@ -1064,6 +1065,10 @@ export default { 'users.conn.empty': 'Nessun connettore ancora registrato.', 'users.conn.disabled': 'disabilitato', + 'users.plug.hint': "Seleziona i plugin che questo utente può vedere e usare. Un plugin disabilitato può essere concesso ora: comparirà appena lo abiliti.", + 'users.plug.empty': 'Nessun plugin disponibile.', + 'users.plug.admin_note': 'Gli amministratori possono usare qualsiasi plugin abilitato, indipendentemente da ciò che è selezionato qui.', + 'users.modal.create_title': 'Nuovo utente', 'users.modal.username': 'Nome utente', 'users.modal.display_name': 'Nome visualizzato',