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'; export class RolesPage extends LightElement { static get properties() { return { _open: { state: true }, _roles: { state: true }, _groups: { 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._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] = await Promise.all([ fetch('/api/roles'), fetch('/api/tool-permission-groups'), ]); if (!rRes.ok) throw new Error(`HTTP ${rRes.status}`); if (!gRes.ok) throw new Error(`HTTP ${gRes.status}`); this._roles = await rRes.json(); this._groups = await gRes.json(); } 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 []; } } _mergeAttrs(attrs, uiMode, allowedGroups) { 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; 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: [] }, }; } _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) }, }; } _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), }), }); 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), }), }); 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'))}
this._patch('attrs', e.target.value)} />
`; } render() { if (!this._open) return nothing; const roles = this._roles ?? []; const loading = this._roles === null; return html`

${t('roles.title')}

${roles.length === 1 ? t('roles.count', { n: roles.length }) : t('roles.count_plural', { n: roles.length })}
${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')}
${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._renderModal()} `; } }