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, Security. Only **create** stays a modal — it is // three fields and a role, it fits. // // The connectors section mirrors the Connectors page's row list (icon, name, // description) so the admin reads one vocabulary everywhere; saving replaces the // whole grant set, like the plugin-detail access checklist. // 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 _busy: { state: true }, _dSaved: { state: true }, // "saved" ticks, one per section _pwSaved: { state: true }, _connSaved: { 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._busy = false; this._dSaved = false; this._pwSaved = false; this._connSaved = 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 res = await fetch(`/api/users/${encodeURIComponent(u.id)}/connectors`); if (!res.ok) throw new Error(await res.text()); this._conns = await res.json(); } catch (e) { this._error = e.message; } } _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: 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`
${t('users.empty')}
| ${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')}`} |
${u.username}
·
${this._roleLabel(u.role_id)}
${u.active ? nothing : html`${t('users.badge.inactive')}`}
${t('users.conn.empty')}
${t('connectors.empty.match', { query: this._connQ })}