Release 0.2.0 #4

Merged
dguiducci merged 96 commits from main into release 2026-08-17 18:07:20 +01:00
15 changed files with 327 additions and 93 deletions
Showing only changes of commit da8a835d70 - Show all commits
+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 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). - **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. **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 | | `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) | | `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-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` | | `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 | | `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/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 | | `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 | | `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 | | `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 | | `llm-providers.js` | `<llm-providers-page>` | LLM provider management |
| `models-hub.js` | `<models-hub-page>` | Models hub landing (LLM / Transcription / Image) | | `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!(!plugin_access::has_access(&pool, "telegram", "u1").await.unwrap());
assert_eq!(plugin_access::users_for_plugin(&pool, "telegram").await.unwrap(), vec!["u2"]); 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(); plugin_user_configs::set(&pool, "telegram", "u2", &serde_json::json!({"linked": true})).await.unwrap();
assert_eq!( assert_eq!(
plugin_user_configs::get(&pool, "telegram", "u2").await.unwrap(), 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(()) Ok(())
} }
/// Replaces the full access list for a plugin in one shot (the admin UI's /// Replaces a user's full plugin-grant list in one shot the Users-page form
/// "who can use this" checklist). /// ("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<()> { pub async fn set_access(pool: &SqlitePool, plugin_id: &str, user_ids: &[String]) -> Result<()> {
let mut tx = pool.begin().await?; let mut tx = pool.begin().await?;
sqlx::query("DELETE FROM plugin_access WHERE plugin_id = ?") sqlx::query("DELETE FROM plugin_access WHERE plugin_id = ?")
+56 -4
View File
@@ -62,6 +62,22 @@ pub struct UserPluginView {
pub user_config: Value, 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 /// A plugin-contributed web page as seen by one user — served by
/// `GET /api/plugins/pages`. `entry_url` is already resolved against the /// `GET /api/plugins/pages`. `entry_url` is already resolved against the
/// plugin's router mount, so the frontend can `import()` it directly. /// 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)) 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>> { pub async fn list_grants(&self, id: &str) -> Result<Vec<String>> {
self.find(id)?; self.find(id)?;
plugin_access::users_for_plugin(&self.db, id).await plugin_access::users_for_plugin(&self.db, id).await
} }
pub async fn set_grants(&self, id: &str, user_ids: &[String]) -> Result<()> { /// One user's grant state across every **grantable** plugin — the Users-page
self.find(id)?; /// checklist, the per-user twin of [`Self::list_grants`].
plugin_access::set_access(&self.db, id, user_ids).await ///
/// 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 /// 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. - 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). - 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 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. - 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 ## 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 ## 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. 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 ## 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) ## 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. 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. 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 // Config properties
.route("/config", get(config::list_properties)) .route("/config", get(config::list_properties))
.route("/config/{key}", put(config::set_property)) .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", get(plugins::list))
.route("/plugins/mine", get(plugins::mine)) .route("/plugins/mine", get(plugins::mine))
.route("/plugins/pages", get(plugins::pages)) .route("/plugins/pages", get(plugins::pages))
.route("/plugins/{id}", put(plugins::update)) .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)) .route("/plugins/{id}/my-config", put(plugins::update_my_config))
// Roles // Roles
.route("/roles", get(roles::list).post(roles::create)) .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", get(users_mgmt::list).post(users_mgmt::create))
.route("/users/{id}", put(users_mgmt::update).delete(users_mgmt::delete)) .route("/users/{id}", put(users_mgmt::update).delete(users_mgmt::delete))
.route("/users/{id}/password", post(users_mgmt::reset_password)) .route("/users/{id}/password", post(users_mgmt::reset_password))
// Per-user connector access (admin curates which registered MCP connectors // Per-user access grants: what this person may use. Both live here — one
// each user may use — globals + per-user catalog, in one surface). // 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}/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. // Shared on-disk folders (blueprint §6) — admin-curated, capability-gated.
.route("/shared-folders", get(shared_folders::list).post(shared_folders::create)) .route("/shared-folders", get(shared_folders::list).post(shared_folders::create))
+54 -10
View File
@@ -1,11 +1,18 @@
//! Plugin management API. //! Plugin management API.
//! //!
//! Two audiences, mirroring the Connectors split: //! Two audiences, mirroring the Connectors split:
//! - **Admin** (`plugin.manage` capability): enable/disable, instance-wide //! - **Admin** (`plugin.manage` capability): enable/disable and instance-wide
//! config, and the per-user access grants (`plugin_access`). //! 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 //! - **Any user**: sees the plugins granted to them (`/plugins/mine`, read by
//! the plugins' own page fragments) and submits their own per-user config //! 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). //! (`/{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::{ use axum::{
extract::{Extension, Path, State}, extract::{Extension, Path, State},
@@ -52,7 +59,7 @@ pub async fn update(
Ok(()) Ok(())
} }
// ── Admin: per-user access grants ───────────────────────────────────────────── // ── Admin: access grants, read plugin-side / written user-side ───────────────
#[derive(Serialize)] #[derive(Serialize)]
pub struct AccessEntry { pub struct AccessEntry {
@@ -62,6 +69,8 @@ pub struct AccessEntry {
pub granted: bool, 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( pub async fn get_access(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>, Extension(auth): Extension<AuthUser>,
@@ -83,19 +92,54 @@ pub async fn get_access(
Ok(Json(entries)) Ok(Json(entries))
} }
#[derive(Deserialize)] // ── Admin: one user's grants across every plugin (the Users page) ────────────
pub struct SetAccessBody { //
pub user_ids: Vec<String>, // 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>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>, Extension(auth): Extension<AuthUser>,
Path(id): Path<String>, Path(target): Path<String>,
Json(body): Json<SetAccessBody>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_PLUGINS).await?; 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(()) 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 // Hosts what was squeezed into the old combined page: the instance-wide
// config form (`config_schema`, saved via `PUT /api/plugins/{id}`) and the // config form (`config_schema`, saved via `PUT /api/plugins/{id}`) and the
// per-user access checklist (`GET/PUT /api/plugins/{id}/access`). The enable // enable toggle, repeated in the summary card so a full setup round-trip
// toggle is repeated in the summary card so a full setup round-trip happens // happens on one page.
// 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'; const PAGE_ID = 'plugin-detail';
@@ -32,10 +38,8 @@ export class PluginDetailPage extends LightElement {
_error: { state: true }, _error: { state: true },
_draft: { state: true }, // config form draft _draft: { state: true }, // config form draft
_status: { state: true }, // { ok?: string, err?: string } _status: { state: true }, // { ok?: string, err?: string }
_access: { state: true }, // AccessEntry[] _access: { state: true }, // AccessEntry[] — read-only roster
_accessSel: { state: true }, // Set of granted user ids
_accessErr: { state: true }, _accessErr: { state: true },
_accessSaved: { state: true },
}; };
} }
@@ -53,9 +57,7 @@ export class PluginDetailPage extends LightElement {
this._draft = null; this._draft = null;
this._status = {}; this._status = {};
this._access = null; this._access = null;
this._accessSel = new Set();
this._accessErr = null; this._accessErr = null;
this._accessSaved = false;
} }
connectedCallback() { connectedCallback() {
@@ -109,7 +111,7 @@ export class PluginDetailPage extends LightElement {
// Keep whatever the admin has already typed across a reload triggered by a save. // Keep whatever the admin has already typed across a reload triggered by a save.
this._draft = { ...(p.config || {}), ...(this._draft || {}) }; this._draft = { ...(p.config || {}), ...(this._draft || {}) };
// Binding-managed plugins (e.g. mobile-connector) gate access through // 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(); if (!p.manages_own_access) await this._loadAccess();
} catch (e) { } catch (e) {
this._error = e.message; this._error = e.message;
@@ -118,9 +120,7 @@ export class PluginDetailPage extends LightElement {
async _loadAccess() { async _loadAccess() {
try { try {
const entries = await jf(`/api/plugins/${encodeURIComponent(this._id)}/access`); this._access = 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));
} catch (e) { } catch (e) {
this._accessErr = e.message; this._accessErr = e.message;
} }
@@ -161,25 +161,13 @@ export class PluginDetailPage extends LightElement {
} }
} }
_toggleAccessUser(userId, on) { // Opens a user's page — the surface that owns the grant. `#users/{id}` is the
const next = new Set(this._accessSel); // same route the Users list pushes, so Back behaves identically.
if (on) next.add(userId); else next.delete(userId); _openUser(e, userId) {
this._accessSel = next; e.preventDefault();
this._accessSaved = false; 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' } }));
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;
}
} }
// ── Render ───────────────────────────────────────────────────────────────── // ── Render ─────────────────────────────────────────────────────────────────
@@ -318,6 +306,7 @@ export class PluginDetailPage extends LightElement {
} }
_renderAccess() { _renderAccess() {
const granted = (this._access ?? []).filter(u => u.granted);
return html` return html`
<div style="margin-top:1.75rem"> <div style="margin-top:1.75rem">
<div class="um-header" style="padding:0 0 .5rem"> <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> <div class="text-muted mb-2" style="font-size:.78rem">${t('plugins.access.desc')}</div>
${this._accessErr ? html` ${this._accessErr ? html`
<div class="alert alert-danger py-2 mb-3" style="font-size:.82rem">${this._accessErr}</div>` : nothing} <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 ${this._access === null
? html`<div style="font-size:.8rem"><i class="bi bi-hourglass-split"></i></div>` ? html`<div style="font-size:.8rem"><i class="bi bi-hourglass-split"></i></div>`
: this._access.length === 0 : html`
? html`<div class="um-empty" style="padding:1rem"><i class="bi bi-people"></i><p>${t('plugins.access.empty')}</p></div>` ${granted.length === 0
: html` ? html`<div class="um-empty" style="padding:1rem"><i class="bi bi-people"></i>
<div class="connector-card" style="cursor:default"> <p>${t('plugins.access.nobody')}</p></div>`
${this._access.map(u => html` : html`
<div class="form-check"> <div class="connector-card" style="cursor:default">
<input class="form-check-input" type="checkbox" id="plugin-access-${u.user_id}" ${granted.map(u => html`
.checked=${this._accessSel.has(u.user_id)} <div class="d-flex align-items-center gap-2" style="font-size:.85rem;padding:.15rem 0">
@change=${(e) => this._toggleAccessUser(u.user_id, e.target.checked)} /> <i class="bi bi-person text-muted"></i>
<label class="form-check-label" for="plugin-access-${u.user_id}"> <a href="#users/${encodeURIComponent(u.user_id)}"
${u.username} <code class="text-muted" style="font-size:.7rem">${u.role_id}</code> @click=${(e) => this._openUser(e, u.user_id)}>${u.username}</a>
</label> <code class="text-muted" style="font-size:.7rem">${u.role_id}</code>
</div>`)} </div>`)}
</div> </div>`}
<button class="btn btn-sm btn-primary mt-2" @click=${() => this._saveAccess()}> <a class="btn btn-sm btn-outline-secondary mt-2" href="#users" @click=${(e) => this._openUser(e, null)}>
<i class="bi bi-check-lg me-1"></i>${t('plugins.access.save')} <i class="bi bi-box-arrow-up-right me-1"></i>${t('plugins.access.manage')}
</button>`} </a>`}
</div>`; </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 // 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 // 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 // navigation. Edit and password followed, so everything about one user lives in
// one place: Profile, Connectors, Security. Only **create** stays a modal — it is // one place: Profile, Connectors, Plugins, Security. Only **create** stays a modal
// three fields and a role, it fits. // — it is three fields and a role, it fits.
// //
// The connectors section mirrors the Connectors page's row list (icon, name, // Both grant sections answer the same question — *what may this person use* — so
// description) so the admin reads one vocabulary everywhere; saving replaces the // they read the same way: the Connectors page's row list (icon, name, description),
// whole grant set, like the plugin-detail access checklist. // 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 // 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). // 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 _conns: { state: true }, // working copy of the user's connector grants
_connQ: { state: true }, _connQ: { state: true },
_noIcon: { state: true }, // connector names whose icon failed to load _noIcon: { state: true }, // connector names whose icon failed to load
_plugs: { state: true }, // working copy of the user's plugin grants
_busy: { state: true }, _busy: { state: true },
_dSaved: { state: true }, // "saved" ticks, one per section _dSaved: { state: true }, // "saved" ticks, one per section
_pwSaved: { state: true }, _pwSaved: { state: true },
_connSaved: { state: true }, _connSaved: { state: true },
_plugSaved: { state: true },
}; };
} }
@@ -67,10 +72,12 @@ export class UsersPage extends LightElement {
this._dPw = ''; this._dPw = '';
this._conns = null; this._conns = null;
this._connQ = ''; this._connQ = '';
this._plugs = null;
this._busy = false; this._busy = false;
this._dSaved = false; this._dSaved = false;
this._pwSaved = false; this._pwSaved = false;
this._connSaved = false; this._connSaved = false;
this._plugSaved = false;
} }
connectedCallback() { connectedCallback() {
@@ -137,9 +144,14 @@ export class UsersPage extends LightElement {
}; };
this._dPw = ''; this._dPw = '';
try { try {
const res = await fetch(`/api/users/${encodeURIComponent(u.id)}/connectors`); const [cRes, pRes] = await Promise.all([
if (!res.ok) throw new Error(await res.text()); fetch(`/api/users/${encodeURIComponent(u.id)}/connectors`),
this._conns = await res.json(); 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; } } catch (e) { this._error = e.message; }
} }
@@ -259,6 +271,29 @@ export class UsersPage extends LightElement {
finally { this._busy = false; } 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 ────────────────────────────────────────────────────────── // ── Detail: security ──────────────────────────────────────────────────────────
async _resetPassword() { 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} <div class="alert alert-danger py-2 mb-3" style="font-size:.85rem">${this._error}</div>` : nothing}
${this._renderProfile(u)} ${this._renderProfile(u)}
${this._renderConnectors(u)} ${this._renderConnectors(u)}
${this._renderPlugins(u)}
${this._renderSecurity(u)} ${this._renderSecurity(u)}
</div> </div>
</div>`; </div>`;
@@ -546,6 +582,56 @@ export class UsersPage extends LightElement {
</div>`; </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) { _renderSecurity(u) {
return html` return html`
<div class="ud-section"> <div class="ud-section">
+8 -3
View File
@@ -892,9 +892,9 @@ export default {
'plugin_page.loading': 'Loading…', 'plugin_page.loading': 'Loading…',
'plugin_page.unavailable': 'This page is not available (plugin disabled or page not granted).', 'plugin_page.unavailable': 'This page is not available (plugin disabled or page not granted).',
'plugins.badge.user_page': 'user page', '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.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.empty': 'No users.', 'plugins.access.nobody': 'Nobody has been granted this plugin yet.',
'plugins.access.save': 'Save access', 'plugins.access.manage': 'Manage access from Users',
'plugins.error.required': '"{field}" is required.', 'plugins.error.required': '"{field}" is required.',
'plugins.catalog.configure': 'Configure', 'plugins.catalog.configure': 'Configure',
'plugins.health.ok': 'active', 'plugins.health.ok': 'active',
@@ -1065,6 +1065,7 @@ export default {
'users.detail.back': 'Users', 'users.detail.back': 'Users',
'users.detail.profile': 'Profile', 'users.detail.profile': 'Profile',
'users.detail.connectors': 'Connectors', 'users.detail.connectors': 'Connectors',
'users.detail.plugins': 'Plugins',
'users.detail.security': 'Security', 'users.detail.security': 'Security',
'users.detail.saved': 'Saved', 'users.detail.saved': 'Saved',
'users.detail.delete_hint': 'Deletes this user, their database and all conversation history.', '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.empty': 'No connectors registered yet.',
'users.conn.disabled': 'disabled', '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.create_title': 'New user',
'users.modal.username': 'Username', 'users.modal.username': 'Username',
'users.modal.display_name': 'Display name', 'users.modal.display_name': 'Display name',
+8 -3
View File
@@ -882,9 +882,9 @@ export default {
'plugin_page.loading': 'Chargement…', 'plugin_page.loading': 'Chargement…',
'plugin_page.unavailable': 'Page non disponible (plugin désactivé ou page non accordée).', 'plugin_page.unavailable': 'Page non disponible (plugin désactivé ou page non accordée).',
'plugins.badge.user_page': 'page utilisateur', '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.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.empty': 'Aucun utilisateur.', 'plugins.access.nobody': "Personne n'a encore accès à ce plugin.",
'plugins.access.save': 'Enregistrer les accès', 'plugins.access.manage': 'Gérer les accès depuis Utilisateurs',
'plugins.error.required': '« {field} » est requis.', 'plugins.error.required': '« {field} » est requis.',
'plugins.catalog.configure': 'Configurer', 'plugins.catalog.configure': 'Configurer',
'plugins.health.ok': 'actif', 'plugins.health.ok': 'actif',
@@ -1052,6 +1052,7 @@ export default {
'users.detail.back': 'Utilisateurs', 'users.detail.back': 'Utilisateurs',
'users.detail.profile': 'Profil', 'users.detail.profile': 'Profil',
'users.detail.connectors': 'Connecteurs', 'users.detail.connectors': 'Connecteurs',
'users.detail.plugins': 'Plugins',
'users.detail.security': 'Sécurité', 'users.detail.security': 'Sécurité',
'users.detail.saved': 'Enregistré', 'users.detail.saved': 'Enregistré',
'users.detail.delete_hint': 'Supprime cet utilisateur, sa base de données et tout l\'historique des conversations.', '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.empty': 'Aucun connecteur enregistré pour le moment.',
'users.conn.disabled': 'désactivé', '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.create_title': 'Nouvel utilisateur',
'users.modal.username': 'Nom d\'utilisateur', 'users.modal.username': 'Nom d\'utilisateur',
'users.modal.display_name': 'Nom d\'affichage', 'users.modal.display_name': 'Nom d\'affichage',
+8 -3
View File
@@ -882,9 +882,9 @@ export default {
'plugin_page.loading': 'Caricamento…', 'plugin_page.loading': 'Caricamento…',
'plugin_page.unavailable': 'Pagina non disponibile (plugin disabilitato o pagina non concessa).', 'plugin_page.unavailable': 'Pagina non disponibile (plugin disabilitato o pagina non concessa).',
'plugins.badge.user_page': 'pagina utente', '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.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.empty': 'Nessun utente.', 'plugins.access.nobody': 'Nessuno ha ancora accesso a questo plugin.',
'plugins.access.save': 'Salva accesso', 'plugins.access.manage': 'Gestisci gli accessi da Utenti',
'plugins.error.required': '"{field}" è obbligatorio.', 'plugins.error.required': '"{field}" è obbligatorio.',
'plugins.catalog.configure': 'Configura', 'plugins.catalog.configure': 'Configura',
'plugins.health.ok': 'attivo', 'plugins.health.ok': 'attivo',
@@ -1052,6 +1052,7 @@ export default {
'users.detail.back': 'Utenti', 'users.detail.back': 'Utenti',
'users.detail.profile': 'Profilo', 'users.detail.profile': 'Profilo',
'users.detail.connectors': 'Connettori', 'users.detail.connectors': 'Connettori',
'users.detail.plugins': 'Plugin',
'users.detail.security': 'Sicurezza', 'users.detail.security': 'Sicurezza',
'users.detail.saved': 'Salvato', 'users.detail.saved': 'Salvato',
'users.detail.delete_hint': 'Elimina questo utente, il suo database e tutta la cronologia delle conversazioni.', '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.empty': 'Nessun connettore ancora registrato.',
'users.conn.disabled': 'disabilitato', '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.create_title': 'Nuovo utente',
'users.modal.username': 'Nome utente', 'users.modal.username': 'Nome utente',
'users.modal.display_name': 'Nome visualizzato', 'users.modal.display_name': 'Nome visualizzato',