users: turn the four modals into a per-user page at #users/{id}
Nightly Build / build (push) Successful in 6m58s
Nightly Build / build (push) Successful in 6m58s
The connectors-assignment dialog was the fourth modal on the Users page and the first to break: a checkbox list taller than the viewport with no scroll. Same failure the connector activation and manual-add dialogs had, same fix — a page. The list stays a table, but rows are clickable and open the user's own page with three sections: - Profile: the old edit form (username, display name, role, directory fields, active switch) with a saved tick; - Connectors: the grant checklist as the Connectors page's row list (icon, name, description, search, global/personal groups), one Save; - Security: password reset (disabled with an explanation for encrypted users) and the delete action. Only user creation stays a modal — three fields and a role fit.
This commit is contained in:
@@ -395,7 +395,7 @@ The role editor (`roles-page.js`) sets the default group + an allowed-groups che
|
||||
| `system-agents.js` | `<system-agents-page>` | `#system-agents` — the caller's own run history for the background system agents (TIC): agent, start, status, duration, counters; row → the run's session |
|
||||
| `shared-folders.js` | `<shared-folders-page>` | `#shared-folders` — admin-only CRUD for on-disk shared folders (§6): create/describe/delete + per-member read-only/read-write grants; description feeds the assistant's `__SHARED_FOLDERS__` context |
|
||||
| `projects/` | `<projects-page>` | `#projects` — host + list + board; the board is tabbed (**Files** explorer with live watcher + write actions, **Sharing** members), deep-linked `#projects/{id}[/sharing]`. See the Projects section |
|
||||
| `connector-detail.js` | `<connector-detail-page>` | A connector's own page (`#connector?name=X`): env/secret form + Test, the **OAuth login panel** (sign in → paste code → complete, §15), global enable. Access grants live **only** on the Users page (`users-page.js` connectors modal), so "who has what" has a single surface |
|
||||
| `connector-detail.js` | `<connector-detail-page>` | A connector's own page (`#connector?name=X`): env/secret form + Test, the **OAuth login panel** (sign in → paste code → complete, §15), global enable. Access grants live **only** on the Users page (`users-page.js` — the `#users/{id}` page's connectors section), so "who has what" has a single surface |
|
||||
| `shared/connector-common.js` | (helpers) | Shared Connectors vocabulary: `statusOf` (incl. `needs_login` for a pending OAuth row), `STATUS_LABEL`, schema normalization, `jf` fetch |
|
||||
| `llm-providers.js` | `<llm-providers-page>` | LLM provider management |
|
||||
| `models-hub.js` | `<models-hub-page>` | Models hub landing (LLM / Transcription / Image) |
|
||||
|
||||
+522
-305
@@ -2,16 +2,50 @@ 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'|'edit'|'password', user?, form }
|
||||
_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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,6 +56,21 @@ export class UsersPage extends LightElement {
|
||||
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() {
|
||||
@@ -31,7 +80,10 @@ export class UsersPage extends LightElement {
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === 'users';
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
if (this._open) this._load();
|
||||
if (this._open) { this._syncViewFromHash(); this._load(); }
|
||||
});
|
||||
window.addEventListener('hashchange', () => {
|
||||
if (this._open) this._syncViewFromHash();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -51,12 +103,71 @@ export class UsersPage extends LightElement {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Modal helpers ────────────────────────────────────────────────────────────
|
||||
// ── 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 = {
|
||||
@@ -65,136 +176,414 @@ export class UsersPage extends LightElement {
|
||||
};
|
||||
}
|
||||
|
||||
_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;
|
||||
async _saveCreate() {
|
||||
const { 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;
|
||||
if (!form.username.trim() || !form.password) { this._error = t('users.error.required_username_pw'); return; }
|
||||
try {
|
||||
const res = await fetch(`/api/users/${user.id}`, { method: 'DELETE' });
|
||||
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; }
|
||||
}
|
||||
|
||||
// ── Per-user connector access ────────────────────────────────────────────────
|
||||
// ── Detail: profile ───────────────────────────────────────────────────────────
|
||||
|
||||
async _openConnectors(user) {
|
||||
this._modal = { mode: 'connectors', user, conns: null };
|
||||
this._error = null;
|
||||
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/${user.id}/connectors`);
|
||||
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());
|
||||
const conns = await res.json();
|
||||
this._modal = { ...this._modal, conns };
|
||||
await this._load();
|
||||
this._dSaved = true;
|
||||
} catch (e) { this._error = e.message; }
|
||||
finally { this._busy = false; }
|
||||
}
|
||||
|
||||
// ── Detail: connectors ───────────────────────────────────────────────────────
|
||||
|
||||
_toggleConn(idx) {
|
||||
const conns = this._modal.conns.map((c, i) => i === idx ? { ...c, granted: !c.granted } : c);
|
||||
this._modal = { ...this._modal, conns };
|
||||
this._conns = this._conns.map((c, i) => i === idx ? { ...c, granted: !c.granted } : c);
|
||||
this._connSaved = false;
|
||||
}
|
||||
|
||||
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;
|
||||
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/${user.id}/connectors`, {
|
||||
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._closeModal();
|
||||
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`
|
||||
<div class="um-page">
|
||||
<div class="um-header">
|
||||
<h2 class="um-title"><i class="bi bi-people-fill me-2"></i>${t('users.title')}</h2>
|
||||
<div class="um-header-right">
|
||||
<span class="um-header-count">${t(users.length === 1 ? 'users.count_one' : 'users.count_other', { n: users.length })}</span>
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._openCreate()}>
|
||||
<i class="bi bi-plus-lg me-1"></i>${t('users.btn.new')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._error && !this._modal ? html`
|
||||
<div class="alert alert-danger py-2 mx-4" style="font-size:.85rem">${this._error}</div>` : nothing}
|
||||
|
||||
<div class="um-table-wrap">
|
||||
${loading ? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> ${t('users.loading')}</div>` : users.length === 0 ? html`
|
||||
<div class="um-empty"><i class="bi bi-people"></i><p>${t('users.empty')}</p></div>
|
||||
` : html`
|
||||
<table class="um-table um-table-clickable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>${t('users.table.username')}</th>
|
||||
<th>${t('users.table.display_name')}</th>
|
||||
<th>${t('users.table.role')}</th>
|
||||
<th>${t('users.table.db')}</th>
|
||||
<th>${t('users.table.status')}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${users.map(u => html`
|
||||
<tr @click=${() => this._openUser(u)}>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="ud-avatar ud-avatar--sm" style="background:${avatarColor(u.username)}">
|
||||
${(u.display_name || u.username).charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<strong>${u.username}</strong>
|
||||
</div>
|
||||
</td>
|
||||
<td>${u.display_name ?? '—'}</td>
|
||||
<td>${this._roleLabel(u.role_id)}</td>
|
||||
<td>${u.encrypted
|
||||
? html`<span class="um-badge um-badge-encrypted">${t('users.badge.encrypted')}</span>`
|
||||
: html`<span class="um-badge um-badge-clear">${t('users.badge.cleartext')}</span>`}</td>
|
||||
<td>${u.active
|
||||
? html`<span class="um-badge um-badge-active">${t('users.badge.active')}</span>`
|
||||
: html`<span class="um-badge um-badge-inactive">${t('users.badge.inactive')}</span>`}</td>
|
||||
<td style="width:1rem"><i class="bi bi-chevron-right text-muted" style="font-size:.75rem"></i></td>
|
||||
</tr>
|
||||
`)}
|
||||
</tbody>
|
||||
</table>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_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`
|
||||
<div class="um-page">
|
||||
${this._renderDetailHeader(null)}
|
||||
${this._error
|
||||
? html`<div class="alert alert-danger py-2 mx-4" style="font-size:.85rem">${this._error}</div>`
|
||||
: html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> ${t('users.loading')}</div>`}
|
||||
</div>`;
|
||||
}
|
||||
return html`
|
||||
<div class="um-page">
|
||||
${this._renderDetailHeader(u)}
|
||||
<div style="padding:0 1.25rem 2rem; overflow:auto; max-width:860px">
|
||||
${this._error ? html`
|
||||
<div class="alert alert-danger py-2 mb-3" style="font-size:.85rem">${this._error}</div>` : nothing}
|
||||
${this._renderProfile(u)}
|
||||
${this._renderConnectors(u)}
|
||||
${this._renderSecurity(u)}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_renderDetailHeader(u) {
|
||||
return html`
|
||||
<div class="um-header">
|
||||
<div class="d-flex align-items-center gap-2" style="min-width:0">
|
||||
<button class="btn btn-sm btn-outline-secondary" title=${t('users.detail.back')} @click=${() => this._back()}>
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</button>
|
||||
${u ? html`
|
||||
<span class="ud-avatar" style="background:${avatarColor(u.username)}">
|
||||
${(u.display_name || u.username).charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<div style="min-width:0">
|
||||
<h2 class="um-title" style="min-width:0;overflow:hidden;text-overflow:ellipsis">
|
||||
${u.display_name || u.username}</h2>
|
||||
<div class="d-flex align-items-center gap-2" style="font-size:.72rem">
|
||||
<code class="text-muted">${u.username}</code>
|
||||
<span class="text-muted">·</span>
|
||||
<span class="text-muted">${this._roleLabel(u.role_id)}</span>
|
||||
${u.active ? nothing : html`<span class="um-badge um-badge-inactive">${t('users.badge.inactive')}</span>`}
|
||||
</div>
|
||||
</div>`
|
||||
: html`<h2 class="um-title">${t('users.title')}</h2>`}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_renderProfile(u) {
|
||||
const f = this._dForm;
|
||||
return html`
|
||||
<div class="ud-section">
|
||||
<h3 class="ud-section-title"><i class="bi bi-person me-2"></i>${t('users.detail.profile')}</h3>
|
||||
<div class="connector-card">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">${t('users.modal.username')}</label>
|
||||
<input class="form-control form-control-sm" .value=${f.username} @input=${e => this._patchD('username', e.target.value)} />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">${t('users.modal.display_name')} <span class="text-muted">${t('users.modal.optional')}</span></label>
|
||||
<input class="form-control form-control-sm" .value=${f.display_name} @input=${e => this._patchD('display_name', e.target.value)} />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">${t('users.modal.role')}</label>
|
||||
<select class="form-select form-select-sm" @change=${e => this._patchD('role_id', e.target.value)}>
|
||||
${(this._roles ?? []).map(r => html`<option value=${r.id} ?selected=${f.role_id === r.id}>${r.label}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">${t('users.modal.birthdate')} <span class="text-muted">${t('users.modal.optional')}</span></label>
|
||||
<input type="date" class="form-control form-control-sm" .value=${f.birthdate} @input=${e => this._patchD('birthdate', e.target.value)} />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">${t('users.modal.sex')} <span class="text-muted">${t('users.modal.optional')}</span></label>
|
||||
<input class="form-control form-control-sm" .value=${f.sex} @input=${e => this._patchD('sex', e.target.value)} />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">${t('users.modal.notes')} <span class="text-muted">${t('users.modal.optional')}</span></label>
|
||||
<textarea class="form-control form-control-sm" rows="3" .value=${f.notes} @input=${e => this._patchD('notes', e.target.value)}></textarea>
|
||||
<div class="form-text">${t('users.modal.notes_hint')}</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="ud-active"
|
||||
.checked=${f.active} @change=${e => this._patchD('active', e.target.checked)} />
|
||||
<label class="form-check-label" for="ud-active">${t('users.modal.active')}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 mt-1">
|
||||
<button class="btn btn-sm btn-primary" ?disabled=${this._busy} @click=${() => this._saveProfile()}>
|
||||
<i class="bi bi-check-lg me-1"></i>${t('users.modal.save_btn')}
|
||||
</button>
|
||||
${this._dSaved ? html`<span class="ud-saved"><i class="bi bi-check2"></i>${t('users.detail.saved')}</span>` : nothing}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_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`
|
||||
<label class="connector-row" style="cursor:pointer">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
.checked=${c.granted} @change=${() => this._toggleConn(idx)} />
|
||||
${showIcon
|
||||
? html`<img class="connector-card-icon" src=${connectorIconUrl(c.name, 'sm')} alt=""
|
||||
@error=${() => this._iconFailed(c.name)} />`
|
||||
: html`<div class="connector-card-icon connector-card-icon--empty"><i class="bi bi-plug"></i></div>`}
|
||||
<div class="connector-row-main">
|
||||
<div class="connector-row-name">
|
||||
<span>${c.friendly_name || c.name}</span>
|
||||
${c.friendly_name ? html`<span class="connector-row-sub">${c.name}</span>` : nothing}
|
||||
</div>
|
||||
${c.description ? html`<div class="connector-row-desc">${c.description}</div>` : nothing}
|
||||
</div>
|
||||
<div class="connector-row-chips">
|
||||
${c.kind === 'global' ? html`
|
||||
<span class="connector-chip"><i class="bi bi-globe"></i>${t('connectors.chip.global')}</span>` : nothing}
|
||||
${!c.enabled ? html`
|
||||
<span class="connector-chip"><i class="bi bi-pause-circle"></i>${t('users.conn.disabled')}</span>` : nothing}
|
||||
</div>
|
||||
</label>`;
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="ud-section">
|
||||
<h3 class="ud-section-title"><i class="bi bi-plug me-2"></i>${t('users.detail.connectors')}</h3>
|
||||
${conns === null
|
||||
? html`<div class="um-empty" style="padding:1rem"><i class="bi bi-hourglass-split"></i> ${t('users.loading')}</div>`
|
||||
: conns.length === 0
|
||||
? html`<div class="um-empty" style="padding:1rem"><i class="bi bi-plug"></i><p>${t('users.conn.empty')}</p></div>`
|
||||
: html`
|
||||
<div class="connector-filters">
|
||||
<div class="connector-search">
|
||||
<i class="bi bi-search"></i>
|
||||
<input class="form-control form-control-sm" placeholder=${t('connectors.search')}
|
||||
.value=${this._connQ} @input=${(e) => { this._connQ = e.target.value; }} />
|
||||
</div>
|
||||
</div>
|
||||
${visible.length === 0
|
||||
? html`<div class="um-empty" style="padding:1rem"><i class="bi bi-search"></i>
|
||||
<p>${t('connectors.empty.match', { query: this._connQ })}</p></div>`
|
||||
: html`
|
||||
${globals.length ? html`
|
||||
<div class="ud-conn-group-title">${t('users.conn.globals')}</div>
|
||||
<div class="form-text mb-2" style="font-size:.75rem">${t('users.conn.hint_global')}</div>
|
||||
<div class="connector-list" style="margin-bottom:1rem">${globals.map(row)}</div>` : nothing}
|
||||
${catalog.length ? html`
|
||||
<div class="ud-conn-group-title">${t('users.conn.catalog')}</div>
|
||||
<div class="form-text mb-2" style="font-size:.75rem">${t('users.conn.hint_catalog')}</div>
|
||||
<div class="connector-list">${catalog.map(row)}</div>` : nothing}`}
|
||||
<div class="d-flex align-items-center gap-2 mt-3">
|
||||
<button class="btn btn-sm btn-primary" ?disabled=${this._busy} @click=${() => this._saveConnectors()}>
|
||||
<i class="bi bi-check-lg me-1"></i>${t('users.modal.save_btn')}
|
||||
</button>
|
||||
${this._connSaved ? html`<span class="ud-saved"><i class="bi bi-check2"></i>${t('users.detail.saved')}</span>` : nothing}
|
||||
</div>`}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_renderSecurity(u) {
|
||||
return html`
|
||||
<div class="ud-section">
|
||||
<h3 class="ud-section-title"><i class="bi bi-shield-lock me-2"></i>${t('users.detail.security')}</h3>
|
||||
<div class="connector-card">
|
||||
${u.encrypted ? html`
|
||||
<div class="alert alert-warning py-2 mb-0" style="font-size:.82rem">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>${t('users.modal.only_cleartext')}
|
||||
</div>` : html`
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">${t('users.modal.new_password')}</label>
|
||||
<input type="password" class="form-control form-control-sm" .value=${this._dPw}
|
||||
@input=${(e) => { this._dPw = e.target.value; this._pwSaved = false; }} />
|
||||
</div>
|
||||
<div class="col-auto d-flex align-items-center gap-2">
|
||||
<button class="btn btn-sm btn-outline-secondary" ?disabled=${this._busy || !this._dPw}
|
||||
@click=${() => this._resetPassword()}>
|
||||
<i class="bi bi-key me-1"></i>${t('users.modal.reset_btn')}
|
||||
</button>
|
||||
${this._pwSaved ? html`<span class="ud-saved"><i class="bi bi-check2"></i>${t('users.detail.saved')}</span>` : nothing}
|
||||
</div>
|
||||
</div>`}
|
||||
</div>
|
||||
|
||||
<div class="connector-card ud-danger">
|
||||
<div class="d-flex align-items-center justify-content-between gap-3 flex-wrap">
|
||||
<div style="min-width:0;font-size:.8rem" class="text-muted">${t('users.detail.delete_hint')}</div>
|
||||
<button class="btn btn-sm btn-outline-danger" ?disabled=${this._busy} @click=${() => this._deleteUser()}>
|
||||
<i class="bi bi-trash me-1"></i>${t('users.action.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Render: create modal ─────────────────────────────────────────────────────
|
||||
|
||||
_profileFields(form) {
|
||||
return html`
|
||||
<div class="mb-3">
|
||||
@@ -213,228 +602,56 @@ export class UsersPage extends LightElement {
|
||||
`;
|
||||
}
|
||||
|
||||
_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`
|
||||
<label class="um-conn-row">
|
||||
<input class="form-check-input" type="checkbox" .checked=${c.granted} @change=${() => this._toggleConn(idx)} />
|
||||
<span class="um-conn-main">
|
||||
<span class="um-conn-name">
|
||||
${c.friendly_name || c.name}
|
||||
${c.kind === 'global' && !c.enabled ? html`<span class="um-badge um-badge-inactive">${t('users.conn.disabled')}</span>` : nothing}
|
||||
</span>
|
||||
${c.description ? html`<span class="um-conn-desc">${c.description}</span>` : nothing}
|
||||
</span>
|
||||
</label>
|
||||
`;
|
||||
};
|
||||
return html`
|
||||
<div class="um-modal-overlay" @click=${(e) => { if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}>
|
||||
<div class="um-modal">
|
||||
<div class="um-modal-header">
|
||||
<i class="bi bi-plug"></i>
|
||||
<span>${t('users.modal.connectors_title', { username: user.username })}</span>
|
||||
<button class="um-btn-icon ms-auto" @click=${() => this._closeModal()}><i class="bi bi-x-lg"></i></button>
|
||||
</div>
|
||||
<div class="um-modal-body">
|
||||
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:.85rem">${this._error}</div>` : nothing}
|
||||
${conns === null ? html`
|
||||
<div class="um-empty"><i class="bi bi-hourglass-split"></i> ${t('users.loading')}</div>
|
||||
` : (globals.length === 0 && catalog.length === 0) ? html`
|
||||
<div class="um-empty"><i class="bi bi-plug"></i><p>${t('users.conn.empty')}</p></div>
|
||||
` : html`
|
||||
${globals.length ? html`
|
||||
<div class="um-conn-group">
|
||||
<div class="um-conn-group-title">${t('users.conn.globals')}</div>
|
||||
<div class="form-text mb-2">${t('users.conn.hint_global')}</div>
|
||||
${globals.map(row)}
|
||||
</div>` : nothing}
|
||||
${catalog.length ? html`
|
||||
<div class="um-conn-group">
|
||||
<div class="um-conn-group-title">${t('users.conn.catalog')}</div>
|
||||
<div class="form-text mb-2">${t('users.conn.hint_catalog')}</div>
|
||||
${catalog.map(row)}
|
||||
</div>` : nothing}
|
||||
`}
|
||||
</div>
|
||||
<div class="um-modal-footer">
|
||||
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._closeModal()}>${t('users.modal.cancel')}</button>
|
||||
<button class="btn btn-sm btn-primary" ?disabled=${conns === null} @click=${() => this._saveConnectors()}>
|
||||
<i class="bi bi-check-lg me-1"></i>${t('users.modal.save_btn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderModal() {
|
||||
_renderCreateModal() {
|
||||
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 });
|
||||
|
||||
const { form } = this._modal;
|
||||
return html`
|
||||
<div class="um-modal-overlay" @click=${(e) => { if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}>
|
||||
<div class="um-modal">
|
||||
<div class="um-modal-header">
|
||||
<i class="bi ${mode === 'create' ? 'bi-person-plus' : mode === 'edit' ? 'bi-pencil-square' : 'bi-key'}"></i>
|
||||
<span>${title}</span>
|
||||
<i class="bi bi-person-plus"></i>
|
||||
<span>${t('users.modal.create_title')}</span>
|
||||
<button class="um-btn-icon ms-auto" @click=${() => this._closeModal()}><i class="bi bi-x-lg"></i></button>
|
||||
</div>
|
||||
<div class="um-modal-body">
|
||||
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:.85rem">${this._error}</div>` : nothing}
|
||||
|
||||
${mode === 'create' ? html`
|
||||
<div class="mb-3">
|
||||
<label class="form-label">${t('users.modal.username')}</label>
|
||||
<input class="form-control" .value=${form.username} @input=${e => this._patch('username', e.target.value)} />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">${t('users.modal.display_name')} <span class="text-muted">${t('users.modal.optional')}</span></label>
|
||||
<input class="form-control" .value=${form.display_name} @input=${e => this._patch('display_name', e.target.value)} />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">${t('users.modal.role')}</label>
|
||||
<select class="form-select" @change=${e => this._patch('role_id', e.target.value)}>
|
||||
${(this._roles ?? []).map(r => html`<option value=${r.id} ?selected=${form.role_id === r.id}>${r.label}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
${this._profileFields(form)}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">${t('users.modal.password')}</label>
|
||||
<input type="password" class="form-control" .value=${form.password} @input=${e => this._patch('password', e.target.value)} />
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="um-enc"
|
||||
.checked=${form.encrypted}
|
||||
@change=${e => this._patch('encrypted', e.target.checked)} />
|
||||
<label class="form-check-label" for="um-enc">${t('users.modal.encrypt')}</label>
|
||||
</div>
|
||||
${form.encrypted ? html`
|
||||
<div class="setup-warn mt-2">${unsafeHTML(t('users.modal.encrypt_warn'))}</div>
|
||||
` : nothing}
|
||||
` : mode === 'edit' ? html`
|
||||
<div class="mb-3">
|
||||
<label class="form-label">${t('users.modal.username')}</label>
|
||||
<input class="form-control" .value=${form.username} @input=${e => this._patch('username', e.target.value)} />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">${t('users.modal.display_name')} <span class="text-muted">${t('users.modal.optional')}</span></label>
|
||||
<input class="form-control" .value=${form.display_name} @input=${e => this._patch('display_name', e.target.value)} />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">${t('users.modal.role')}</label>
|
||||
<select class="form-select" @change=${e => this._patch('role_id', e.target.value)}>
|
||||
${(this._roles ?? []).map(r => html`<option value=${r.id} ?selected=${form.role_id === r.id}>${r.label}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
${this._profileFields(form)}
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="um-active"
|
||||
.checked=${form.active}
|
||||
@change=${e => this._patch('active', e.target.checked)} />
|
||||
<label class="form-check-label" for="um-active">${t('users.modal.active')}</label>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="alert alert-warning py-2 mb-3" style="font-size:.82rem">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>${t('users.modal.only_cleartext')}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">${t('users.modal.new_password')}</label>
|
||||
<input type="password" class="form-control" .value=${form.password} @input=${e => this._patch('password', e.target.value)} />
|
||||
</div>
|
||||
`}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">${t('users.modal.username')}</label>
|
||||
<input class="form-control" .value=${form.username} @input=${e => this._patch('username', e.target.value)} />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">${t('users.modal.display_name')} <span class="text-muted">${t('users.modal.optional')}</span></label>
|
||||
<input class="form-control" .value=${form.display_name} @input=${e => this._patch('display_name', e.target.value)} />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">${t('users.modal.role')}</label>
|
||||
<select class="form-select" @change=${e => this._patch('role_id', e.target.value)}>
|
||||
${(this._roles ?? []).map(r => html`<option value=${r.id} ?selected=${form.role_id === r.id}>${r.label}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
${this._profileFields(form)}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">${t('users.modal.password')}</label>
|
||||
<input type="password" class="form-control" .value=${form.password} @input=${e => this._patch('password', e.target.value)} />
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="um-enc"
|
||||
.checked=${form.encrypted}
|
||||
@change=${e => this._patch('encrypted', e.target.checked)} />
|
||||
<label class="form-check-label" for="um-enc">${t('users.modal.encrypt')}</label>
|
||||
</div>
|
||||
${form.encrypted ? html`
|
||||
<div class="setup-warn mt-2">${unsafeHTML(t('users.modal.encrypt_warn'))}</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
<div class="um-modal-footer">
|
||||
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._closeModal()}>${t('users.modal.cancel')}</button>
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._save()}>
|
||||
<i class="bi bi-check-lg me-1"></i>${mode === 'create' ? t('users.modal.create_btn') : mode === 'edit' ? t('users.modal.save_btn') : t('users.modal.reset_btn')}
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._saveCreate()}>
|
||||
<i class="bi bi-check-lg me-1"></i>${t('users.modal.create_btn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._open) return nothing;
|
||||
const users = this._users ?? [];
|
||||
const loading = this._users === null;
|
||||
|
||||
return html`
|
||||
<div class="um-page">
|
||||
<div class="um-header">
|
||||
<h2 class="um-title"><i class="bi bi-people-fill me-2"></i>${t('users.title')}</h2>
|
||||
<div class="um-header-right">
|
||||
<span class="um-header-count">${t(users.length === 1 ? 'users.count_one' : 'users.count_other', { n: users.length })}</span>
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._openCreate()}>
|
||||
<i class="bi bi-plus-lg me-1"></i>${t('users.btn.new')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._error && !this._modal ? html`
|
||||
<div class="alert alert-danger py-2 mx-4" style="font-size:.85rem">${this._error}</div>
|
||||
` : nothing}
|
||||
|
||||
<div class="um-table-wrap">
|
||||
${loading ? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> ${t('users.loading')}</div>` : users.length === 0 ? html`
|
||||
<div class="um-empty"><i class="bi bi-people"></i><p>${t('users.empty')}</p></div>
|
||||
` : html`
|
||||
<table class="um-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>${t('users.table.username')}</th>
|
||||
<th>${t('users.table.display_name')}</th>
|
||||
<th>${t('users.table.role')}</th>
|
||||
<th>${t('users.table.db')}</th>
|
||||
<th>${t('users.table.status')}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${users.map(u => html`
|
||||
<tr>
|
||||
<td><strong>${u.username}</strong></td>
|
||||
<td>${u.display_name ?? '—'}</td>
|
||||
<td>${this._roleLabel(u.role_id)}</td>
|
||||
<td>${u.encrypted
|
||||
? html`<span class="um-badge um-badge-encrypted">${t('users.badge.encrypted')}</span>`
|
||||
: html`<span class="um-badge um-badge-clear">${t('users.badge.cleartext')}</span>`}</td>
|
||||
<td>${u.active
|
||||
? html`<span class="um-badge um-badge-active">${t('users.badge.active')}</span>`
|
||||
: html`<span class="um-badge um-badge-inactive">${t('users.badge.inactive')}</span>`}</td>
|
||||
<td>
|
||||
<div class="um-actions">
|
||||
<button class="um-btn-icon" title=${t('users.action.reset_pw')} @click=${() => this._openPassword(u)}>
|
||||
<i class="bi bi-key"></i>
|
||||
</button>
|
||||
<button class="um-btn-icon" title=${t('users.action.edit')} @click=${() => this._openEdit(u)}>
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button class="um-btn-icon" title=${t('users.action.connectors')} @click=${() => this._openConnectors(u)}>
|
||||
<i class="bi bi-plug"></i>
|
||||
</button>
|
||||
<button class="um-btn-icon" title=${t('users.action.delete')} @click=${() => this._delete(u)}>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`)}
|
||||
</tbody>
|
||||
</table>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
${this._renderModal()}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
+71
-21
@@ -137,28 +137,78 @@
|
||||
color: var(--placeholder-color);
|
||||
}
|
||||
|
||||
/* Per-user connector access checklist */
|
||||
.um-conn-group { margin-bottom: 18px; }
|
||||
.um-conn-group:last-child { margin-bottom: 0; }
|
||||
.um-conn-group-title {
|
||||
/* ── User detail page (#users/{id}) ──────────────────────────────────────────
|
||||
*
|
||||
* One user's own page: Profile / Connectors / Security sections. Surfaces come
|
||||
* from the shared `.connector-card`; what is local is the avatar, the section
|
||||
* rhythm and the "saved" tick. Rows in the connectors checklist reuse the
|
||||
* Connectors page's `.connector-list`/`.connector-row`.
|
||||
*/
|
||||
|
||||
.ud-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: .8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .03em;
|
||||
color: var(--placeholder-color);
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
.um-conn-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius-sm, 8px);
|
||||
margin-bottom: 8px;
|
||||
|
||||
.ud-avatar--sm {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.ud-section {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.ud-section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.6rem;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.ud-conn-group-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--placeholder-color);
|
||||
margin: 0 0 0.4rem;
|
||||
}
|
||||
|
||||
.ud-saved {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--bs-success-text-emphasis);
|
||||
}
|
||||
|
||||
/* The delete-user card: a danger-tinted edge so it reads as the risky zone
|
||||
without shouting. */
|
||||
.ud-danger {
|
||||
border-color: var(--bs-danger-border-subtle);
|
||||
}
|
||||
|
||||
.ud-section .connector-row .form-check-input {
|
||||
margin-top: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Clickable list rows (the whole row opens the user's page). */
|
||||
.um-table-clickable tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
.um-conn-row:hover { background: var(--bs-tertiary-bg); }
|
||||
.um-conn-row input { margin-top: 3px; flex: 0 0 auto; }
|
||||
.um-conn-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.um-conn-name { font-weight: 500; display: flex; align-items: center; gap: 8px; }
|
||||
.um-conn-desc { font-size: .82rem; color: var(--placeholder-color); }
|
||||
|
||||
.um-table-clickable tbody tr:hover td {
|
||||
background: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
+8
-6
@@ -1054,12 +1054,16 @@ export default {
|
||||
'users.badge.active': 'Active',
|
||||
'users.badge.inactive': 'Inactive',
|
||||
|
||||
'users.action.reset_pw': 'Reset password',
|
||||
'users.action.edit': 'Edit',
|
||||
'users.action.connectors': 'Connectors',
|
||||
'users.action.delete': 'Delete',
|
||||
|
||||
'users.modal.connectors_title': 'Connectors — {username}',
|
||||
'users.detail.back': 'Users',
|
||||
'users.detail.profile': 'Profile',
|
||||
'users.detail.connectors': 'Connectors',
|
||||
'users.detail.security': 'Security',
|
||||
'users.detail.saved': 'Saved',
|
||||
'users.detail.delete_hint': 'Deletes this user, their database and all conversation history.',
|
||||
'users.detail.no_such': 'User not found.',
|
||||
|
||||
'users.conn.globals': 'Shared connectors',
|
||||
'users.conn.catalog': 'Personal connectors',
|
||||
'users.conn.hint_global': 'Available to this user as soon as you enable it.',
|
||||
@@ -1068,8 +1072,6 @@ export default {
|
||||
'users.conn.disabled': 'disabled',
|
||||
|
||||
'users.modal.create_title': 'New user',
|
||||
'users.modal.edit_title': 'Edit {username}',
|
||||
'users.modal.reset_title': 'Reset password — {username}',
|
||||
'users.modal.username': 'Username',
|
||||
'users.modal.display_name': 'Display name',
|
||||
'users.modal.optional': '(optional)',
|
||||
|
||||
+8
-6
@@ -1041,12 +1041,16 @@ export default {
|
||||
'users.badge.active': 'Actif',
|
||||
'users.badge.inactive': 'Inactif',
|
||||
|
||||
'users.action.reset_pw': 'Réinitialiser le mot de passe',
|
||||
'users.action.edit': 'Modifier',
|
||||
'users.action.connectors': 'Connecteurs',
|
||||
'users.action.delete': 'Supprimer',
|
||||
|
||||
'users.modal.connectors_title': 'Connecteurs — {username}',
|
||||
'users.detail.back': 'Utilisateurs',
|
||||
'users.detail.profile': 'Profil',
|
||||
'users.detail.connectors': 'Connecteurs',
|
||||
'users.detail.security': 'Sécurité',
|
||||
'users.detail.saved': 'Enregistré',
|
||||
'users.detail.delete_hint': 'Supprime cet utilisateur, sa base de données et tout l\'historique des conversations.',
|
||||
'users.detail.no_such': 'Utilisateur introuvable.',
|
||||
|
||||
'users.conn.globals': 'Connecteurs partagés',
|
||||
'users.conn.catalog': 'Connecteurs personnels',
|
||||
'users.conn.hint_global': 'Disponible pour cet utilisateur dès que vous l\'activez.',
|
||||
@@ -1055,8 +1059,6 @@ export default {
|
||||
'users.conn.disabled': 'désactivé',
|
||||
|
||||
'users.modal.create_title': 'Nouvel utilisateur',
|
||||
'users.modal.edit_title': 'Modifier {username}',
|
||||
'users.modal.reset_title': 'Réinitialiser le mot de passe — {username}',
|
||||
'users.modal.username': 'Nom d\'utilisateur',
|
||||
'users.modal.display_name': 'Nom d\'affichage',
|
||||
'users.modal.optional': '(facultatif)',
|
||||
|
||||
+8
-6
@@ -1041,12 +1041,16 @@ export default {
|
||||
'users.badge.active': 'Attivo',
|
||||
'users.badge.inactive': 'Inattivo',
|
||||
|
||||
'users.action.reset_pw': 'Reimposta password',
|
||||
'users.action.edit': 'Modifica',
|
||||
'users.action.connectors': 'Connettori',
|
||||
'users.action.delete': 'Elimina',
|
||||
|
||||
'users.modal.connectors_title': 'Connettori — {username}',
|
||||
'users.detail.back': 'Utenti',
|
||||
'users.detail.profile': 'Profilo',
|
||||
'users.detail.connectors': 'Connettori',
|
||||
'users.detail.security': 'Sicurezza',
|
||||
'users.detail.saved': 'Salvato',
|
||||
'users.detail.delete_hint': 'Elimina questo utente, il suo database e tutta la cronologia delle conversazioni.',
|
||||
'users.detail.no_such': 'Utente non trovato.',
|
||||
|
||||
'users.conn.globals': 'Connettori condivisi',
|
||||
'users.conn.catalog': 'Connettori personali',
|
||||
'users.conn.hint_global': 'Disponibile per questo utente non appena lo abiliti.',
|
||||
@@ -1055,8 +1059,6 @@ export default {
|
||||
'users.conn.disabled': 'disabilitato',
|
||||
|
||||
'users.modal.create_title': 'Nuovo utente',
|
||||
'users.modal.edit_title': 'Modifica {username}',
|
||||
'users.modal.reset_title': 'Reimposta password — {username}',
|
||||
'users.modal.username': 'Nome utente',
|
||||
'users.modal.display_name': 'Nome visualizzato',
|
||||
'users.modal.optional': '(opzionale)',
|
||||
|
||||
Reference in New Issue
Block a user