Release 0.2.0 #4
@@ -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) |
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 = ?")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user