diff --git a/CLAUDE.md b/CLAUDE.md index 5139451..635cd6e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -395,7 +395,7 @@ The role editor (`roles-page.js`) sets the default group + an allowed-groups che | `system-agents.js` | `` | `#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` — 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` — 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` | `` | 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` | `` | 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 provider management | | `models-hub.js` | `` | Models hub landing (LLM / Transcription / Image) | diff --git a/web/components/users-page.js b/web/components/users-page.js index fe1069d..6215478 100644 --- a/web/components/users-page.js +++ b/web/components/users-page.js @@ -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` +
+
+

${t('users.title')}

+
+ ${t(users.length === 1 ? 'users.count_one' : 'users.count_other', { n: users.length })} + +
+
+ + ${this._error && !this._modal ? html` +
${this._error}
` : nothing} + +
+ ${loading ? html`
${t('users.loading')}
` : users.length === 0 ? html` +

${t('users.empty')}

+ ` : html` + + + + + + + + + + + + + ${users.map(u => html` + this._openUser(u)}> + + + + + + + + `)} + +
${t('users.table.username')}${t('users.table.display_name')}${t('users.table.role')}${t('users.table.db')}${t('users.table.status')}
+
+ + ${(u.display_name || u.username).charAt(0).toUpperCase()} + + ${u.username} +
+
${u.display_name ?? '—'}${this._roleLabel(u.role_id)}${u.encrypted + ? html`${t('users.badge.encrypted')}` + : html`${t('users.badge.cleartext')}`}${u.active + ? html`${t('users.badge.active')}` + : html`${t('users.badge.inactive')}`}
+ `} +
+
+ `; + } + _roleLabel(roleId) { return this._roles?.find(r => r.id === roleId)?.label ?? roleId; } + // ── Render: the user's own page ──────────────────────────────────────────── + + _renderDetail() { + const u = this._user; + if (!u || !this._dForm) { + return html` +
+ ${this._renderDetailHeader(null)} + ${this._error + ? html`
${this._error}
` + : html`
${t('users.loading')}
`} +
`; + } + return html` +
+ ${this._renderDetailHeader(u)} +
+ ${this._error ? html` +
${this._error}
` : nothing} + ${this._renderProfile(u)} + ${this._renderConnectors(u)} + ${this._renderSecurity(u)} +
+
`; + } + + _renderDetailHeader(u) { + return html` +
+
+ + ${u ? html` + + ${(u.display_name || u.username).charAt(0).toUpperCase()} + +
+

+ ${u.display_name || u.username}

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

${t('users.title')}

`} +
+
`; + } + + _renderProfile(u) { + const f = this._dForm; + return html` +
+

${t('users.detail.profile')}

+
+
+
+ + this._patchD('username', e.target.value)} /> +
+
+ + this._patchD('display_name', e.target.value)} /> +
+
+ + +
+
+ + this._patchD('birthdate', e.target.value)} /> +
+
+ + this._patchD('sex', e.target.value)} /> +
+
+ + +
${t('users.modal.notes_hint')}
+
+
+
+ this._patchD('active', e.target.checked)} /> + +
+
+
+
+ + ${this._dSaved ? html`${t('users.detail.saved')}` : nothing} +
+
+
`; + } + + _renderConnectors(u) { + const conns = this._conns; + const q = this._connQ.trim().toLowerCase(); + const visible = (conns ?? []).filter(c => !q + || c.name.toLowerCase().includes(q) + || (c.friendly_name ?? '').toLowerCase().includes(q) + || (c.description ?? '').toLowerCase().includes(q)); + const globals = visible.filter(c => c.kind === 'global'); + const catalog = visible.filter(c => c.kind === 'catalog'); + + const row = (c) => { + const idx = this._conns.indexOf(c); + const showIcon = !this._noIcon.has(c.name); + return html` + `; + }; + + return html` +
+

${t('users.detail.connectors')}

+ ${conns === null + ? html`
${t('users.loading')}
` + : conns.length === 0 + ? html`

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

` + : html` +
+ +
+ ${visible.length === 0 + ? html`
+

${t('connectors.empty.match', { query: this._connQ })}

` + : html` + ${globals.length ? html` +
${t('users.conn.globals')}
+
${t('users.conn.hint_global')}
+
${globals.map(row)}
` : nothing} + ${catalog.length ? html` +
${t('users.conn.catalog')}
+
${t('users.conn.hint_catalog')}
+
${catalog.map(row)}
` : nothing}`} +
+ + ${this._connSaved ? html`${t('users.detail.saved')}` : nothing} +
`} +
`; + } + + _renderSecurity(u) { + return html` +
+

${t('users.detail.security')}

+
+ ${u.encrypted ? html` +
+ ${t('users.modal.only_cleartext')} +
` : html` +
+
+ + { this._dPw = e.target.value; this._pwSaved = false; }} /> +
+
+ + ${this._pwSaved ? html`${t('users.detail.saved')}` : nothing} +
+
`} +
+ +
+
+
${t('users.detail.delete_hint')}
+ +
+
+
`; + } + + // ── Render: create modal ───────────────────────────────────────────────────── + _profileFields(form) { return html`
@@ -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` - - `; - }; - return html` -
{ if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}> -
-
- - ${t('users.modal.connectors_title', { username: user.username })} - -
-
- ${this._error ? html`
${this._error}
` : nothing} - ${conns === null ? html` -
${t('users.loading')}
- ` : (globals.length === 0 && catalog.length === 0) ? html` -

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

- ` : html` - ${globals.length ? html` -
-
${t('users.conn.globals')}
-
${t('users.conn.hint_global')}
- ${globals.map(row)} -
` : nothing} - ${catalog.length ? html` -
-
${t('users.conn.catalog')}
-
${t('users.conn.hint_catalog')}
- ${catalog.map(row)} -
` : nothing} - `} -
- -
-
- `; - } - - _renderModal() { + _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`
{ if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}>
- - ${title} + + ${t('users.modal.create_title')}
${this._error ? html`
${this._error}
` : nothing} - - ${mode === 'create' ? html` -
- - this._patch('username', e.target.value)} /> -
-
- - this._patch('display_name', e.target.value)} /> -
-
- - -
- ${this._profileFields(form)} -
- - this._patch('password', e.target.value)} /> -
-
- this._patch('encrypted', e.target.checked)} /> - -
- ${form.encrypted ? html` -
${unsafeHTML(t('users.modal.encrypt_warn'))}
- ` : nothing} - ` : mode === 'edit' ? html` -
- - this._patch('username', e.target.value)} /> -
-
- - this._patch('display_name', e.target.value)} /> -
-
- - -
- ${this._profileFields(form)} -
- this._patch('active', e.target.checked)} /> - -
- ` : html` -
- ${t('users.modal.only_cleartext')} -
-
- - this._patch('password', e.target.value)} /> -
- `} +
+ + this._patch('username', e.target.value)} /> +
+
+ + this._patch('display_name', e.target.value)} /> +
+
+ + +
+ ${this._profileFields(form)} +
+ + this._patch('password', e.target.value)} /> +
+
+ this._patch('encrypted', e.target.checked)} /> + +
+ ${form.encrypted ? html` +
${unsafeHTML(t('users.modal.encrypt_warn'))}
+ ` : nothing}
`; } - - render() { - if (!this._open) return nothing; - const users = this._users ?? []; - const loading = this._users === null; - - return html` -
-
-

${t('users.title')}

-
- ${t(users.length === 1 ? 'users.count_one' : 'users.count_other', { n: users.length })} - -
-
- - ${this._error && !this._modal ? html` -
${this._error}
- ` : nothing} - -
- ${loading ? html`
${t('users.loading')}
` : users.length === 0 ? html` -

${t('users.empty')}

- ` : html` - - - - - - - - - - - - - ${users.map(u => html` - - - - - - - - - `)} - -
${t('users.table.username')}${t('users.table.display_name')}${t('users.table.role')}${t('users.table.db')}${t('users.table.status')}
${u.username}${u.display_name ?? '—'}${this._roleLabel(u.role_id)}${u.encrypted - ? html`${t('users.badge.encrypted')}` - : html`${t('users.badge.cleartext')}`}${u.active - ? html`${t('users.badge.active')}` - : html`${t('users.badge.inactive')}`} -
- - - - -
-
- `} -
-
- ${this._renderModal()} - `; - } } diff --git a/web/css/users-roles.css b/web/css/users-roles.css index 291159b..0d4cdc5 100644 --- a/web/css/users-roles.css +++ b/web/css/users-roles.css @@ -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); +} diff --git a/web/i18n/en.js b/web/i18n/en.js index 678e201..01b158e 100644 --- a/web/i18n/en.js +++ b/web/i18n/en.js @@ -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)', diff --git a/web/i18n/fr.js b/web/i18n/fr.js index 2c69379..3e9d8e5 100644 --- a/web/i18n/fr.js +++ b/web/i18n/fr.js @@ -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)', diff --git a/web/i18n/it.js b/web/i18n/it.js index 647c0c7..c8e0ea6 100644 --- a/web/i18n/it.js +++ b/web/i18n/it.js @@ -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)',