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'; 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'|'edit'|'password', user?, form } }; } constructor() { super(); this._open = false; this._users = null; this._roles = 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 === 'users'; 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 [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(); } catch (e) { this._error = e.message; } } // ── Modal helpers ──────────────────────────────────────────────────────────── _openCreate() { this._modal = { mode: 'create', form: { username: '', display_name: '', role_id: this._roles?.[0]?.id ?? '', password: '', encrypted: false, birthdate: '', sex: '', notes: '' }, }; } _openEdit(user) { this._modal = { mode: 'edit', user, form: { username: user.username, display_name: user.display_name ?? '', role_id: user.role_id, active: user.active, birthdate: user.birthdate ?? '', sex: user.sex ?? '', notes: user.notes ?? '' }, }; } _openPassword(user) { this._modal = { mode: 'password', user, form: { password: '' } }; } _closeModal() { this._modal = null; this._error = null; } _patch(field, value) { this._modal = { ...this._modal, form: { ...this._modal.form, [field]: value } }; } // ── API actions ────────────────────────────────────────────────────────────── async _save() { const { mode, form } = this._modal; this._error = null; if (mode === 'create') { 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; } } else if (mode === 'edit') { const { user } = this._modal; if (!form.username.trim()) { this._error = t('users.error.required_username'); return; } try { const res = await fetch(`/api/users/${user.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: form.username.trim(), display_name: form.display_name.trim() || null, role_id: form.role_id, active: form.active, 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; } } else if (mode === 'password') { const { user } = this._modal; if (!form.password) { this._error = t('users.error.password_empty'); return; } try { const res = await fetch(`/api/users/${user.id}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: form.password }), }); if (!res.ok) throw new Error(await res.text()); this._closeModal(); } catch (e) { this._error = e.message; } } } async _delete(user) { if (!confirm(t('users.confirm.delete', { username: user.username }))) return; try { const res = await fetch(`/api/users/${user.id}`, { method: 'DELETE' }); if (!res.ok) throw new Error(await res.text()); await this._load(); } catch (e) { this._error = e.message; } } // ── Per-user connector access ──────────────────────────────────────────────── async _openConnectors(user) { this._modal = { mode: 'connectors', user, conns: null }; this._error = null; try { const res = await fetch(`/api/users/${user.id}/connectors`); if (!res.ok) throw new Error(await res.text()); const conns = await res.json(); this._modal = { ...this._modal, conns }; } catch (e) { this._error = e.message; } } _toggleConn(idx) { const conns = this._modal.conns.map((c, i) => i === idx ? { ...c, granted: !c.granted } : c); this._modal = { ...this._modal, conns }; } async _saveConnectors() { const { user, conns } = this._modal; const global_ids = conns.filter(c => c.kind === 'global' && c.granted).map(c => c.id); const catalog_names = conns.filter(c => c.kind === 'catalog' && c.granted).map(c => c.name); this._error = null; try { const res = await fetch(`/api/users/${user.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._closeModal(); } catch (e) { this._error = e.message; } } // ── Render ────────────────────────────────────────────────────────────────── _roleLabel(roleId) { return this._roles?.find(r => r.id === roleId)?.label ?? roleId; } _profileFields(form) { return html`
this._patch('birthdate', e.target.value)} />
this._patch('sex', e.target.value)} />
${t('users.modal.notes_hint')}
`; } _renderConnectorsModal() { const { user, conns } = this._modal; const globals = (conns ?? []).filter(c => c.kind === 'global'); const catalog = (conns ?? []).filter(c => c.kind === 'catalog'); const row = (c) => { const idx = this._modal.conns.indexOf(c); return html` `; }; return html`
{ if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}>
${t('users.modal.connectors_title', { username: user.username })}
${this._error ? html`
${this._error}
` : nothing} ${conns === null ? html`
${t('users.loading')}
` : (globals.length === 0 && catalog.length === 0) ? html`

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

` : 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} `}
`; } _renderModal() { if (!this._modal) return nothing; if (this._modal.mode === 'connectors') return this._renderConnectorsModal(); const { mode, form, user } = this._modal; const title = mode === 'create' ? t('users.modal.create_title') : mode === 'edit' ? t('users.modal.edit_title', { username: user.username }) : t('users.modal.reset_title', { username: user.username }); return html`
{ if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}>
${title}
${this._error ? html`
${this._error}
` : nothing} ${mode === 'create' ? html`
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} ` : mode === 'edit' ? html`
this._patch('username', e.target.value)} />
this._patch('display_name', e.target.value)} />
${this._profileFields(form)}
this._patch('active', e.target.checked)} />
` : html`
${t('users.modal.only_cleartext')}
this._patch('password', e.target.value)} />
`}
`; } render() { if (!this._open) return nothing; 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` `)}
${t('users.table.username')} ${t('users.table.display_name')} ${t('users.table.role')} ${t('users.table.db')} ${t('users.table.status')}
${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')}`}
`}
${this._renderModal()} `; } }