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:
2026-07-18 21:38:42 +01:00
parent 2b35312abd
commit 126886e309
109 changed files with 6228 additions and 1390 deletions
+65 -29
View File
@@ -1,5 +1,7 @@
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';
const ADMIN_ID = 'admin';
@@ -26,6 +28,8 @@ export class RolesPage 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 === 'roles';
this.style.display = this._open ? 'flex' : 'none';
@@ -33,6 +37,11 @@ export class RolesPage extends LightElement {
});
}
disconnectedCallback() {
window.removeEventListener('locale-changed', this.__onLocaleChanged);
super.disconnectedCallback();
}
async _load() {
this._error = null;
try {
@@ -40,8 +49,8 @@ export class RolesPage extends LightElement {
fetch('/api/roles'),
fetch('/api/tool-permission-groups'),
]);
if (!rRes.ok) throw new Error(`Roles: HTTP ${rRes.status}`);
if (!gRes.ok) throw new Error(`Groups: HTTP ${gRes.status}`);
if (!rRes.ok) throw new Error(`HTTP ${rRes.status}`);
if (!gRes.ok) throw new Error(`HTTP ${gRes.status}`);
this._roles = await rRes.json();
this._groups = await gRes.json();
} catch (e) {
@@ -51,10 +60,25 @@ export class RolesPage extends LightElement {
// ── Modal helpers ────────────────────────────────────────────────────────────
// `ui_mode` lives in the free-form attrs JSON (data-driven, §0.1): the UI
// surfaces it as a first-class select without hardcoding any role semantics.
_attrsUiMode(attrs) {
try { return JSON.parse(attrs || '{}').ui_mode === 'simple' ? 'simple' : 'full'; }
catch { return 'full'; }
}
_mergeAttrs(attrs, uiMode) {
let o = {};
try { o = JSON.parse(attrs || '{}') ?? {}; } catch { o = {}; }
if (uiMode === 'simple') o.ui_mode = 'simple'; else delete o.ui_mode;
const keys = Object.keys(o);
return keys.length ? JSON.stringify(o) : null;
}
_openCreate() {
this._modal = {
mode: 'create',
form: { id: '', label: '', permission_group: this._groups?.[0]?.id ?? 'default', attrs: '' },
form: { id: '', label: '', permission_group: this._groups?.[0]?.id ?? 'default', attrs: '', ui_mode: 'full' },
};
}
@@ -62,7 +86,7 @@ export class RolesPage extends LightElement {
this._modal = {
mode: 'edit',
role,
form: { label: role.label, permission_group: role.permission_group, attrs: role.attrs ?? '' },
form: { label: role.label, permission_group: role.permission_group, attrs: role.attrs ?? '', ui_mode: this._attrsUiMode(role.attrs) },
};
}
@@ -79,7 +103,7 @@ export class RolesPage extends LightElement {
this._error = null;
if (mode === 'create') {
if (!form.id.trim() || !form.label.trim()) { this._error = 'ID and label are required.'; return; }
if (!form.id.trim() || !form.label.trim()) { this._error = t('roles.error.id_label'); return; }
try {
const res = await fetch('/api/roles', {
method: 'POST',
@@ -88,7 +112,7 @@ export class RolesPage extends LightElement {
id: form.id.trim(),
label: form.label.trim(),
permission_group: form.permission_group,
attrs: form.attrs.trim() || null,
attrs: this._mergeAttrs(form.attrs, form.ui_mode),
}),
});
if (!res.ok) throw new Error(await res.text());
@@ -97,7 +121,7 @@ export class RolesPage extends LightElement {
} catch (e) { this._error = e.message; }
} else {
const { role } = this._modal;
if (!form.label.trim()) { this._error = 'Label is required.'; return; }
if (!form.label.trim()) { this._error = t('roles.error.label'); return; }
try {
const res = await fetch(`/api/roles/${role.id}`, {
method: 'PUT',
@@ -105,7 +129,7 @@ export class RolesPage extends LightElement {
body: JSON.stringify({
label: form.label.trim(),
permission_group: form.permission_group,
attrs: form.attrs.trim() || null,
attrs: this._mergeAttrs(form.attrs, form.ui_mode),
}),
});
if (!res.ok) throw new Error(await res.text());
@@ -116,7 +140,7 @@ export class RolesPage extends LightElement {
}
async _delete(role) {
if (!confirm(`Delete role "${role.label}"?`)) return;
if (!confirm(t('roles.confirm.delete', { name: role.label }))) return;
try {
const res = await fetch(`/api/roles/${role.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text());
@@ -133,7 +157,7 @@ export class RolesPage extends LightElement {
_renderModal() {
if (!this._modal) return nothing;
const { mode, form, role } = this._modal;
const title = mode === 'create' ? 'New role' : `Edit ${role.label}`;
const title = mode === 'create' ? t('roles.form.new') : t('roles.form.edit', { name: role.label });
return html`
<div class="um-modal-overlay" @click=${(e) => { if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}>
@@ -148,33 +172,41 @@ export class RolesPage extends LightElement {
${mode === 'create' ? html`
<div class="mb-3">
<label class="form-label">ID <span class="text-muted">(slug)</span></label>
<input class="form-control font-monospace" placeholder="e.g. editor" .value=${form.id}
<label class="form-label">${t('roles.form.id')} <span class="text-muted">${t('roles.form.id_hint')}</span></label>
<input class="form-control font-monospace" placeholder=${t('roles.form.id_ph')} .value=${form.id}
@input=${e => this._patch('id', e.target.value)} />
<div class="form-text" style="font-size:.75rem">Lowercase, no spaces. Cannot be changed later.</div>
<div class="form-text" style="font-size:.75rem">${t('roles.form.id_desc')}</div>
</div>
` : nothing}
<div class="mb-3">
<label class="form-label">Label</label>
<label class="form-label">${t('roles.form.label')}</label>
<input class="form-control" .value=${form.label} @input=${e => this._patch('label', e.target.value)} />
</div>
<div class="mb-3">
<label class="form-label">Permission group</label>
<label class="form-label">${t('roles.form.group')}</label>
<select class="form-select" @change=${e => this._patch('permission_group', e.target.value)}>
${(this._groups ?? []).map(g => html`<option value=${g.id} ?selected=${form.permission_group === g.id}>${g.name}</option>`)}
</select>
</div>
<div class="mb-3">
<label class="form-label">Attrs <span class="text-muted">(JSON, optional)</span></label>
<input class="form-control font-monospace" placeholder="{}" .value=${form.attrs}
<label class="form-label">${t('roles.form.interface')}</label>
<select class="form-select" @change=${e => this._patch('ui_mode', e.target.value)}>
<option value="full" ?selected=${form.ui_mode === 'full'}>${t('roles.form.interface_full')}</option>
<option value="simple" ?selected=${form.ui_mode === 'simple'}>${t('roles.form.interface_simple')}</option>
</select>
<div class="form-text" style="font-size:.75rem">${unsafeHTML(t('roles.form.interface_hint'))}</div>
</div>
<div class="mb-3">
<label class="form-label">${t('roles.form.attrs')} <span class="text-muted">${t('roles.form.attrs_hint')}</span></label>
<input class="form-control font-monospace" placeholder=${t('roles.form.attrs_ph')} .value=${form.attrs}
@input=${e => this._patch('attrs', 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('roles.form.cancel')}</button>
<button class="btn btn-sm btn-primary" @click=${() => this._save()}>
<i class="bi bi-check-lg me-1"></i>${mode === 'create' ? 'Create' : 'Save'}
<i class="bi bi-check-lg me-1"></i>${mode === 'create' ? t('roles.form.create') : t('roles.form.save')}
</button>
</div>
</div>
@@ -190,11 +222,11 @@ export class RolesPage extends LightElement {
return html`
<div class="um-page">
<div class="um-header">
<h2 class="um-title"><i class="bi bi-tags me-2"></i>Roles</h2>
<h2 class="um-title"><i class="bi bi-tags me-2"></i>${t('roles.title')}</h2>
<div class="um-header-right">
<span class="um-header-count">${roles.length} role${roles.length === 1 ? '' : 's'}</span>
<span class="um-header-count">${roles.length === 1 ? t('roles.count', { n: roles.length }) : t('roles.count_plural', { n: roles.length })}</span>
<button class="btn btn-sm btn-primary" @click=${() => this._openCreate()}>
<i class="bi bi-plus-lg me-1"></i>New role
<i class="bi bi-plus-lg me-1"></i>${t('roles.new_role')}
</button>
</div>
</div>
@@ -204,15 +236,16 @@ export class RolesPage extends LightElement {
` : nothing}
<div class="um-table-wrap">
${loading ? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> Loading…</div>` : roles.length === 0 ? html`
<div class="um-empty"><i class="bi bi-tags"></i><p>No roles.</p></div>
${loading ? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> ${t('roles.loading')}</div>` : roles.length === 0 ? html`
<div class="um-empty"><i class="bi bi-tags"></i><p>${t('roles.empty')}</p></div>
` : html`
<table class="um-table">
<thead>
<tr>
<th>ID</th>
<th>Label</th>
<th>Permission group</th>
<th>${t('roles.col.id')}</th>
<th>${t('roles.col.label')}</th>
<th>${t('roles.col.group')}</th>
<th>${t('roles.col.interface')}</th>
<th></th>
</tr>
</thead>
@@ -224,14 +257,17 @@ export class RolesPage extends LightElement {
<td><code>${r.id}</code></td>
<td><strong>${r.label}</strong></td>
<td>${this._groupLabel(r.permission_group)}</td>
<td>${this._attrsUiMode(r.attrs) === 'simple'
? html`<span class="badge" style="background:var(--accent-soft);color:var(--accent)">${t('roles.badge.simple')}</span>`
: html`<span class="badge bg-secondary">${t('roles.badge.full')}</span>`}</td>
<td>
<div class="um-actions">
<button class="um-btn-icon" title=${isAdmin ? 'Built-in role — locked' : 'Edit'}
<button class="um-btn-icon" title=${isAdmin ? t('roles.tooltip.locked') : t('roles.tooltip.edit')}
?disabled=${isAdmin}
@click=${() => !isAdmin && this._openEdit(r)}>
<i class="bi bi-pencil"></i>
</button>
<button class="um-btn-icon" title=${isAdmin ? 'Built-in role — locked' : 'Delete'}
<button class="um-btn-icon" title=${isAdmin ? t('roles.tooltip.locked') : t('roles.tooltip.delete')}
?disabled=${isAdmin}
@click=${() => !isAdmin && this._delete(r)}>
<i class="bi bi-trash"></i>