mcp: per-user connector access control with deny-by-default grants
Nightly Build / build (push) Successful in 6m33s

This commit is contained in:
2026-07-21 20:48:56 +01:00
parent c8e4cb4384
commit 17f5769e0d
11 changed files with 503 additions and 8 deletions
@@ -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<Vec<String>> {
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<Vec<String>> {
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<bool> {
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<Vec<String>> {
let before: std::collections::HashSet<String> =
catalog_names_for_user(pool, user_id).await?.into_iter().collect();
let after: std::collections::HashSet<String> =
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);
}
}
@@ -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(())
}
+15
View File
@@ -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.
+22 -2
View File
@@ -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(&registry, 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`).
+120 -6
View File
@@ -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<i64>,
/// Global runtime name OR catalog entry name — the grant key for its table.
pub name: String,
pub friendly_name: Option<String>,
pub description: Option<String>,
/// 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<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(target): Path<String>,
) -> Result<Json<Vec<UserConnectorView>>, 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<String> =
mcp_catalog_access::catalog_names_for_user(skald.db(), &target).await?
.into_iter().collect();
let mut out: Vec<UserConnectorView> = 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<i64>,
#[serde(default)]
pub catalog_names: Vec<String>,
}
/// `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<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(target): Path<String>,
Json(body): Json<UserConnectorsBody>,
) -> Result<Json<Value>, 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<String> =
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<ActivateBody>,
) -> Result<Json<Value>, 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
+3
View File
@@ -186,6 +186,9 @@ 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).
.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))
+97
View File
@@ -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`
<label class="um-conn-row">
<input class="form-check-input" type="checkbox" .checked=${c.granted} @change=${() => this._toggleConn(idx)} />
<span class="um-conn-main">
<span class="um-conn-name">
${c.friendly_name || c.name}
${c.kind === 'global' && !c.enabled ? html`<span class="um-badge um-badge-inactive">${t('users.conn.disabled')}</span>` : nothing}
</span>
${c.description ? html`<span class="um-conn-desc">${c.description}</span>` : nothing}
</span>
</label>
`;
};
return html`
<div class="um-modal-overlay" @click=${(e) => { if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}>
<div class="um-modal">
<div class="um-modal-header">
<i class="bi bi-plug"></i>
<span>${t('users.modal.connectors_title', { username: user.username })}</span>
<button class="um-btn-icon ms-auto" @click=${() => this._closeModal()}><i class="bi bi-x-lg"></i></button>
</div>
<div class="um-modal-body">
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:.85rem">${this._error}</div>` : nothing}
${conns === null ? html`
<div class="um-empty"><i class="bi bi-hourglass-split"></i> ${t('users.loading')}</div>
` : (globals.length === 0 && catalog.length === 0) ? html`
<div class="um-empty"><i class="bi bi-plug"></i><p>${t('users.conn.empty')}</p></div>
` : html`
${globals.length ? html`
<div class="um-conn-group">
<div class="um-conn-group-title">${t('users.conn.globals')}</div>
<div class="form-text mb-2">${t('users.conn.hint_global')}</div>
${globals.map(row)}
</div>` : nothing}
${catalog.length ? html`
<div class="um-conn-group">
<div class="um-conn-group-title">${t('users.conn.catalog')}</div>
<div class="form-text mb-2">${t('users.conn.hint_catalog')}</div>
${catalog.map(row)}
</div>` : nothing}
`}
</div>
<div class="um-modal-footer">
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._closeModal()}>${t('users.modal.cancel')}</button>
<button class="btn btn-sm btn-primary" ?disabled=${conns === null} @click=${() => this._saveConnectors()}>
<i class="bi bi-check-lg me-1"></i>${t('users.modal.save_btn')}
</button>
</div>
</div>
</div>
`;
}
_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 {
<button class="um-btn-icon" title=${t('users.action.edit')} @click=${() => this._openEdit(u)}>
<i class="bi bi-pencil"></i>
</button>
<button class="um-btn-icon" title=${t('users.action.connectors')} @click=${() => this._openConnectors(u)}>
<i class="bi bi-plug"></i>
</button>
<button class="um-btn-icon" title=${t('users.action.delete')} @click=${() => this._delete(u)}>
<i class="bi bi-trash"></i>
</button>
+26
View File
@@ -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); }
+9
View File
@@ -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}',
+9
View File
@@ -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}',
+9
View File
@@ -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}',