import { html, nothing } from 'lit'; import { LightElement } from '../lib/base.js'; export class ApprovalGroupsPage extends LightElement { static properties = { _open: { state: true }, _groups: { state: true }, _rules: { state: true }, _error: { state: true }, _groupEditId: { state: true }, _groupForm: { state: true }, _groupSaving: { state: true }, _duplicateOf: { state: true }, // group being duplicated, or null _dupForm: { state: true }, // { id, name } _dupSaving: { state: true }, }; constructor() { super(); this._open = false; this._groups = []; this._rules = []; this._error = null; this._groupEditId = null; this._groupForm = { id: '', name: '', description: '' }; this._groupSaving = false; this._duplicateOf = null; this._dupForm = { id: '', name: '' }; this._dupSaving = false; } connectedCallback() { super.connectedCallback(); window.addEventListener('llm-page-change', async (e) => { this._open = e.detail.page === 'approval'; this.style.display = this._open ? 'flex' : 'none'; if (!this._open) return; await this._load(); const match = window.location.hash.match(/^#approval\/(.+)$/); if (match) { const group = this._groups.find(g => g.id === decodeURIComponent(match[1])); if (group) { this._navigateTo(group); return; } } }); window.addEventListener('approval-navigate', (e) => { if (e.detail.group !== null) return; // Returning from rules view — show groups again this._open = true; this.style.display = 'flex'; this._load(); }); window.addEventListener('hashchange', () => { if (!this._open) return; const match = window.location.hash.match(/^#approval\/(.+)$/); if (!match) return; const group = this._groups.find(g => g.id === decodeURIComponent(match[1])); if (group) this._navigateTo(group); }); } async _load() { this._error = null; try { const [gRes, rRes] = await Promise.all([ fetch('/api/tool-permission-groups'), fetch('/api/approval/rules'), ]); if (!gRes.ok) throw new Error(`Groups: HTTP ${gRes.status}`); if (!rRes.ok) throw new Error(`Rules: HTTP ${rRes.status}`); const groups = await gRes.json(); this._groups = groups.sort((a, b) => { if (a.id === 'default') return -1; if (b.id === 'default') return 1; return a.name.localeCompare(b.name); }); this._rules = await rRes.json(); } catch (e) { this._error = e.message; } } _rulesForGroup(groupId) { return this._rules.filter(r => (r.group_id ?? 'default') === groupId); } // ── Navigation ──────────────────────────────────────────────────────────────── _navigateTo(group) { this._open = false; this.style.display = 'none'; window.location.hash = `approval/${group.id}`; window.dispatchEvent(new CustomEvent('approval-navigate', { detail: { group } })); } // ── Group management ────────────────────────────────────────────────────────── _startNewGroup() { this._groupEditId = 'new'; this._groupForm = { id: '', name: '', description: '' }; this._duplicateOf = null; } _startEditGroup(group) { this._groupEditId = group.id; this._groupForm = { id: group.id, name: group.name, description: group.description ?? '' }; this._duplicateOf = null; } _cancelGroupEdit() { this._groupEditId = null; } _patchGroup(field, value) { this._groupForm = { ...this._groupForm, [field]: value }; } async _saveGroup() { const isNew = this._groupEditId === 'new'; if (!this._groupForm.name.trim()) { this._error = 'Group name is required.'; return; } if (isNew && !this._groupForm.id.trim()) { this._error = 'Group ID is required.'; return; } this._groupSaving = true; this._error = null; try { const body = isNew ? { id: this._groupForm.id.trim(), name: this._groupForm.name.trim(), description: this._groupForm.description.trim() || null } : { name: this._groupForm.name.trim(), description: this._groupForm.description.trim() || null }; const url = isNew ? '/api/tool-permission-groups' : `/api/tool-permission-groups/${this._groupEditId}`; const res = await fetch(url, { method: isNew ? 'POST' : 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(await res.text()); this._groupEditId = null; await this._load(); } catch (e) { this._error = e.message; } finally { this._groupSaving = false; } } async _deleteGroup(group) { const count = this._rulesForGroup(group.id).length; const msg = count > 0 ? `Delete group "${group.name}" and its ${count} rule${count === 1 ? '' : 's'}?` : `Delete group "${group.name}"?`; if (!confirm(msg)) return; try { const res = await fetch(`/api/tool-permission-groups/${group.id}`, { method: 'DELETE' }); if (!res.ok) throw new Error(await res.text()); await this._load(); } catch (e) { this._error = e.message; } } // ── Duplicate group ─────────────────────────────────────────────────────────── _startDuplicate(group) { this._duplicateOf = group; this._dupForm = { id: `${group.id}_copy`, name: `Copy of ${group.name}`, }; this._groupEditId = null; // close any open create/rename form } _cancelDuplicate() { this._duplicateOf = null; } async _saveDuplicate() { if (!this._dupForm.name.trim()) { this._error = 'Name is required.'; return; } if (!this._dupForm.id.trim()) { this._error = 'ID is required.'; return; } this._dupSaving = true; this._error = null; try { const res = await fetch(`/api/tool-permission-groups/${this._duplicateOf.id}/duplicate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: this._dupForm.id.trim(), name: this._dupForm.name.trim() }), }); if (!res.ok) throw new Error(await res.text()); this._duplicateOf = null; await this._load(); } catch (e) { this._error = e.message; } finally { this._dupSaving = false; } } // ── Group form ──────────────────────────────────────────────────────────────── _renderGroupForm() { const isNew = this._groupEditId === 'new'; const f = this._groupForm; return html`
${isNew ? 'New group' : 'Rename group'}
${isNew ? html`
this._patchGroup('id', e.target.value)} />
Lowercase slug, no spaces. Cannot be changed later.
` : nothing}
this._patchGroup('name', e.target.value)} />
this._patchGroup('description', e.target.value)} />
`; } // ── Duplicate form ──────────────────────────────────────────────────────────── _renderDuplicateForm() { const src = this._duplicateOf; const f = this._dupForm; return html`
Duplicate ${src.name}
{ this._dupForm = { ...this._dupForm, name: e.target.value }; }} />
{ this._dupForm = { ...this._dupForm, id: e.target.value }; }} />
Lowercase slug, no spaces. Cannot be changed later.
All ${this._rulesForGroup(src.id).length} rule${this._rulesForGroup(src.id).length === 1 ? '' : 's'} from ${src.name} will be copied.
`; } // ── Group card ──────────────────────────────────────────────────────────────── _renderGroupCard(group) { const count = this._rulesForGroup(group.id).length; const isDefault = group.id === 'default'; return html`
this._navigateTo(group)}>
${isDefault ? html`Default` : nothing} ${group.name} ${count}
e.stopPropagation()}>
${group.description ? html`
${group.description}
` : nothing}
`; } // ── Groups view ─────────────────────────────────────────────────────────────── render() { return html`

Security

${this._groups.length} group${this._groups.length === 1 ? '' : 's'}

Permission groups are named sets of approval rules. A session's active Agent Profile determines which group applies — that group's rules are evaluated first, with the Default group as fallback.

Click a group to view and manage its rules. The Default group cannot be deleted, but its rules can be edited freely.

${this._error ? html`
${this._error}
` : nothing} ${this._groupEditId !== null ? this._renderGroupForm() : nothing} ${this._duplicateOf !== null ? this._renderDuplicateForm() : nothing}
${this._groups.length === 0 ? html`

No groups yet.

` : this._groups.map(g => this._renderGroupCard(g))}
`; } }