Release 0.2.0 #4

Merged
dguiducci merged 96 commits from main into release 2026-08-17 18:07:20 +01:00
6 changed files with 618 additions and 345 deletions
Showing only changes of commit fadb31832f - Show all commits
+1 -1
View File
@@ -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 | | `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 | | `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 | | `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 | | `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 | | `llm-providers.js` | `<llm-providers-page>` | LLM provider management |
| `models-hub.js` | `<models-hub-page>` | Models hub landing (LLM / Transcription / Image) | | `models-hub.js` | `<models-hub-page>` | Models hub landing (LLM / Transcription / Image) |
+522 -305
View File
@@ -2,16 +2,50 @@ import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { LightElement } from '../lib/base.js'; import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.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 { export class UsersPage extends LightElement {
static get properties() { static get properties() {
return { return {
_open: { state: true }, _open: { state: true },
_users: { state: true }, _users: { state: true },
_roles: { state: true }, _roles: { state: true },
_error: { state: true }, _error: { state: true },
_modal: { state: true }, // null | { mode: 'create'|'edit'|'password', user?, form } _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._roles = null;
this._error = null; this._error = null;
this._modal = 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() { connectedCallback() {
@@ -31,7 +80,10 @@ export class UsersPage extends LightElement {
window.addEventListener('llm-page-change', (e) => { window.addEventListener('llm-page-change', (e) => {
this._open = e.detail.page === 'users'; this._open = e.detail.page === 'users';
this.style.display = this._open ? 'flex' : 'none'; 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}`); if (!rRes.ok) throw new Error(`Roles: HTTP ${rRes.status}`);
this._users = await uRes.json(); this._users = await uRes.json();
this._roles = await rRes.json(); this._roles = await rRes.json();
if (this._view === 'user') this._enterDetail();
} catch (e) { } catch (e) {
this._error = e.message; 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() { _openCreate() {
this._modal = { 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; } _closeModal() { this._modal = null; this._error = null; }
_patch(field, value) { _patch(field, value) {
this._modal = { ...this._modal, form: { ...this._modal.form, [field]: value } }; this._modal = { ...this._modal, form: { ...this._modal.form, [field]: value } };
} }
// ── API actions ────────────────────────────────────────────────────────────── async _saveCreate() {
const { form } = this._modal;
async _save() {
const { mode, form } = this._modal;
this._error = null; this._error = null;
if (!form.username.trim() || !form.password) { this._error = t('users.error.required_username_pw'); return; }
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 { 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()); if (!res.ok) throw new Error(await res.text());
this._closeModal();
await this._load(); await this._load();
} catch (e) { this._error = e.message; } } catch (e) { this._error = e.message; }
} }
// ── Per-user connector access ──────────────────────────────────────────────── // ── Detail: profile ───────────────────────────────────────────────────────────
async _openConnectors(user) { async _saveProfile() {
this._modal = { mode: 'connectors', user, conns: null }; const u = this._user;
this._error = null; const f = this._dForm;
if (!f.username.trim()) { this._error = t('users.error.required_username'); return; }
this._busy = true; this._error = null;
try { 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()); if (!res.ok) throw new Error(await res.text());
const conns = await res.json(); await this._load();
this._modal = { ...this._modal, conns }; this._dSaved = true;
} catch (e) { this._error = e.message; } } catch (e) { this._error = e.message; }
finally { this._busy = false; }
} }
// ── Detail: connectors ───────────────────────────────────────────────────────
_toggleConn(idx) { _toggleConn(idx) {
const conns = this._modal.conns.map((c, i) => i === idx ? { ...c, granted: !c.granted } : c); this._conns = this._conns.map((c, i) => i === idx ? { ...c, granted: !c.granted } : c);
this._modal = { ...this._modal, conns }; this._connSaved = false;
} }
async _saveConnectors() { async _saveConnectors() {
const { user, conns } = this._modal; const u = this._user;
const global_ids = conns.filter(c => c.kind === 'global' && c.granted).map(c => c.id); const global_ids = this._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); const catalog_names = this._conns.filter(c => c.kind === 'catalog' && c.granted).map(c => c.name);
this._error = null; this._busy = true; this._error = null;
try { try {
const res = await fetch(`/api/users/${user.id}/connectors`, { const res = await fetch(`/api/users/${encodeURIComponent(u.id)}/connectors`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ global_ids, catalog_names }), body: JSON.stringify({ global_ids, catalog_names }),
}); });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
this._closeModal(); this._connSaved = true;
} catch (e) { this._error = e.message; } } 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 ──────────────────────────────────────────────────────────────────
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) { _roleLabel(roleId) {
return this._roles?.find(r => r.id === roleId)?.label ?? 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) { _profileFields(form) {
return html` return html`
<div class="mb-3"> <div class="mb-3">
@@ -213,228 +602,56 @@ export class UsersPage extends LightElement {
`; `;
} }
_renderConnectorsModal() { _renderCreateModal() {
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() {
if (!this._modal) return nothing; if (!this._modal) return nothing;
if (this._modal.mode === 'connectors') return this._renderConnectorsModal(); const { form } = this._modal;
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` return html`
<div class="um-modal-overlay" @click=${(e) => { if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}> <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">
<div class="um-modal-header"> <div class="um-modal-header">
<i class="bi ${mode === 'create' ? 'bi-person-plus' : mode === 'edit' ? 'bi-pencil-square' : 'bi-key'}"></i> <i class="bi bi-person-plus"></i>
<span>${title}</span> <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> <button class="um-btn-icon ms-auto" @click=${() => this._closeModal()}><i class="bi bi-x-lg"></i></button>
</div> </div>
<div class="um-modal-body"> <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} ${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:.85rem">${this._error}</div>` : nothing}
<div class="mb-3">
${mode === 'create' ? html` <label class="form-label">${t('users.modal.username')}</label>
<div class="mb-3"> <input class="form-control" .value=${form.username} @input=${e => this._patch('username', e.target.value)} />
<label class="form-label">${t('users.modal.username')}</label> </div>
<input class="form-control" .value=${form.username} @input=${e => this._patch('username', e.target.value)} /> <div class="mb-3">
</div> <label class="form-label">${t('users.modal.display_name')} <span class="text-muted">${t('users.modal.optional')}</span></label>
<div class="mb-3"> <input class="form-control" .value=${form.display_name} @input=${e => this._patch('display_name', e.target.value)} />
<label class="form-label">${t('users.modal.display_name')} <span class="text-muted">${t('users.modal.optional')}</span></label> </div>
<input class="form-control" .value=${form.display_name} @input=${e => this._patch('display_name', e.target.value)} /> <div class="mb-3">
</div> <label class="form-label">${t('users.modal.role')}</label>
<div class="mb-3"> <select class="form-select" @change=${e => this._patch('role_id', e.target.value)}>
<label class="form-label">${t('users.modal.role')}</label> ${(this._roles ?? []).map(r => html`<option value=${r.id} ?selected=${form.role_id === r.id}>${r.label}</option>`)}
<select class="form-select" @change=${e => this._patch('role_id', e.target.value)}> </select>
${(this._roles ?? []).map(r => html`<option value=${r.id} ?selected=${form.role_id === r.id}>${r.label}</option>`)} </div>
</select> ${this._profileFields(form)}
</div> <div class="mb-3">
${this._profileFields(form)} <label class="form-label">${t('users.modal.password')}</label>
<div class="mb-3"> <input type="password" class="form-control" .value=${form.password} @input=${e => this._patch('password', e.target.value)} />
<label class="form-label">${t('users.modal.password')}</label> </div>
<input type="password" class="form-control" .value=${form.password} @input=${e => this._patch('password', e.target.value)} /> <div class="form-check">
</div> <input class="form-check-input" type="checkbox" id="um-enc"
<div class="form-check"> .checked=${form.encrypted}
<input class="form-check-input" type="checkbox" id="um-enc" @change=${e => this._patch('encrypted', e.target.checked)} />
.checked=${form.encrypted} <label class="form-check-label" for="um-enc">${t('users.modal.encrypt')}</label>
@change=${e => this._patch('encrypted', e.target.checked)} /> </div>
<label class="form-check-label" for="um-enc">${t('users.modal.encrypt')}</label> ${form.encrypted ? html`
</div> <div class="setup-warn mt-2">${unsafeHTML(t('users.modal.encrypt_warn'))}</div>
${form.encrypted ? html` ` : nothing}
<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> </div>
<div class="um-modal-footer"> <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-outline-secondary" @click=${() => this._closeModal()}>${t('users.modal.cancel')}</button>
<button class="btn btn-sm btn-primary" @click=${() => this._save()}> <button class="btn btn-sm btn-primary" @click=${() => this._saveCreate()}>
<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')} <i class="bi bi-check-lg me-1"></i>${t('users.modal.create_btn')}
</button> </button>
</div> </div>
</div> </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
View File
@@ -137,28 +137,78 @@
color: var(--placeholder-color); color: var(--placeholder-color);
} }
/* Per-user connector access checklist */ /* ── User detail page (#users/{id}) ──────────────────────────────────────────
.um-conn-group { margin-bottom: 18px; } *
.um-conn-group:last-child { margin-bottom: 0; } * One user's own page: Profile / Connectors / Security sections. Surfaces come
.um-conn-group-title { * 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-weight: 600;
font-size: .8rem; font-size: 1rem;
text-transform: uppercase; flex-shrink: 0;
letter-spacing: .03em; user-select: none;
color: var(--placeholder-color);
} }
.um-conn-row {
display: flex; .ud-avatar--sm {
align-items: flex-start; width: 26px;
gap: 10px; height: 26px;
padding: 10px 12px; font-size: 0.72rem;
border: 1px solid var(--card-border); }
border-radius: var(--radius-sm, 8px);
margin-bottom: 8px; .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; cursor: pointer;
} }
.um-conn-row:hover { background: var(--bs-tertiary-bg); }
.um-conn-row input { margin-top: 3px; flex: 0 0 auto; } .um-table-clickable tbody tr:hover td {
.um-conn-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; } background: var(--bs-tertiary-bg);
.um-conn-name { font-weight: 500; display: flex; align-items: center; gap: 8px; } }
.um-conn-desc { font-size: .82rem; color: var(--placeholder-color); }
+8 -6
View File
@@ -1054,12 +1054,16 @@ export default {
'users.badge.active': 'Active', 'users.badge.active': 'Active',
'users.badge.inactive': 'Inactive', 'users.badge.inactive': 'Inactive',
'users.action.reset_pw': 'Reset password',
'users.action.edit': 'Edit',
'users.action.connectors': 'Connectors',
'users.action.delete': 'Delete', '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.globals': 'Shared connectors',
'users.conn.catalog': 'Personal connectors', 'users.conn.catalog': 'Personal connectors',
'users.conn.hint_global': 'Available to this user as soon as you enable it.', '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.conn.disabled': 'disabled',
'users.modal.create_title': 'New user', '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.username': 'Username',
'users.modal.display_name': 'Display name', 'users.modal.display_name': 'Display name',
'users.modal.optional': '(optional)', 'users.modal.optional': '(optional)',
+8 -6
View File
@@ -1041,12 +1041,16 @@ export default {
'users.badge.active': 'Actif', 'users.badge.active': 'Actif',
'users.badge.inactive': 'Inactif', '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.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.globals': 'Connecteurs partagés',
'users.conn.catalog': 'Connecteurs personnels', 'users.conn.catalog': 'Connecteurs personnels',
'users.conn.hint_global': 'Disponible pour cet utilisateur dès que vous l\'activez.', '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.conn.disabled': 'désactivé',
'users.modal.create_title': 'Nouvel utilisateur', '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.username': 'Nom d\'utilisateur',
'users.modal.display_name': 'Nom d\'affichage', 'users.modal.display_name': 'Nom d\'affichage',
'users.modal.optional': '(facultatif)', 'users.modal.optional': '(facultatif)',
+8 -6
View File
@@ -1041,12 +1041,16 @@ export default {
'users.badge.active': 'Attivo', 'users.badge.active': 'Attivo',
'users.badge.inactive': 'Inattivo', 'users.badge.inactive': 'Inattivo',
'users.action.reset_pw': 'Reimposta password',
'users.action.edit': 'Modifica',
'users.action.connectors': 'Connettori',
'users.action.delete': 'Elimina', '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.globals': 'Connettori condivisi',
'users.conn.catalog': 'Connettori personali', 'users.conn.catalog': 'Connettori personali',
'users.conn.hint_global': 'Disponibile per questo utente non appena lo abiliti.', 'users.conn.hint_global': 'Disponibile per questo utente non appena lo abiliti.',
@@ -1055,8 +1059,6 @@ export default {
'users.conn.disabled': 'disabilitato', 'users.conn.disabled': 'disabilitato',
'users.modal.create_title': 'Nuovo utente', '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.username': 'Nome utente',
'users.modal.display_name': 'Nome visualizzato', 'users.modal.display_name': 'Nome visualizzato',
'users.modal.optional': '(opzionale)', 'users.modal.optional': '(opzionale)',