import { html, nothing } from 'lit'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { LightElement } from '../lib/base.js'; import { t } from '../lib/i18n.js'; const ADMIN_ID = 'admin'; // Source-driven chat agent (needs a project run-context): never a personal default, // so it is excluded from the role assistant picker (mirrors the server-side guard). const PROJECT_COORDINATOR_ID = 'project-coordinator'; export class RolesPage extends LightElement { static get properties() { return { _open: { state: true }, _roles: { state: true }, _groups: { state: true }, _agents: { state: true }, _error: { state: true }, _modal: { state: true }, // null | { mode: 'create'|'edit', role?, form } }; } constructor() { super(); this._open = false; this._roles = null; this._groups = null; this._agents = null; this._error = null; this._modal = null; } connectedCallback() { super.connectedCallback(); this.__onLocaleChanged = () => this.requestUpdate(); window.addEventListener('locale-changed', this.__onLocaleChanged); window.addEventListener('llm-page-change', (e) => { this._open = e.detail.page === 'roles'; this.style.display = this._open ? 'flex' : 'none'; if (this._open) this._load(); }); } disconnectedCallback() { window.removeEventListener('locale-changed', this.__onLocaleChanged); super.disconnectedCallback(); } async _load() { this._error = null; try { const [rRes, gRes, aRes] = await Promise.all([ fetch('/api/roles'), fetch('/api/tool-permission-groups'), fetch('/api/agents'), ]); if (!rRes.ok) throw new Error(`HTTP ${rRes.status}`); if (!gRes.ok) throw new Error(`HTTP ${gRes.status}`); if (!aRes.ok) throw new Error(`HTTP ${aRes.status}`); this._roles = await rRes.json(); this._groups = await gRes.json(); // Only `type:chat` agents are entry agents; project-coordinator is source-driven. this._agents = (await aRes.json()) .filter(a => a.type === 'chat' && a.id !== PROJECT_COORDINATOR_ID); } catch (e) { this._error = e.message; } } // ── Modal helpers ──────────────────────────────────────────────────────────── // `ui_mode` lives in the free-form attrs JSON (data-driven, §0.1): the UI // surfaces it as a first-class select without hardcoding any role semantics. _attrsUiMode(attrs) { try { return JSON.parse(attrs || '{}').ui_mode === 'simple' ? 'simple' : 'full'; } catch { return 'full'; } } // Extra security-groups the role may pick beyond its default `permission_group` // (the effective set is default ∪ these). Lives in attrs JSON (§0.1). _attrsAllowedGroups(attrs) { try { const a = JSON.parse(attrs || '{}').permission_groups; return Array.isArray(a) ? a : []; } catch { return []; } } // The role's default entry agent (data-driven, §0.1). Empty means "fall back to the // instance default assistant" — the server resolver handles it, so we store nothing. _attrsChatAgent(attrs) { try { const a = JSON.parse(attrs || '{}').chat_agent; return typeof a === 'string' ? a : ''; } catch { return ''; } } // Display name for a chat-agent id (falls back to the id, or the default label when unset). _agentName(id) { if (!id) return t('roles.form.assistant_default'); return this._agents?.find(a => a.id === id)?.name ?? id; } // Whether a plugin or connector the admin installs reaches this role on its own. // Absent means yes — the server's RoleAttrs defaults it to true, so only an // opt-out is ever written (see `db::access_defaults`). _attrsAutoGrant(attrs) { try { return JSON.parse(attrs || '{}').auto_grant !== false; } catch { return true; } } _mergeAttrs(attrs, uiMode, allowedGroups, chatAgent, autoGrant) { let o = {}; try { o = JSON.parse(attrs || '{}') ?? {}; } catch { o = {}; } if (uiMode === 'simple') o.ui_mode = 'simple'; else delete o.ui_mode; const extras = Array.isArray(allowedGroups) ? allowedGroups.filter(Boolean) : []; if (extras.length) o.permission_groups = extras; else delete o.permission_groups; if (chatAgent) o.chat_agent = chatAgent; else delete o.chat_agent; // Only the opt-out is persisted; `true` is the server-side default. if (autoGrant === false) o.auto_grant = false; else delete o.auto_grant; const keys = Object.keys(o); return keys.length ? JSON.stringify(o) : null; } _openCreate() { this._modal = { mode: 'create', form: { id: '', label: '', permission_group: this._groups?.[0]?.id ?? 'default', attrs: '', ui_mode: 'full', allowed_groups: [], chat_agent: '', auto_grant: true }, }; } _openEdit(role) { this._modal = { mode: 'edit', role, form: { label: role.label, permission_group: role.permission_group, attrs: role.attrs ?? '', ui_mode: this._attrsUiMode(role.attrs), allowed_groups: this._attrsAllowedGroups(role.attrs), chat_agent: this._attrsChatAgent(role.attrs), auto_grant: this._attrsAutoGrant(role.attrs) }, }; } _closeModal() { this._modal = null; this._error = null; } _patch(field, value) { this._modal = { ...this._modal, form: { ...this._modal.form, [field]: value } }; } _toggleAllowedGroup(id, checked) { const cur = new Set(this._modal.form.allowed_groups || []); if (checked) cur.add(id); else cur.delete(id); this._patch('allowed_groups', [...cur]); } // ── API actions ────────────────────────────────────────────────────────────── async _save() { const { mode, form } = this._modal; this._error = null; if (mode === 'create') { if (!form.id.trim() || !form.label.trim()) { this._error = t('roles.error.id_label'); return; } try { const res = await fetch('/api/roles', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: form.id.trim(), label: form.label.trim(), permission_group: form.permission_group, attrs: this._mergeAttrs(form.attrs, form.ui_mode, form.allowed_groups, form.chat_agent, form.auto_grant), }), }); if (!res.ok) throw new Error(await res.text()); this._closeModal(); await this._load(); } catch (e) { this._error = e.message; } } else { const { role } = this._modal; if (!form.label.trim()) { this._error = t('roles.error.label'); return; } try { const res = await fetch(`/api/roles/${role.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ label: form.label.trim(), permission_group: form.permission_group, attrs: this._mergeAttrs(form.attrs, form.ui_mode, form.allowed_groups, form.chat_agent, form.auto_grant), }), }); if (!res.ok) throw new Error(await res.text()); this._closeModal(); await this._load(); } catch (e) { this._error = e.message; } } } async _delete(role) { if (!confirm(t('roles.confirm.delete', { name: role.label }))) return; try { const res = await fetch(`/api/roles/${role.id}`, { method: 'DELETE' }); if (!res.ok) throw new Error(await res.text()); await this._load(); } catch (e) { this._error = e.message; } } // ── Render ────────────────────────────────────────────────────────────────── _groupLabel(groupId) { return this._groups?.find(g => g.id === groupId)?.name ?? groupId; } _renderModal() { if (!this._modal) return nothing; const { mode, form, role } = this._modal; const title = mode === 'create' ? t('roles.form.new') : t('roles.form.edit', { name: role.label }); return html`
{ if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}>
${title}
${this._error ? html`
${this._error}
` : nothing} ${mode === 'create' ? html`
this._patch('id', e.target.value)} />
${t('roles.form.id_desc')}
` : nothing}
this._patch('label', e.target.value)} />
${t('roles.form.allowed_hint')}
${(this._groups ?? []).filter(g => g.id !== form.permission_group).map(g => html`
this._toggleAllowedGroup(g.id, e.target.checked)} />
`)}
${unsafeHTML(t('roles.form.interface_hint'))}
${t('roles.form.assistant_hint')}
this._patch('auto_grant', e.target.checked)} />
${t('roles.form.auto_grant_hint')}
this._patch('attrs', e.target.value)} />
`; } render() { if (!this._open) return nothing; const roles = this._roles ?? []; const loading = this._roles === null; return html`
${this._error && !this._modal ? html`
${this._error}
` : nothing}
${loading ? html`
${t('roles.loading')}
` : roles.length === 0 ? html`

${t('roles.empty')}

` : html` ${roles.map(r => { const isAdmin = r.id === ADMIN_ID; return html` `; })}
${t('roles.col.id')} ${t('roles.col.label')} ${t('roles.col.group')} ${t('roles.col.interface')} ${t('roles.col.assistant')} ${t('roles.col.auto_grant')}
${r.id} ${r.label} ${this._groupLabel(r.permission_group)} ${this._attrsUiMode(r.attrs) === 'simple' ? html`${t('roles.badge.simple')}` : html`${t('roles.badge.full')}`} ${this._agentName(this._attrsChatAgent(r.attrs))} ${isAdmin || this._attrsAutoGrant(r.attrs) ? html`${t('roles.badge.auto_grant_on')}` : html`${t('roles.badge.auto_grant_off')}`}
`}
${this._renderModal()} `; } }