Major rebranding, i18n, dashboard, shared folders, role capabilities
- Rebrand: new app/agent icons, SKALD.md, warm "paper" CSS palette (terracotta accent, --radius tokens, WCAG contrast, reduced-motion), updated favicon, tray icon, skaldkonur asset - i18n: backend crate (i18n.rs, locale column, ui_locale config), frontend library (web/lib/i18n.js, I18nMixin, t(key)), translation files (web/i18n/), every component wired - Dashboard: <dashboard-page> replaces old home-page content; <app-copilot> becomes the landing page (full/dock layout modes) - Shared folders: API endpoints (shared_folders.rs), frontend page, can_write membership, container mount topology, user_fs routing - Role capabilities: new db table & authorization seam (data not enums), roles.attrs JSON for ui_mode / interface select - Setup: skald-setup prompts for language + password, sets ui_locale - General: components migrated to CSS variables, Lit conventions cleanup, connectors/catalog/marketplace/approval refactoring
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { html, nothing } from 'lit';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { t } from '../lib/i18n.js';
|
||||
|
||||
export class UsersPage extends LightElement {
|
||||
|
||||
@@ -24,6 +26,8 @@ export class UsersPage extends LightElement {
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.__onLocaleChanged = () => this.requestUpdate();
|
||||
window.addEventListener('locale-changed', this.__onLocaleChanged);
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === 'users';
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
@@ -31,6 +35,11 @@ export class UsersPage extends LightElement {
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener('locale-changed', this.__onLocaleChanged);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
async _load() {
|
||||
this._error = null;
|
||||
try {
|
||||
@@ -81,7 +90,7 @@ export class UsersPage extends LightElement {
|
||||
this._error = null;
|
||||
|
||||
if (mode === 'create') {
|
||||
if (!form.username.trim() || !form.password) { this._error = 'Username and password are required.'; return; }
|
||||
if (!form.username.trim() || !form.password) { this._error = t('users.error.required_username_pw'); return; }
|
||||
try {
|
||||
const res = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
@@ -100,7 +109,7 @@ export class UsersPage extends LightElement {
|
||||
} catch (e) { this._error = e.message; }
|
||||
} else if (mode === 'edit') {
|
||||
const { user } = this._modal;
|
||||
if (!form.username.trim()) { this._error = 'Username is required.'; return; }
|
||||
if (!form.username.trim()) { this._error = t('users.error.required_username'); return; }
|
||||
try {
|
||||
const res = await fetch(`/api/users/${user.id}`, {
|
||||
method: 'PUT',
|
||||
@@ -118,7 +127,7 @@ export class UsersPage extends LightElement {
|
||||
} catch (e) { this._error = e.message; }
|
||||
} else if (mode === 'password') {
|
||||
const { user } = this._modal;
|
||||
if (!form.password) { this._error = 'Password must not be empty.'; return; }
|
||||
if (!form.password) { this._error = t('users.error.password_empty'); return; }
|
||||
try {
|
||||
const res = await fetch(`/api/users/${user.id}/password`, {
|
||||
method: 'POST',
|
||||
@@ -132,7 +141,7 @@ export class UsersPage extends LightElement {
|
||||
}
|
||||
|
||||
async _delete(user) {
|
||||
if (!confirm(`Delete user "${user.username}"? This permanently erases their database and all conversation history.`)) return;
|
||||
if (!confirm(t('users.confirm.delete', { username: user.username }))) return;
|
||||
try {
|
||||
const res = await fetch(`/api/users/${user.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
@@ -149,9 +158,9 @@ export class UsersPage extends LightElement {
|
||||
_renderModal() {
|
||||
if (!this._modal) return nothing;
|
||||
const { mode, form, user } = this._modal;
|
||||
const title = mode === 'create' ? 'New user'
|
||||
: mode === 'edit' ? `Edit ${user.username}`
|
||||
: `Reset password — ${user.username}`;
|
||||
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`
|
||||
<div class="um-modal-overlay" @click=${(e) => { if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}>
|
||||
@@ -166,45 +175,43 @@ export class UsersPage extends LightElement {
|
||||
|
||||
${mode === 'create' ? html`
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<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">Display name <span class="text-muted">(optional)</span></label>
|
||||
<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">Role</label>
|
||||
<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>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<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">Encrypt conversation history</label>
|
||||
<label class="form-check-label" for="um-enc">${t('users.modal.encrypt')}</label>
|
||||
</div>
|
||||
${form.encrypted ? html`
|
||||
<div class="setup-warn mt-2">
|
||||
<strong>Warning:</strong> if the password is lost, the conversation history is permanently unrecoverable.
|
||||
</div>
|
||||
<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">Username</label>
|
||||
<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">Display name <span class="text-muted">(optional)</span></label>
|
||||
<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">Role</label>
|
||||
<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>
|
||||
@@ -213,23 +220,22 @@ export class UsersPage extends LightElement {
|
||||
<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">Active</label>
|
||||
<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>
|
||||
Only works for cleartext (non-encrypted) users.
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>${t('users.modal.only_cleartext')}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">New password</label>
|
||||
<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 class="um-modal-footer">
|
||||
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._closeModal()}>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()}>
|
||||
<i class="bi bi-check-lg me-1"></i>${mode === 'create' ? 'Create' : mode === 'edit' ? 'Save' : 'Reset'}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,11 +251,11 @@ export class UsersPage extends LightElement {
|
||||
return html`
|
||||
<div class="um-page">
|
||||
<div class="um-header">
|
||||
<h2 class="um-title"><i class="bi bi-people-fill me-2"></i>Users</h2>
|
||||
<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">${users.length} user${users.length === 1 ? '' : 's'}</span>
|
||||
<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>New user
|
||||
<i class="bi bi-plus-lg me-1"></i>${t('users.btn.new')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -259,17 +265,17 @@ export class UsersPage extends LightElement {
|
||||
` : nothing}
|
||||
|
||||
<div class="um-table-wrap">
|
||||
${loading ? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> Loading…</div>` : users.length === 0 ? html`
|
||||
<div class="um-empty"><i class="bi bi-people"></i><p>No users.</p></div>
|
||||
${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>Username</th>
|
||||
<th>Display name</th>
|
||||
<th>Role</th>
|
||||
<th>DB</th>
|
||||
<th>Status</th>
|
||||
<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>
|
||||
@@ -280,20 +286,20 @@ export class UsersPage extends LightElement {
|
||||
<td>${u.display_name ?? '—'}</td>
|
||||
<td>${this._roleLabel(u.role_id)}</td>
|
||||
<td>${u.encrypted
|
||||
? html`<span class="um-badge um-badge-encrypted">Encrypted</span>`
|
||||
: html`<span class="um-badge um-badge-clear">Cleartext</span>`}</td>
|
||||
? 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">Active</span>`
|
||||
: html`<span class="um-badge um-badge-inactive">Inactive</span>`}</td>
|
||||
? 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="Reset password" @click=${() => this._openPassword(u)}>
|
||||
<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="Edit" @click=${() => this._openEdit(u)}>
|
||||
<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="Delete" @click=${() => this._delete(u)}>
|
||||
<button class="um-btn-icon" title=${t('users.action.delete')} @click=${() => this._delete(u)}>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user