mcp: per-user connector access control with deny-by-default grants
Nightly Build / build (push) Successful in 6m33s
Nightly Build / build (push) Successful in 6m33s
This commit is contained in:
@@ -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