From 17f5769e0d605849a2f151bf2ba54c460dea2f78 Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Tue, 21 Jul 2026 20:48:56 +0100 Subject: [PATCH] mcp: per-user connector access control with deny-by-default grants --- .../skald-core/src/db/mcp_catalog_access.rs | 174 ++++++++++++++++++ crates/skald-core/src/db/mcp_global_access.rs | 19 ++ crates/skald-core/src/db/mod.rs | 15 ++ crates/skald-core/src/skald/user_context.rs | 24 ++- src/frontend/api/mcp.rs | 126 ++++++++++++- src/frontend/api/mod.rs | 3 + web/components/users-page.js | 97 ++++++++++ web/css/users-roles.css | 26 +++ web/i18n/en.js | 9 + web/i18n/fr.js | 9 + web/i18n/it.js | 9 + 11 files changed, 503 insertions(+), 8 deletions(-) create mode 100644 crates/skald-core/src/db/mcp_catalog_access.rs diff --git a/crates/skald-core/src/db/mcp_catalog_access.rs b/crates/skald-core/src/db/mcp_catalog_access.rs new file mode 100644 index 0000000..60e29ef --- /dev/null +++ b/crates/skald-core/src/db/mcp_catalog_access.rs @@ -0,0 +1,174 @@ +//! Which users the admin has authorized to activate each per-user catalog +//! connector (the catalog twin of [`super::mcp_global_access`]). +//! +//! Registry junction table in `system.db`, deny-by-default: a user may see and +//! activate a `per_user` catalog entry only if a row grants it. Supersedes +//! `mcp_catalog.role_filter` as the access gate. Both FKs are registry→registry +//! (allowed), mirroring `mcp_global_access` / `shared_folder_members`. + +use anyhow::Result; +use sqlx::SqlitePool; + +// ── Reads ──────────────────────────────────────────────────────────────────── + +/// The catalog entry names a user is authorized to activate. +pub async fn catalog_names_for_user(pool: &SqlitePool, user_id: &str) -> Result> { + let rows = sqlx::query_as::<_, (String,)>( + "SELECT catalog_name FROM mcp_catalog_access WHERE user_id = ? ORDER BY catalog_name", + ) + .bind(user_id) + .fetch_all(pool) + .await?; + Ok(rows.into_iter().map(|(n,)| n).collect()) +} + +/// The ids of the users authorized to activate a given catalog entry. +pub async fn users_for_catalog(pool: &SqlitePool, catalog_name: &str) -> Result> { + let rows = sqlx::query_as::<_, (String,)>( + "SELECT user_id FROM mcp_catalog_access WHERE catalog_name = ? ORDER BY user_id", + ) + .bind(catalog_name) + .fetch_all(pool) + .await?; + Ok(rows.into_iter().map(|(u,)| u).collect()) +} + +pub async fn has_access(pool: &SqlitePool, catalog_name: &str, user_id: &str) -> Result { + let row = sqlx::query_as::<_, (i64,)>( + "SELECT 1 FROM mcp_catalog_access WHERE catalog_name = ? AND user_id = ?", + ) + .bind(catalog_name) + .bind(user_id) + .fetch_optional(pool) + .await?; + Ok(row.is_some()) +} + +// ── Writes ─────────────────────────────────────────────────────────────────── + +/// Grants a user access to a catalog entry. Idempotent on the PK. +pub async fn grant(pool: &SqlitePool, catalog_name: &str, user_id: &str) -> Result<()> { + sqlx::query( + "INSERT OR IGNORE INTO mcp_catalog_access (catalog_name, user_id) VALUES (?, ?)", + ) + .bind(catalog_name) + .bind(user_id) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn revoke(pool: &SqlitePool, catalog_name: &str, user_id: &str) -> Result<()> { + sqlx::query("DELETE FROM mcp_catalog_access WHERE catalog_name = ? AND user_id = ?") + .bind(catalog_name) + .bind(user_id) + .execute(pool) + .await?; + Ok(()) +} + +/// Replaces a user's full catalog-access list in one shot (the Users-page form: +/// "which connectors may this person use"). Returns the set of names that were +/// **removed** by this write, so the caller can deactivate any that were live. +pub async fn set_for_user( + pool: &SqlitePool, + user_id: &str, + catalog_names: &[String], +) -> Result> { + let before: std::collections::HashSet = + catalog_names_for_user(pool, user_id).await?.into_iter().collect(); + let after: std::collections::HashSet = + catalog_names.iter().cloned().collect(); + + let mut tx = pool.begin().await?; + sqlx::query("DELETE FROM mcp_catalog_access WHERE user_id = ?") + .bind(user_id) + .execute(&mut *tx) + .await?; + for name in &after { + sqlx::query( + "INSERT OR IGNORE INTO mcp_catalog_access (catalog_name, user_id) VALUES (?, ?)", + ) + .bind(name) + .bind(user_id) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + + Ok(before.difference(&after).cloned().collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + /// A registry-schema database in a throwaway temp dir (mirrors the harness in + /// `shared_folders::tests`). FK enforcement is on, so `users` + `mcp_catalog` + /// rows must exist before a grant references them. + async fn registry_pool(tag: &str) -> (SqlitePool, PathBuf) { + use std::sync::atomic::{AtomicU64, Ordering}; + static SEQ: AtomicU64 = AtomicU64::new(0); + let n = SEQ.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir() + .join(format!("skald-catalogaccess-{}-{tag}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let pool = crate::db::init_system_pool(&dir.join("system.db").to_string_lossy()) + .await + .unwrap(); + for (id, name) in [("u1", "alice"), ("u2", "bob")] { + sqlx::query("INSERT INTO users (id, username, role_id, encrypted) VALUES (?, ?, 'admin', 0)") + .bind(id).bind(name).execute(&pool).await.unwrap(); + } + for cat in ["gmail", "pokemon"] { + sqlx::query("INSERT INTO mcp_catalog (name, scope, source) VALUES (?, 'per_user', 'remote')") + .bind(cat).execute(&pool).await.unwrap(); + } + (pool, dir) + } + + #[tokio::test] + async fn grant_is_per_user_and_deny_by_default() { + let (pool, dir) = registry_pool("deny-default").await; + + // Nothing granted yet — deny by default. + assert!(!has_access(&pool, "gmail", "u1").await.unwrap()); + + grant(&pool, "gmail", "u1").await.unwrap(); + assert!(has_access(&pool, "gmail", "u1").await.unwrap()); + // The grant is per-user: bob is unaffected. + assert!(!has_access(&pool, "gmail", "u2").await.unwrap()); + assert_eq!(catalog_names_for_user(&pool, "u1").await.unwrap(), vec!["gmail"]); + assert_eq!(users_for_catalog(&pool, "gmail").await.unwrap(), vec!["u1"]); + + revoke(&pool, "gmail", "u1").await.unwrap(); + assert!(!has_access(&pool, "gmail", "u1").await.unwrap()); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn set_for_user_replaces_and_reports_revoked() { + let (pool, dir) = registry_pool("set-for-user").await; + + // Start with gmail granted. + let removed = set_for_user(&pool, "u1", &["gmail".into()]).await.unwrap(); + assert!(removed.is_empty()); + assert!(has_access(&pool, "gmail", "u1").await.unwrap()); + + // Swap to pokemon: gmail is the revoked one, pokemon the new grant. + let removed = set_for_user(&pool, "u1", &["pokemon".into()]).await.unwrap(); + assert_eq!(removed, vec!["gmail"]); + assert!(!has_access(&pool, "gmail", "u1").await.unwrap()); + assert!(has_access(&pool, "pokemon", "u1").await.unwrap()); + + // Clearing all reports pokemon as revoked. + let removed = set_for_user(&pool, "u1", &[]).await.unwrap(); + assert_eq!(removed, vec!["pokemon"]); + assert!(catalog_names_for_user(&pool, "u1").await.unwrap().is_empty()); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/skald-core/src/db/mcp_global_access.rs b/crates/skald-core/src/db/mcp_global_access.rs index 6101c05..8d97607 100644 --- a/crates/skald-core/src/db/mcp_global_access.rs +++ b/crates/skald-core/src/db/mcp_global_access.rs @@ -89,3 +89,22 @@ pub async fn set_access(pool: &SqlitePool, server_id: i64, user_ids: &[String]) tx.commit().await?; Ok(()) } + +/// Replaces one user's full global-access list in one shot — the per-user twin of +/// [`set_access`], for the Users-page "which connectors may this person use" form. +pub async fn set_for_user(pool: &SqlitePool, user_id: &str, server_ids: &[i64]) -> Result<()> { + let mut tx = pool.begin().await?; + sqlx::query("DELETE FROM mcp_global_access WHERE user_id = ?") + .bind(user_id) + .execute(&mut *tx) + .await?; + for server_id in server_ids { + sqlx::query("INSERT OR IGNORE INTO mcp_global_access (server_id, user_id) VALUES (?, ?)") + .bind(server_id) + .bind(user_id) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(()) +} diff --git a/crates/skald-core/src/db/mod.rs b/crates/skald-core/src/db/mod.rs index aaa5373..b833930 100644 --- a/crates/skald-core/src/db/mod.rs +++ b/crates/skald-core/src/db/mod.rs @@ -12,6 +12,7 @@ pub mod known_tools; pub mod llm_requests; pub mod llm_request_payloads; pub mod mcp_catalog; +pub mod mcp_catalog_access; pub mod mcp_events; pub mod mcp_global_access; pub mod mcp_global_servers; @@ -612,6 +613,20 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> { .execute(pool) .await?; + // Which users the admin has authorized to activate each per-user catalog + // connector (the catalog twin of `mcp_global_access`; deny-by-default — no row + // = no access). `catalog_name` FK is registry→registry (both in this file), + // allowed. Supersedes `mcp_catalog.role_filter` as the access gate. + sqlx::query( + "CREATE TABLE IF NOT EXISTS mcp_catalog_access ( + catalog_name TEXT NOT NULL REFERENCES mcp_catalog(name) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (catalog_name, user_id) + )", + ) + .execute(pool) + .await?; + // Capability grants per role (blueprint §14). A single indexed lookup instead // of parsing `roles.attrs`. `admin` implicitly holds every capability (checked // in code), so only non-admin roles need rows here. diff --git a/crates/skald-core/src/skald/user_context.rs b/crates/skald-core/src/skald/user_context.rs index a53d8b2..063795c 100644 --- a/crates/skald-core/src/skald/user_context.rs +++ b/crates/skald-core/src/skald/user_context.rs @@ -217,8 +217,28 @@ impl UserContextFactory { self.supervisor.adopt_one(mname, tokio::spawn(async move { match crate::db::mcp_user_servers::all_startable(&upool).await { Ok(rows) => { - let mut specs = Vec::with_capacity(rows.len()); - for r in &rows { + // Access filter (deny-by-default): a catalog-derived connector + // starts only while the admin still grants this user access to + // it. Self-registered remotes (no `catalog_name`) are the user's + // own to run. A revoked connector therefore stays dormant from + // the next login on, even though its activation row persists in + // the user's database (which the admin cannot reach while locked). + let mut startable = Vec::with_capacity(rows.len()); + for r in rows { + let allowed = match &r.catalog_name { + Some(cat) => crate::db::mcp_catalog_access::has_access(®istry, cat, &uid) + .await + .unwrap_or(false), + None => true, + }; + if allowed { + startable.push(r); + } else { + tracing::info!(user = %uid, connector = %r.name, "per-user MCP: not starting — catalog access not granted"); + } + } + let mut specs = Vec::with_capacity(startable.len()); + for r in &startable { // Reconcile files + node/python deps in the container // before starting (covers a fresh container and any // connector update — see `prepare_local_connector`). diff --git a/src/frontend/api/mcp.rs b/src/frontend/api/mcp.rs index 56e002d..bb90e06 100644 --- a/src/frontend/api/mcp.rs +++ b/src/frontend/api/mcp.rs @@ -17,7 +17,7 @@ use axum::Json; use serde::Deserialize; use serde_json::{json, Value}; -use skald_core::db::{mcp_catalog, mcp_global_access, mcp_global_servers, mcp_user_servers, oauth_providers, role_capabilities}; +use skald_core::db::{mcp_catalog, mcp_catalog_access, mcp_global_access, mcp_global_servers, mcp_user_servers, oauth_providers, role_capabilities}; use skald_core::skald::Skald; use super::caps::require_cap; @@ -633,6 +633,111 @@ pub async fn global_set_access( Ok(Json(json!({ "ok": true }))) } +// ── admin: per-user connector access (the Users-page "who can use what") ─────── +// +// One surface over both access tables: which registered connectors the admin has +// authorized for a given user. `global` rows write `mcp_global_access`, `catalog` +// rows write `mcp_catalog_access`. For a global the grant is immediate access; for +// a catalog entry it is *eligibility to activate* — the user still supplies their +// own credentials / OAuth in their own Connectors page. Admin-only. + +/// One registered connector as the Users-page access checklist renders it. +#[derive(serde::Serialize)] +pub struct UserConnectorView { + /// `"global"` | `"catalog"` — which access table `name`/`id` belongs to. + pub kind: &'static str, + /// Global server id (the `mcp_global_access` key); `None` for catalog rows. + pub id: Option, + /// Global runtime name OR catalog entry name — the grant key for its table. + pub name: String, + pub friendly_name: Option, + pub description: Option, + /// Global only: a disabled global is nobody's to use yet (shown greyed). + pub enabled: bool, + /// Whether this user is currently authorized for it. + pub granted: bool, +} + +/// `GET /api/users/{id}/connectors` — every registered connector with this user's +/// grant flag. Admin-only. +pub async fn user_connectors_get( + State(skald): State>, + Extension(auth): Extension, + Path(target): Path, +) -> Result>, ApiError> { + require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_CATALOG).await?; + skald_core::db::users::get(skald.db(), &target).await? + .ok_or_else(|| ApiError::not_found("no such user"))?; + + let granted_catalog: std::collections::HashSet = + mcp_catalog_access::catalog_names_for_user(skald.db(), &target).await? + .into_iter().collect(); + + let mut out: Vec = Vec::new(); + for s in mcp_global_servers::all(skald.db()).await? { + let granted = mcp_global_access::has_access(skald.db(), s.id, &target).await?; + out.push(UserConnectorView { + kind: "global", id: Some(s.id), name: s.name, + friendly_name: s.friendly_name, description: s.description, + enabled: s.enabled, granted, + }); + } + for e in mcp_catalog::list_for_scope(skald.db(), "per_user").await? { + let granted = granted_catalog.contains(&e.name); + out.push(UserConnectorView { + kind: "catalog", id: None, name: e.name.clone(), + friendly_name: e.friendly_name, description: e.description, + enabled: true, granted, + }); + } + Ok(Json(out)) +} + +#[derive(Deserialize)] +pub struct UserConnectorsBody { + #[serde(default)] + pub global_ids: Vec, + #[serde(default)] + pub catalog_names: Vec, +} + +/// `PUT /api/users/{id}/connectors` — replaces this user's full access set across +/// both tables. Admin-only. +pub async fn user_connectors_set( + State(skald): State>, + Extension(auth): Extension, + Path(target): Path, + Json(body): Json, +) -> Result, ApiError> { + require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_CATALOG).await?; + skald_core::db::users::get(skald.db(), &target).await? + .ok_or_else(|| ApiError::not_found("no such user"))?; + + // Globals settle at the target user's next login (their `accessible_global` + // snapshot is captured then) — same as the existing per-server access flow. + mcp_global_access::set_for_user(skald.db(), &target, &body.global_ids).await?; + + // Catalog: apply the grant set; `set_for_user` returns the names this revoked. + let revoked = mcp_catalog_access::set_for_user(skald.db(), &target, &body.catalog_names).await?; + + // Immediate revoke for a LIVE user: stop + drop any now-forbidden activation. + // A locked user cannot be reached (their DB is sealed to the admin); the + // startup access filter keeps the connector dormant from their next login on. + if !revoked.is_empty() { + if let Some(ctx) = skald.user_context_if_live(&target).await { + if let Ok(rows) = mcp_user_servers::all(&ctx.pool).await { + for r in rows { + if r.catalog_name.as_deref().is_some_and(|c| revoked.iter().any(|n| n == c)) { + ctx.user_mcp.stop_server(&r.name); + let _ = mcp_user_servers::delete(&ctx.pool, r.id).await; + } + } + } + } + } + Ok(Json(json!({ "ok": true }))) +} + // ── user: available catalog + activation ────────────────────────────────────── /// A globally-active connector as the Connectors page renders it. @@ -673,9 +778,15 @@ pub async fn available( let manages_catalog = role_capabilities::has(skald.db(), &user.role_id, role_capabilities::MANAGE_CATALOG).await?; + let granted_catalog: std::collections::HashSet = + mcp_catalog_access::catalog_names_for_user(skald.db(), &auth.user_id).await? + .into_iter() + .collect(); let mut catalog: Vec<_> = mcp_catalog::list_for_scope(skald.db(), "per_user").await? .into_iter() - .filter(|e| e.allowed_for_role(&user.role_id)) + // Deny-by-default: a user sees a per-user catalog entry only if the admin + // granted it; a catalog manager sees every entry to curate it. + .filter(|e| manages_catalog || granted_catalog.contains(&e.name)) .collect(); if manages_catalog { catalog.extend(mcp_catalog::list_for_scope(skald.db(), "global").await?); @@ -736,8 +847,6 @@ pub async fn activate( Json(body): Json, ) -> Result, ApiError> { let ctx = require_context(&skald, &auth.user_id).await?; - let user = skald_core::db::users::get(skald.db(), &auth.user_id).await? - .ok_or_else(|| ApiError::unauthorized("unknown user"))?; // Resolve the row to insert from either the catalog or a self-registered remote. let insert = match &body.catalog_name { @@ -747,8 +856,13 @@ pub async fn activate( if entry.scope != "per_user" { return Err(ApiError::bad_request("catalog entry is not a per-user connector")); } - if !entry.allowed_for_role(&user.role_id) { - return Err(ApiError::forbidden("your role may not activate this connector")); + // Deny-by-default per-user access: the admin must have granted this user + // the connector (`mcp_catalog_access`). This is the real boundary — the + // `available` list only hides it in the UI. + if !mcp_catalog_access::has_access(skald.db(), cat_name, &auth.user_id).await? { + return Err(ApiError::forbidden( + "you are not authorized to use this connector — ask an admin to enable it for you", + )); } let cap = if entry.source == "local_script" { role_capabilities::REGISTER_LOCAL_FROM_CATALOG diff --git a/src/frontend/api/mod.rs b/src/frontend/api/mod.rs index 4dbe1ad..28f0753 100644 --- a/src/frontend/api/mod.rs +++ b/src/frontend/api/mod.rs @@ -186,6 +186,9 @@ pub fn router() -> Router> { .route("/users", get(users_mgmt::list).post(users_mgmt::create)) .route("/users/{id}", put(users_mgmt::update).delete(users_mgmt::delete)) .route("/users/{id}/password", post(users_mgmt::reset_password)) + // Per-user connector access (admin curates which registered MCP connectors + // each user may use — globals + per-user catalog, in one surface). + .route("/users/{id}/connectors", get(mcp::user_connectors_get).put(mcp::user_connectors_set)) // Shared on-disk folders (blueprint §6) — admin-curated, capability-gated. .route("/shared-folders", get(shared_folders::list).post(shared_folders::create)) diff --git a/web/components/users-page.js b/web/components/users-page.js index 35b4518..fe1069d 100644 --- a/web/components/users-page.js +++ b/web/components/users-page.js @@ -155,6 +155,40 @@ export class UsersPage extends LightElement { } catch (e) { this._error = e.message; } } + // ── Per-user connector access ──────────────────────────────────────────────── + + async _openConnectors(user) { + this._modal = { mode: 'connectors', user, conns: null }; + this._error = null; + try { + const res = await fetch(`/api/users/${user.id}/connectors`); + if (!res.ok) throw new Error(await res.text()); + const conns = await res.json(); + this._modal = { ...this._modal, conns }; + } catch (e) { this._error = e.message; } + } + + _toggleConn(idx) { + const conns = this._modal.conns.map((c, i) => i === idx ? { ...c, granted: !c.granted } : c); + this._modal = { ...this._modal, conns }; + } + + async _saveConnectors() { + const { user, conns } = this._modal; + const global_ids = conns.filter(c => c.kind === 'global' && c.granted).map(c => c.id); + const catalog_names = conns.filter(c => c.kind === 'catalog' && c.granted).map(c => c.name); + this._error = null; + try { + const res = await fetch(`/api/users/${user.id}/connectors`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ global_ids, catalog_names }), + }); + if (!res.ok) throw new Error(await res.text()); + this._closeModal(); + } catch (e) { this._error = e.message; } + } + // ── Render ────────────────────────────────────────────────────────────────── _roleLabel(roleId) { @@ -179,8 +213,68 @@ export class UsersPage extends LightElement { `; } + _renderConnectorsModal() { + const { user, conns } = this._modal; + const globals = (conns ?? []).filter(c => c.kind === 'global'); + const catalog = (conns ?? []).filter(c => c.kind === 'catalog'); + const row = (c) => { + const idx = this._modal.conns.indexOf(c); + return html` + + `; + }; + return html` +
{ if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}> +
+
+ + ${t('users.modal.connectors_title', { username: user.username })} + +
+
+ ${this._error ? html`
${this._error}
` : nothing} + ${conns === null ? html` +
${t('users.loading')}
+ ` : (globals.length === 0 && catalog.length === 0) ? html` +

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

+ ` : html` + ${globals.length ? html` +
+
${t('users.conn.globals')}
+
${t('users.conn.hint_global')}
+ ${globals.map(row)} +
` : nothing} + ${catalog.length ? html` +
+
${t('users.conn.catalog')}
+
${t('users.conn.hint_catalog')}
+ ${catalog.map(row)} +
` : nothing} + `} +
+ +
+
+ `; + } + _renderModal() { if (!this._modal) return nothing; + if (this._modal.mode === 'connectors') return this._renderConnectorsModal(); const { mode, form, user } = this._modal; const title = mode === 'create' ? t('users.modal.create_title') : mode === 'edit' ? t('users.modal.edit_title', { username: user.username }) @@ -325,6 +419,9 @@ export class UsersPage extends LightElement { + diff --git a/web/css/users-roles.css b/web/css/users-roles.css index d4a80f4..291159b 100644 --- a/web/css/users-roles.css +++ b/web/css/users-roles.css @@ -136,3 +136,29 @@ padding: 48px 24px; color: var(--placeholder-color); } + +/* Per-user connector access checklist */ +.um-conn-group { margin-bottom: 18px; } +.um-conn-group:last-child { margin-bottom: 0; } +.um-conn-group-title { + font-weight: 600; + font-size: .8rem; + text-transform: uppercase; + letter-spacing: .03em; + color: var(--placeholder-color); +} +.um-conn-row { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + border: 1px solid var(--card-border); + border-radius: var(--radius-sm, 8px); + margin-bottom: 8px; + cursor: pointer; +} +.um-conn-row:hover { background: var(--bs-tertiary-bg); } +.um-conn-row input { margin-top: 3px; flex: 0 0 auto; } +.um-conn-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.um-conn-name { font-weight: 500; display: flex; align-items: center; gap: 8px; } +.um-conn-desc { font-size: .82rem; color: var(--placeholder-color); } diff --git a/web/i18n/en.js b/web/i18n/en.js index 71b0bf6..e0f53b7 100644 --- a/web/i18n/en.js +++ b/web/i18n/en.js @@ -999,8 +999,17 @@ export default { 'users.action.reset_pw': 'Reset password', 'users.action.edit': 'Edit', + 'users.action.connectors': 'Connectors', 'users.action.delete': 'Delete', + 'users.modal.connectors_title': 'Connectors — {username}', + 'users.conn.globals': 'Shared connectors', + 'users.conn.catalog': 'Personal connectors', + 'users.conn.hint_global': 'Available to this user as soon as you enable it.', + 'users.conn.hint_catalog': 'This user may activate these and sign in themselves from their Connectors page.', + 'users.conn.empty': 'No connectors registered yet.', + 'users.conn.disabled': 'disabled', + 'users.modal.create_title': 'New user', 'users.modal.edit_title': 'Edit {username}', 'users.modal.reset_title': 'Reset password — {username}', diff --git a/web/i18n/fr.js b/web/i18n/fr.js index ba2719e..213d735 100644 --- a/web/i18n/fr.js +++ b/web/i18n/fr.js @@ -986,8 +986,17 @@ export default { 'users.action.reset_pw': 'Réinitialiser le mot de passe', 'users.action.edit': 'Modifier', + 'users.action.connectors': 'Connecteurs', 'users.action.delete': 'Supprimer', + 'users.modal.connectors_title': 'Connecteurs — {username}', + 'users.conn.globals': 'Connecteurs partagés', + 'users.conn.catalog': 'Connecteurs personnels', + 'users.conn.hint_global': 'Disponible pour cet utilisateur dès que vous l\'activez.', + 'users.conn.hint_catalog': 'Cet utilisateur peut les activer et se connecter lui-même depuis sa page Connecteurs.', + 'users.conn.empty': 'Aucun connecteur enregistré pour le moment.', + 'users.conn.disabled': 'désactivé', + 'users.modal.create_title': 'Nouvel utilisateur', 'users.modal.edit_title': 'Modifier {username}', 'users.modal.reset_title': 'Réinitialiser le mot de passe — {username}', diff --git a/web/i18n/it.js b/web/i18n/it.js index 0116fa3..4a34171 100644 --- a/web/i18n/it.js +++ b/web/i18n/it.js @@ -986,8 +986,17 @@ export default { 'users.action.reset_pw': 'Reimposta password', 'users.action.edit': 'Modifica', + 'users.action.connectors': 'Connettori', 'users.action.delete': 'Elimina', + 'users.modal.connectors_title': 'Connettori — {username}', + 'users.conn.globals': 'Connettori condivisi', + 'users.conn.catalog': 'Connettori personali', + 'users.conn.hint_global': 'Disponibile per questo utente non appena lo abiliti.', + 'users.conn.hint_catalog': 'Questo utente può attivarli e accedere da sé dalla propria pagina Connettori.', + 'users.conn.empty': 'Nessun connettore ancora registrato.', + 'users.conn.disabled': 'disabilitato', + 'users.modal.create_title': 'Nuovo utente', 'users.modal.edit_title': 'Modifica {username}', 'users.modal.reset_title': 'Reimposta password — {username}',