Setup: utente admin via web, ruoli e run-context con security-group, onboarding install/uninstall script
Nightly Build / build (push) Failing after 6m12s

This commit is contained in:
2026-07-20 12:54:56 +01:00
parent b6128e4053
commit 44dc67cda0
29 changed files with 1631 additions and 110 deletions
+26
View File
@@ -25,6 +25,7 @@ export class AppCopilot extends I18nMixin(ChatSession) {
_mode: { state: true },
_me: { state: true },
_modelOpen: { state: true },
_groupOpen: { state: true },
_tabs: { state: true },
_activeSource: { state: true },
_cmdMenu: { state: true },
@@ -38,6 +39,7 @@ export class AppCopilot extends I18nMixin(ChatSession) {
this._mode = 'dock';
this._me = null;
this._modelOpen = false;
this._groupOpen = false;
this._resizing = false;
// Slash-command autocomplete: `_cmdMenu` is the filtered list currently shown
// (null = hidden), `_cmdSel` the highlighted index, `_allCommands` the merged
@@ -62,6 +64,7 @@ export class AppCopilot extends I18nMixin(ChatSession) {
this._restoreState();
this._loadCommands();
this._loadMe();
this._loadSecurityGroups();
// Same element, two layouts: the chat is the home page ('full') and docks
// to the side on every other route — state is never lost, it only resizes.
this._applyMode(this._pageFromHash() === 'home' ? 'full' : 'dock');
@@ -450,6 +453,29 @@ export class AppCopilot extends I18nMixin(ChatSession) {
</button>
</div>
` : nothing}
${this._securityGroups.length > 1 ? html`
<div class="copilot-model-wrap">
${this._groupOpen ? html`
<div class="copilot-model-overlay" @click=${() => { this._groupOpen = false; }}></div>
<div class="copilot-model-dropdown">
${this._securityGroups.map(g => html`
<button
class="copilot-model-item ${g.id === this._selectedGroup ? 'active' : ''}"
@click=${() => { this._selectGroup(g.id); this._groupOpen = false; }}
>${g.name}</button>
`)}
</div>
` : nothing}
<button
class="copilot-model-pill"
title=${t('chat.security_group')}
@click=${() => { this._groupOpen = !this._groupOpen; }}>
<i class="bi bi-shield-lock"></i>
<span>${this._securityGroups.find(g => g.id === this._selectedGroup)?.name ?? this._selectedGroup}</span>
<i class="bi bi-chevron-${this._groupOpen ? 'down' : 'up'}"></i>
</button>
</div>
` : nothing}
<button
class="copilot-toolbar-btn"
title=${t('chat.new_session')}
+34 -5
View File
@@ -67,10 +67,21 @@ export class RolesPage extends LightElement {
catch { return 'full'; }
}
_mergeAttrs(attrs, uiMode) {
// Extra security-groups the role may pick beyond its default `permission_group`
// (the effective set is default these). Lives in attrs JSON (§0.1).
_attrsAllowedGroups(attrs) {
try {
const a = JSON.parse(attrs || '{}').permission_groups;
return Array.isArray(a) ? a : [];
} catch { return []; }
}
_mergeAttrs(attrs, uiMode, allowedGroups) {
let o = {};
try { o = JSON.parse(attrs || '{}') ?? {}; } catch { o = {}; }
if (uiMode === 'simple') o.ui_mode = 'simple'; else delete o.ui_mode;
const extras = Array.isArray(allowedGroups) ? allowedGroups.filter(Boolean) : [];
if (extras.length) o.permission_groups = extras; else delete o.permission_groups;
const keys = Object.keys(o);
return keys.length ? JSON.stringify(o) : null;
}
@@ -78,7 +89,7 @@ export class RolesPage extends LightElement {
_openCreate() {
this._modal = {
mode: 'create',
form: { id: '', label: '', permission_group: this._groups?.[0]?.id ?? 'default', attrs: '', ui_mode: 'full' },
form: { id: '', label: '', permission_group: this._groups?.[0]?.id ?? 'default', attrs: '', ui_mode: 'full', allowed_groups: [] },
};
}
@@ -86,7 +97,7 @@ export class RolesPage extends LightElement {
this._modal = {
mode: 'edit',
role,
form: { label: role.label, permission_group: role.permission_group, attrs: role.attrs ?? '', ui_mode: this._attrsUiMode(role.attrs) },
form: { label: role.label, permission_group: role.permission_group, attrs: role.attrs ?? '', ui_mode: this._attrsUiMode(role.attrs), allowed_groups: this._attrsAllowedGroups(role.attrs) },
};
}
@@ -96,6 +107,12 @@ export class RolesPage extends LightElement {
this._modal = { ...this._modal, form: { ...this._modal.form, [field]: value } };
}
_toggleAllowedGroup(id, checked) {
const cur = new Set(this._modal.form.allowed_groups || []);
if (checked) cur.add(id); else cur.delete(id);
this._patch('allowed_groups', [...cur]);
}
// ── API actions ──────────────────────────────────────────────────────────────
async _save() {
@@ -112,7 +129,7 @@ export class RolesPage extends LightElement {
id: form.id.trim(),
label: form.label.trim(),
permission_group: form.permission_group,
attrs: this._mergeAttrs(form.attrs, form.ui_mode),
attrs: this._mergeAttrs(form.attrs, form.ui_mode, form.allowed_groups),
}),
});
if (!res.ok) throw new Error(await res.text());
@@ -129,7 +146,7 @@ export class RolesPage extends LightElement {
body: JSON.stringify({
label: form.label.trim(),
permission_group: form.permission_group,
attrs: this._mergeAttrs(form.attrs, form.ui_mode),
attrs: this._mergeAttrs(form.attrs, form.ui_mode, form.allowed_groups),
}),
});
if (!res.ok) throw new Error(await res.text());
@@ -189,6 +206,18 @@ export class RolesPage extends LightElement {
${(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">${t('roles.form.allowed')}</label>
<div class="form-text mb-2" style="font-size:.75rem">${t('roles.form.allowed_hint')}</div>
${(this._groups ?? []).filter(g => g.id !== form.permission_group).map(g => html`
<div class="form-check">
<input class="form-check-input" type="checkbox" id="allow-${g.id}"
.checked=${(form.allowed_groups || []).includes(g.id)}
@change=${e => this._toggleAllowedGroup(g.id, e.target.checked)} />
<label class="form-check-label" for="allow-${g.id}">${g.name}</label>
</div>
`)}
</div>
<div class="mb-3">
<label class="form-label">${t('roles.form.interface')}</label>
<select class="form-select" @change=${e => this._patch('ui_mode', e.target.value)}>
+37
View File
@@ -11,6 +11,8 @@ export class SetupPage extends I18nMixin(LightElement) {
_confirm: { state: true },
_encrypted: { state: true },
_locale: { state: true },
_profiles: { state: true },
_profile: { state: true },
_error: { state: true },
_busy: { state: true },
};
@@ -23,10 +25,29 @@ export class SetupPage extends I18nMixin(LightElement) {
this._confirm = '';
this._encrypted = true;
this._locale = getLocale();
this._profiles = [];
this._profile = 'family';
this._error = null;
this._busy = false;
}
connectedCallback() {
super.connectedCallback();
this._loadProfiles();
}
async _loadProfiles() {
try {
const res = await fetch('/api/setup/profiles');
if (!res.ok) return;
const list = await res.json();
if (Array.isArray(list) && list.length) {
this._profiles = list;
this._profile = list[0].id;
}
} catch { /* one preset ships; a failed fetch just keeps the default */ }
}
_submit(e) {
e.preventDefault();
if (this._busy) return;
@@ -60,6 +81,7 @@ export class SetupPage extends I18nMixin(LightElement) {
password: this._password,
encrypted: this._encrypted,
locale: this._locale,
profile: this._profile,
}),
});
if (!res.ok) {
@@ -92,6 +114,21 @@ export class SetupPage extends I18nMixin(LightElement) {
${this._error ? html`<div class="setup-error">${this._error}</div>` : null}
${this._profiles.length > 1 ? html`
<div class="mb-3">
<label class="form-label">${t('setup.profile')}</label>
<select
class="form-select"
.value=${this._profile}
@change=${e => this._profile = e.target.value}
?disabled=${this._busy}>
${this._profiles.map(p => html`
<option value=${p.id} ?selected=${this._profile === p.id}>${p.label}</option>
`)}
</select>
</div>
` : null}
<div class="mb-3">
<label class="form-label">${t('login.username')}</label>
<input