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.title')}

${t(users.length === 1 ? 'users.count_one' : 'users.count_other', { n: users.length })}
${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._renderSecurity(u)}
`; } _renderDetailHeader(u) { return html`
${u ? html` ${(u.display_name || u.username).charAt(0).toUpperCase()}

${u.display_name || u.username}

${u.username} · ${this._roleLabel(u.role_id)} ${u.active ? nothing : html`${t('users.badge.inactive')}`}
` : html`

${t('users.title')}

`}
`; } _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}
`}
`; } _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}
`; } }