Version 0.0.1 #2
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`).
|
||||
|
||||
+120
-6
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
Reference in New Issue
Block a user