move per-user plugin grants to the user's page
Nightly Build / build (push) Successful in 7m16s

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.
This commit is contained in:
2026-07-29 11:36:47 +01:00
parent 8bcf09a67e
commit da8a835d70
15 changed files with 327 additions and 93 deletions
+4 -3
View File
@@ -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<Self>)` — 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=<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=<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/<id>/`**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/<plugin_id>/<page_id>`. A single `<plugin-page-host>` (`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/<id>/…` 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` | `<cron-jobs-page>` | Scheduled job management |
| `connectors.js` | `<connectors-page>` | 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` | `<plugin-catalog>` | `#plugins` — admin status board: one card per plugin (enable toggle + health dot + Configure → `#plugin-detail`) |
| `plugin-detail.js` | `<plugin-detail>` | `#plugin-detail?id=<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>` | `#plugin-detail?id=<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-page>` | `#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` | `<plugin-page-host>` | Host for plugin-contributed pages (`#plugin/<plugin_id>/<page_id>`): dynamic-imports the fragment module, registers its element, mounts it with `plugin-id` |
| `system-agents.js` | `<system-agents-page>` | `#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-page>` | `#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-page>` | `#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` | `<connector-detail-page>` | 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` | `<connector-detail-page>` | 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-providers-page>` | LLM provider management |
| `models-hub.js` | `<models-hub-page>` | Models hub landing (LLM / Transcription / Image) |
+15
View File
@@ -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(),
+33 -2
View File
@@ -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 = ?")
+56 -4
View File
@@ -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<Vec<String>> {
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<Vec<PluginGrantView>> {
let granted: std::collections::HashSet<String> =
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
+1
View File
@@ -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.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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.
+6 -4
View File
@@ -179,12 +179,13 @@ pub fn router() -> Router<Arc<Skald>> {
// 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<Arc<Skald>> {
.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))
+54 -10
View File
@@ -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<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
@@ -83,19 +92,54 @@ pub async fn get_access(
Ok(Json(entries))
}
#[derive(Deserialize)]
pub struct SetAccessBody {
pub user_ids: Vec<String>,
// ── 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<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(id): Path<String>,
Json(body): Json<SetAccessBody>,
Path(target): Path<String>,
) -> Result<impl IntoResponse, ApiError> {
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<String>,
}
pub async fn user_plugins_set(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(target): Path<String>,
Json(body): Json<UserPluginsBody>,
) -> Result<impl IntoResponse, ApiError> {
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(())
}
+37 -50
View File
@@ -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`
<div style="margin-top:1.75rem">
<div class="um-header" style="padding:0 0 .5rem">
@@ -326,27 +315,25 @@ export class PluginDetailPage extends LightElement {
<div class="text-muted mb-2" style="font-size:.78rem">${t('plugins.access.desc')}</div>
${this._accessErr ? html`
<div class="alert alert-danger py-2 mb-3" style="font-size:.82rem">${this._accessErr}</div>` : nothing}
${this._accessSaved ? html`
<div class="alert alert-success py-2 mb-3" style="font-size:.82rem">${t('plugins.saved')}</div>` : nothing}
${this._access === null
? html`<div style="font-size:.8rem"><i class="bi bi-hourglass-split"></i></div>`
: this._access.length === 0
? html`<div class="um-empty" style="padding:1rem"><i class="bi bi-people"></i><p>${t('plugins.access.empty')}</p></div>`
: html`
<div class="connector-card" style="cursor:default">
${this._access.map(u => html`
<div class="form-check">
<input class="form-check-input" type="checkbox" id="plugin-access-${u.user_id}"
.checked=${this._accessSel.has(u.user_id)}
@change=${(e) => this._toggleAccessUser(u.user_id, e.target.checked)} />
<label class="form-check-label" for="plugin-access-${u.user_id}">
${u.username} <code class="text-muted" style="font-size:.7rem">${u.role_id}</code>
</label>
</div>`)}
</div>
<button class="btn btn-sm btn-primary mt-2" @click=${() => this._saveAccess()}>
<i class="bi bi-check-lg me-1"></i>${t('plugins.access.save')}
</button>`}
: html`
${granted.length === 0
? html`<div class="um-empty" style="padding:1rem"><i class="bi bi-people"></i>
<p>${t('plugins.access.nobody')}</p></div>`
: html`
<div class="connector-card" style="cursor:default">
${granted.map(u => html`
<div class="d-flex align-items-center gap-2" style="font-size:.85rem;padding:.15rem 0">
<i class="bi bi-person text-muted"></i>
<a href="#users/${encodeURIComponent(u.user_id)}"
@click=${(e) => this._openUser(e, u.user_id)}>${u.username}</a>
<code class="text-muted" style="font-size:.7rem">${u.role_id}</code>
</div>`)}
</div>`}
<a class="btn btn-sm btn-outline-secondary mt-2" href="#users" @click=${(e) => this._openUser(e, null)}>
<i class="bi bi-box-arrow-up-right me-1"></i>${t('plugins.access.manage')}
</a>`}
</div>`;
}
}
+94 -8
View File
@@ -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 {
<div class="alert alert-danger py-2 mb-3" style="font-size:.85rem">${this._error}</div>` : nothing}
${this._renderProfile(u)}
${this._renderConnectors(u)}
${this._renderPlugins(u)}
${this._renderSecurity(u)}
</div>
</div>`;
@@ -546,6 +582,56 @@ export class UsersPage extends LightElement {
</div>`;
}
// 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`
<div class="ud-section">
<h3 class="ud-section-title"><i class="bi bi-puzzle me-2"></i>${t('users.detail.plugins')}</h3>
${isAdmin ? html`
<div class="alert alert-info py-2 mb-2" style="font-size:.8rem">
<i class="bi bi-info-circle me-1"></i>${t('users.plug.admin_note')}
</div>` : nothing}
${plugs === null
? html`<div class="um-empty" style="padding:1rem"><i class="bi bi-hourglass-split"></i> ${t('users.loading')}</div>`
: plugs.length === 0
? html`<div class="um-empty" style="padding:1rem"><i class="bi bi-puzzle"></i><p>${t('users.plug.empty')}</p></div>`
: html`
<div class="form-text mb-2" style="font-size:.75rem">${t('users.plug.hint')}</div>
<div class="connector-list">
${plugs.map((p, i) => html`
<label class="connector-row" style="cursor:pointer">
<input class="form-check-input" type="checkbox"
.checked=${p.granted} @change=${() => this._togglePlug(i)} />
<div class="connector-card-icon connector-card-icon--empty"><i class="bi bi-puzzle"></i></div>
<div class="connector-row-main">
<div class="connector-row-name">
<span>${p.name}</span>
<span class="connector-row-sub">${p.id}</span>
</div>
${p.description ? html`<div class="connector-row-desc">${p.description}</div>` : nothing}
</div>
<div class="connector-row-chips">
${p.enabled ? nothing : html`
<span class="connector-chip"><i class="bi bi-pause-circle"></i>${t('users.conn.disabled')}</span>`}
</div>
</label>`)}
</div>
<div class="d-flex align-items-center gap-2 mt-3">
<button class="btn btn-sm btn-primary" ?disabled=${this._busy} @click=${() => this._savePlugins()}>
<i class="bi bi-check-lg me-1"></i>${t('users.modal.save_btn')}
</button>
${this._plugSaved ? html`<span class="ud-saved"><i class="bi bi-check2"></i>${t('users.detail.saved')}</span>` : nothing}
</div>`}
</div>`;
}
_renderSecurity(u) {
return html`
<div class="ud-section">
+8 -3
View File
@@ -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',
+8 -3
View File
@@ -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',
+8 -3
View File
@@ -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',