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'; import { connectorIconUrl } from './shared/connector-common.js'; // Users admin — the list at `#users`, one user's page at `#users/{id}`. // // The per-user surface used to be four modals (create / edit / password / // connectors). The connectors one collapsed first: a checkbox list taller than the // viewport with no scroll. Same failure the connector activation and manual-add // dialogs had, same fix — a page scrolls, and leaving it is a deliberate // navigation. Edit and password followed, so everything about one user lives in // one place: Profile, Connectors, Plugins, Security. Only **create** stays a modal // — it is three fields and a role, it fits. // // Both grant sections answer the same question — *what may this person use* — so // they read the same way: the Connectors page's row list (icon, name, description), // a chip for anything not enabled instance-wide, and a save that replaces the whole // grant set. Plugins moved here from the plugin's own page for that reason: granted // plugin-by-plugin, "what does this person have?" meant opening every plugin in // turn, and the answer lived on N pages instead of one. // Stable per-user avatar color: same user, same hue, everywhere (same hash as the // topbar avatar — duplicated, it is three lines and the topbar does not export it). function avatarColor(name) { let h = 0; for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0; return `hsl(${h % 360}, 55%, 52%)`; } export class UsersPage extends LightElement { static get properties() { return { _open: { state: true }, _users: { state: true }, _roles: { state: true }, _error: { state: true }, _modal: { state: true }, // null | { mode: 'create', form } _view: { state: true }, // 'list' | 'user' _userId: { state: true }, _dForm: { state: true }, // profile form for the open user _dPw: { state: true }, // security: new-password field _conns: { state: true }, // working copy of the user's connector grants _connQ: { state: true }, _noIcon: { state: true }, // connector names whose icon failed to load _plugs: { state: true }, // working copy of the user's plugin grants _triage: { state: true }, // { interval_minutes, default_interval_minutes } | null _triageIn: { state: true }, // the input's own string ('' = follow the instance default) _busy: { state: true }, _dSaved: { state: true }, // "saved" ticks, one per section _pwSaved: { state: true }, _connSaved: { state: true }, _plugSaved: { state: true }, _trgSaved: { state: true }, }; } constructor() { super(); this._open = false; this._users = null; this._roles = null; this._error = null; this._modal = null; this._noIcon = new Set(); this._resetDetail(); } _resetDetail() { this._view = 'list'; this._userId = null; this._dForm = null; this._dPw = ''; this._conns = null; this._connQ = ''; this._plugs = null; this._triage = null; this._triageIn = ''; this._busy = false; this._dSaved = false; this._pwSaved = false; this._connSaved = false; this._plugSaved = false; this._trgSaved = false; } connectedCallback() { super.connectedCallback(); this.__onLocaleChanged = () => this.requestUpdate(); window.addEventListener('locale-changed', this.__onLocaleChanged); window.addEventListener('llm-page-change', (e) => { this._open = e.detail.page === 'users'; this.style.display = this._open ? 'flex' : 'none'; if (this._open) { this._syncViewFromHash(); this._load(); } }); window.addEventListener('hashchange', () => { if (this._open) this._syncViewFromHash(); }); } disconnectedCallback() { window.removeEventListener('locale-changed', this.__onLocaleChanged); super.disconnectedCallback(); } async _load() { this._error = null; try { const [uRes, rRes] = await Promise.all([ fetch('/api/users'), fetch('/api/roles'), ]); if (!uRes.ok) throw new Error(`Users: HTTP ${uRes.status}`); if (!rRes.ok) throw new Error(`Roles: HTTP ${rRes.status}`); this._users = await uRes.json(); this._roles = await rRes.json(); if (this._view === 'user') this._enterDetail(); } catch (e) { this._error = e.message; } } // ── Routing: `#users` list vs `#users/{id}` detail ────────────────────────── _syncViewFromHash() { const parts = location.hash.slice(1).split('/'); const id = parts[0] === 'users' && parts[1] ? decodeURIComponent(parts[1]) : null; if (!id) { if (this._view !== 'list') this._resetDetail(); return; } if (id !== this._userId || this._view !== 'user') { this._resetDetail(); this._view = 'user'; this._userId = id; if (this._users) this._enterDetail(); } } get _user() { return (this._users ?? []).find(u => u.id === this._userId) ?? null; } async _enterDetail() { const u = this._user; if (!u) { this._error = t('users.detail.no_such'); return; } this._dForm = { username: u.username, display_name: u.display_name ?? '', role_id: u.role_id, active: u.active, birthdate: u.birthdate ?? '', sex: u.sex ?? '', notes: u.notes ?? '', }; this._dPw = ''; try { const [cRes, pRes] = await Promise.all([ fetch(`/api/users/${encodeURIComponent(u.id)}/connectors`), fetch(`/api/users/${encodeURIComponent(u.id)}/plugins`), ]); if (!cRes.ok) throw new Error(await cRes.text()); if (!pRes.ok) throw new Error(await pRes.text()); this._conns = await cRes.json(); this._plugs = await pRes.json(); } catch (e) { this._error = e.message; } // Admin-only, unlike the two above (which a role holding `plugin.manage` can // also reach). A refusal hides the section rather than reddening the page: // there is nothing wrong, this reader simply has no business with schedules. try { const res = await fetch(`/api/users/${encodeURIComponent(u.id)}/event-triage`); if (res.ok) { this._triage = await res.json(); this._triageIn = this._triage.interval_minutes == null ? '' : String(this._triage.interval_minutes); } } catch { /* section stays hidden */ } } _openUser(u) { history.pushState({ page: 'users', user: u.id }, '', `#users/${encodeURIComponent(u.id)}`); this._syncViewFromHash(); } _back() { // Prefer real history so the browser's own Back stays consistent; fall back to // the list when this page was opened straight from a pasted URL. if (history.length > 1) { history.back(); return; } history.pushState({ page: 'users' }, '', '#users'); this._resetDetail(); } _patchD(field, value) { this._dForm = { ...this._dForm, [field]: value }; this._dSaved = false; } _iconFailed(name) { const next = new Set(this._noIcon); next.add(name); this._noIcon = next; } // ── Create (the one remaining modal) ───────────────────────────────────────── _openCreate() { this._modal = { mode: 'create', form: { username: '', display_name: '', role_id: this._roles?.[0]?.id ?? '', password: '', encrypted: false, birthdate: '', sex: '', notes: '' }, }; } _closeModal() { this._modal = null; this._error = null; } _patch(field, value) { this._modal = { ...this._modal, form: { ...this._modal.form, [field]: value } }; } async _saveCreate() { const { form } = this._modal; this._error = null; if (!form.username.trim() || !form.password) { this._error = t('users.error.required_username_pw'); return; } try { const res = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: form.username.trim(), display_name: form.display_name.trim() || null, role_id: form.role_id, password: form.password, encrypted: form.encrypted, birthdate: form.birthdate || null, sex: form.sex.trim() || null, notes: form.notes.trim() || null, }), }); if (!res.ok) throw new Error(await res.text()); this._closeModal(); await this._load(); } catch (e) { this._error = e.message; } } // ── Detail: profile ─────────────────────────────────────────────────────────── async _saveProfile() { const u = this._user; const f = this._dForm; if (!f.username.trim()) { this._error = t('users.error.required_username'); return; } this._busy = true; this._error = null; try { const res = await fetch(`/api/users/${encodeURIComponent(u.id)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: f.username.trim(), display_name: f.display_name.trim() || null, role_id: f.role_id, active: f.active, birthdate: f.birthdate || null, sex: f.sex.trim() || null, notes: f.notes.trim() || null, }), }); if (!res.ok) throw new Error(await res.text()); await this._load(); this._dSaved = true; } catch (e) { this._error = e.message; } finally { this._busy = false; } } // ── Detail: connectors ─────────────────────────────────────────────────────── _toggleConn(idx) { this._conns = this._conns.map((c, i) => i === idx ? { ...c, granted: !c.granted } : c); this._connSaved = false; } async _saveConnectors() { const u = this._user; const global_ids = this._conns.filter(c => c.kind === 'global' && c.granted).map(c => c.id); const catalog_names = this._conns.filter(c => c.kind === 'catalog' && c.granted).map(c => c.name); this._busy = true; this._error = null; try { const res = await fetch(`/api/users/${encodeURIComponent(u.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._connSaved = true; } catch (e) { this._error = e.message; } finally { this._busy = false; } } // ── Detail: plugins ────────────────────────────────────────────────────────── _togglePlug(idx) { this._plugs = this._plugs.map((p, i) => i === idx ? { ...p, granted: !p.granted } : p); this._plugSaved = false; } async _savePlugins() { const u = this._user; const plugin_ids = this._plugs.filter(p => p.granted).map(p => p.id); this._busy = true; this._error = null; try { const res = await fetch(`/api/users/${encodeURIComponent(u.id)}/plugins`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ plugin_ids }), }); if (!res.ok) throw new Error(await res.text()); this._plugSaved = true; } catch (e) { this._error = e.message; } finally { this._busy = false; } } // ── Detail: event-triage schedule ──────────────────────────────────────────── async _saveTriage() { const u = this._user; const raw = this._triageIn.trim(); // Empty is a value, not a missing one: it clears the override and puts this // person back on the instance schedule. Hence `null` rather than an omitted // field, and hence no "use default" checkbox — the empty box says it. let interval_minutes = null; if (raw !== '') { const n = Number(raw); if (!Number.isInteger(n) || n < 1) { this._error = t('users.triage.invalid'); return; } interval_minutes = n; } this._busy = true; this._error = null; try { const res = await fetch(`/api/users/${encodeURIComponent(u.id)}/event-triage`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ interval_minutes }), }); if (!res.ok) throw new Error(await res.text()); this._triage = await res.json(); this._trgSaved = true; } catch (e) { this._error = e.message; } finally { this._busy = false; } } // ── Detail: security ────────────────────────────────────────────────────────── async _resetPassword() { const u = this._user; if (!this._dPw) { this._error = t('users.error.password_empty'); return; } this._busy = true; this._error = null; try { const res = await fetch(`/api/users/${encodeURIComponent(u.id)}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: this._dPw }), }); if (!res.ok) throw new Error(await res.text()); this._dPw = ''; this._pwSaved = true; } catch (e) { this._error = e.message; } finally { this._busy = false; } } async _deleteUser() { const u = this._user; if (!confirm(t('users.confirm.delete', { username: u.username }))) return; this._busy = true; this._error = null; try { const res = await fetch(`/api/users/${encodeURIComponent(u.id)}`, { method: 'DELETE' }); if (!res.ok) throw new Error(await res.text()); history.pushState({ page: 'users' }, '', '#users'); this._resetDetail(); await this._load(); } catch (e) { this._error = e.message; this._busy = false; } } // ── Render ────────────────────────────────────────────────────────────────── render() { if (!this._open) return nothing; return html` ${this._view === 'user' ? this._renderDetail() : this._renderList()} ${this._renderCreateModal()} `; } _renderList() { const users = this._users ?? []; const loading = this._users === null; return html`
${this._error && !this._modal ? html`
${this._error}
` : nothing}
${loading ? html`
${t('users.loading')}
` : users.length === 0 ? html`

${t('users.empty')}

` : html` ${users.map(u => html` this._openUser(u)}> `)}
${t('users.table.username')} ${t('users.table.display_name')} ${t('users.table.role')} ${t('users.table.db')} ${t('users.table.status')}
${(u.display_name || u.username).charAt(0).toUpperCase()} ${u.username}
${u.display_name ?? '—'} ${this._roleLabel(u.role_id)} ${u.encrypted ? html`${t('users.badge.encrypted')}` : html`${t('users.badge.cleartext')}`} ${u.active ? html`${t('users.badge.active')}` : html`${t('users.badge.inactive')}`}
`}
`; } _roleLabel(roleId) { return this._roles?.find(r => r.id === roleId)?.label ?? roleId; } // ── Render: the user's own page ──────────────────────────────────────────── _renderDetail() { const u = this._user; if (!u || !this._dForm) { return html`
${this._renderDetailHeader(null)} ${this._error ? html`
${this._error}
` : html`
${t('users.loading')}
`}
`; } return html`
${this._renderDetailHeader(u)}
${this._error ? html`
${this._error}
` : nothing} ${this._renderProfile(u)} ${this._renderConnectors(u)} ${this._renderPlugins(u)} ${this._renderTriage(u)} ${this._renderSecurity(u)}
`; } _renderDetailHeader(u) { return html` `; } _renderProfile(u) { const f = this._dForm; return html`

${t('users.detail.profile')}

this._patchD('username', e.target.value)} />
this._patchD('display_name', e.target.value)} />
this._patchD('birthdate', e.target.value)} />
this._patchD('sex', e.target.value)} />
${t('users.modal.notes_hint')}
this._patchD('active', e.target.checked)} />
${this._dSaved ? html`${t('users.detail.saved')}` : nothing}
`; } _renderConnectors(u) { const conns = this._conns; const q = this._connQ.trim().toLowerCase(); const visible = (conns ?? []).filter(c => !q || c.name.toLowerCase().includes(q) || (c.friendly_name ?? '').toLowerCase().includes(q) || (c.description ?? '').toLowerCase().includes(q)); const globals = visible.filter(c => c.kind === 'global'); const catalog = visible.filter(c => c.kind === 'catalog'); const row = (c) => { const idx = this._conns.indexOf(c); const showIcon = !this._noIcon.has(c.name); return html` `; }; return html`

${t('users.detail.connectors')}

${conns === null ? html`
${t('users.loading')}
` : conns.length === 0 ? html`

${t('users.conn.empty')}

` : html`
${visible.length === 0 ? html`

${t('connectors.empty.match', { query: this._connQ })}

` : html` ${globals.length ? html`
${t('users.conn.globals')}
${t('users.conn.hint_global')}
${globals.map(row)}
` : nothing} ${catalog.length ? html`
${t('users.conn.catalog')}
${t('users.conn.hint_catalog')}
${catalog.map(row)}
` : nothing}`}
${this._connSaved ? html`${t('users.detail.saved')}` : nothing}
`}
`; } // Deliberately unfiltered, unlike the connectors above: a plugin ships in the // binary, so the list is short and a search box over it is furniture. Plugins // that gate access through their own pairing (Mobile Connector) never reach // here — the server omits them, since a checkbox would control nothing. _renderPlugins(u) { const plugs = this._plugs; // An admin holds every enabled plugin implicitly (`list_accessible` short- // circuits on the role), so unticked boxes here would read as "no access". const isAdmin = u.role_id === 'admin'; return html`

${t('users.detail.plugins')}

${isAdmin ? html`
${t('users.plug.admin_note')}
` : nothing} ${plugs === null ? html`
${t('users.loading')}
` : plugs.length === 0 ? html`

${t('users.plug.empty')}

` : html`
${t('users.plug.hint')}
${plugs.map((p, i) => html` `)}
${this._plugSaved ? html`${t('users.detail.saved')}` : nothing}
`}
`; } // The one *schedule* on this page, and the only agent that gets one: event // triage fires on inbound events, so how often it runs is a fact about the // person, not about the instance. Someone on a dozen mailing lists is triaged // on nearly every tick. _renderTriage(u) { if (!this._triage) return nothing; // not an admin, or the fetch failed const def = this._triage.default_interval_minutes; return html`

${t('users.detail.triage')}

${t('users.triage.hint')}
{ this._triageIn = e.target.value; this._trgSaved = false; }} />
${this._triageIn.trim() === '' ? t('users.triage.using_default', { n: def }) : t('users.triage.using_override')}
${this._trgSaved ? html`${t('users.detail.saved')}` : nothing}
`; } _renderSecurity(u) { return html`

${t('users.detail.security')}

${u.encrypted ? html`
${t('users.modal.only_cleartext')}
` : html`
{ this._dPw = e.target.value; this._pwSaved = false; }} />
${this._pwSaved ? html`${t('users.detail.saved')}` : nothing}
`}
${t('users.detail.delete_hint')}
`; } // ── Render: create modal ───────────────────────────────────────────────────── _profileFields(form) { return html`
this._patch('birthdate', e.target.value)} />
this._patch('sex', e.target.value)} />
${t('users.modal.notes_hint')}
`; } _renderCreateModal() { if (!this._modal) return nothing; const { form } = this._modal; return html`
{ if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}>
${t('users.modal.create_title')}
${this._error ? html`
${this._error}
` : nothing}
this._patch('username', e.target.value)} />
this._patch('display_name', e.target.value)} />
${this._profileFields(form)}
this._patch('password', e.target.value)} />
this._patch('encrypted', e.target.checked)} />
${form.encrypted ? html`
${unsafeHTML(t('users.modal.encrypt_warn'))}
` : nothing}
`; } }