feat(auth): login, roles, user mgmt, setup wizard, and session guard
- New skald-setup crate: interactive first-run wizard that creates the admin user, prompts for encryption choice and password - Auth system: session-based login/logout with cookie, guard middleware - Roles API: CRUD for data-driven roles, seeded on first boot - Users management API: create, list, edit, delete users - Setup state API: check if first admin has been created - Frontend: login-page, setup-page, users-page, roles-page, profile-page components with corresponding CSS - Topbar: avatar dropdown with profile link and logout - Sidebar: nav entries for Users and Roles (admin only) - Page shell CSS: layout support for the new pages - build.sh: builds both skald and skald-setup binaries - run.sh: runs skald-setup before the server loop - CLAUDE.md: updated workspace layout and build/run docs
This commit is contained in:
+40
@@ -9,6 +9,9 @@ import { ModelsImageSection } from './components/models-image.js';
|
||||
import { ModelsTtsSection } from './components/models-tts.js';
|
||||
import { TasksPage } from './components/tasks/index.js';
|
||||
import { AgentsPage } from './components/agents.js';
|
||||
import { UsersPage } from './components/users-page.js';
|
||||
import { RolesPage } from './components/roles-page.js';
|
||||
import { ProfilePage } from './components/profile-page.js';
|
||||
import { ApprovalGroupsPage } from './components/approval-groups.js';
|
||||
import { ApprovalRulesPage } from './components/approval-rules.js';
|
||||
import { ConfigPage } from './components/config-page.js';
|
||||
@@ -20,6 +23,8 @@ import { SessionDetailPage } from './components/session-detail.js';
|
||||
import { TicSessionsPage } from './components/tic-sessions.js';
|
||||
import { ProjectsPage } from './components/projects/index.js';
|
||||
import { FileViewerPage } from './components/file-viewer-page.js';
|
||||
import { SetupPage } from './components/setup-page.js';
|
||||
import { LoginPage } from './components/login-page.js';
|
||||
|
||||
// Register the global `openFile(path)` helper (window.openFile → location.hash).
|
||||
import './lib/open-file.js';
|
||||
@@ -35,6 +40,9 @@ customElements.define('models-image-section', ModelsImageSection);
|
||||
customElements.define('models-tts-section', ModelsTtsSection);
|
||||
customElements.define('tasks-page', TasksPage);
|
||||
customElements.define('agents-page', AgentsPage);
|
||||
customElements.define('users-page', UsersPage);
|
||||
customElements.define('roles-page', RolesPage);
|
||||
customElements.define('profile-page', ProfilePage);
|
||||
customElements.define('approval-groups-page', ApprovalGroupsPage);
|
||||
customElements.define('approval-rules-page', ApprovalRulesPage);
|
||||
customElements.define('config-page', ConfigPage);
|
||||
@@ -46,9 +54,41 @@ customElements.define('session-detail-page', SessionDetailPage);
|
||||
customElements.define('tic-sessions-page', TicSessionsPage);
|
||||
customElements.define('projects-page', ProjectsPage);
|
||||
customElements.define('file-viewer-page', FileViewerPage);
|
||||
customElements.define('setup-page', SetupPage);
|
||||
customElements.define('login-page', LoginPage);
|
||||
|
||||
// Toggle the workspace placeholder when an LLM page opens/closes.
|
||||
const workspace = document.getElementById('app-workspace');
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
workspace.style.display = e.detail.page ? 'none' : 'flex';
|
||||
});
|
||||
|
||||
// ── First-run check ─────────────────────────────────────────────────────────
|
||||
// If no user exists yet, show the setup screen. Otherwise, check if we have
|
||||
// a valid session: if not, show the login screen. If logged in, show the app.
|
||||
(async () => {
|
||||
const app = document.getElementById('app');
|
||||
const setup = document.querySelector('setup-page');
|
||||
const login = document.querySelector('login-page');
|
||||
|
||||
try {
|
||||
const setupRes = await fetch('/api/setup/status');
|
||||
if (setupRes.ok) {
|
||||
const { needs_setup } = await setupRes.json();
|
||||
if (needs_setup) {
|
||||
if (app) app.style.display = 'none';
|
||||
if (setup) setup.style.display = '';
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch { /* fall through to auth check */ }
|
||||
|
||||
try {
|
||||
const meRes = await fetch('/api/auth/me');
|
||||
if (meRes.status === 401) {
|
||||
if (app) app.style.display = 'none';
|
||||
if (login) login.style.display = '';
|
||||
return;
|
||||
}
|
||||
} catch { /* show app by default */ }
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { html } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
|
||||
export class LoginPage extends LightElement {
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
_username: { state: true },
|
||||
_password: { state: true },
|
||||
_error: { state: true },
|
||||
_busy: { state: true },
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._username = '';
|
||||
this._password = '';
|
||||
this._error = null;
|
||||
this._busy = false;
|
||||
}
|
||||
|
||||
_submit(e) {
|
||||
e.preventDefault();
|
||||
if (this._busy) return;
|
||||
|
||||
this._error = null;
|
||||
|
||||
if (!this._username.trim() || !this._password) {
|
||||
this._error = 'Enter your username and password.';
|
||||
return;
|
||||
}
|
||||
|
||||
this._doLogin();
|
||||
}
|
||||
|
||||
async _doLogin() {
|
||||
this._busy = true;
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: this._username.trim(),
|
||||
password: this._password,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
this._error = 'Invalid username or password.';
|
||||
return;
|
||||
}
|
||||
// Logged in — reload into the app.
|
||||
window.location.reload();
|
||||
} catch {
|
||||
this._error = 'Network error — please try again.';
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const btnLabel = this._busy
|
||||
? html`<span class="login-spinner"></span>Signing in…`
|
||||
: 'Sign in';
|
||||
|
||||
return html`
|
||||
<div class="login-page">
|
||||
<form class="login-card" @submit=${this._submit} autocomplete="on">
|
||||
<div class="login-logo">
|
||||
<img src="/assets/icons/icon-192.png" alt="Skald" />
|
||||
</div>
|
||||
<h1 class="login-title">Welcome back</h1>
|
||||
<p class="login-subtitle">Sign in to your account.</p>
|
||||
|
||||
${this._error ? html`<div class="login-error">${this._error}</div>` : null}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
autocomplete="username"
|
||||
.value=${this._username}
|
||||
@input=${e => this._username = e.target.value}
|
||||
?disabled=${this._busy} />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
autocomplete="current-password"
|
||||
.value=${this._password}
|
||||
@input=${e => this._password = e.target.value}
|
||||
?disabled=${this._busy} />
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary login-submit" ?disabled=${this._busy}>
|
||||
${btnLabel}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
|
||||
export class ProfilePage extends LightElement {
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
_open: { state: true },
|
||||
_me: { state: true },
|
||||
_displayName: { state: true },
|
||||
_savingName: { state: true },
|
||||
_nameMsg: { state: true },
|
||||
_pwCurrent: { state: true },
|
||||
_pwNew: { state: true },
|
||||
_pwConfirm: { state: true },
|
||||
_savingPw: { state: true },
|
||||
_pwMsg: { state: true },
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._open = false;
|
||||
this._me = null;
|
||||
this._displayName = '';
|
||||
this._savingName = false;
|
||||
this._nameMsg = null;
|
||||
this._pwCurrent = '';
|
||||
this._pwNew = '';
|
||||
this._pwConfirm = '';
|
||||
this._savingPw = false;
|
||||
this._pwMsg = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === 'profile';
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
if (this._open) this._load();
|
||||
});
|
||||
}
|
||||
|
||||
async _load() {
|
||||
try {
|
||||
const res = await fetch('/api/auth/me');
|
||||
if (res.ok) {
|
||||
this._me = await res.json();
|
||||
this._displayName = this._me.display_name ?? '';
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async _saveName() {
|
||||
if (this._savingName) return;
|
||||
this._savingName = true;
|
||||
this._nameMsg = null;
|
||||
try {
|
||||
const res = await fetch('/api/auth/profile', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ display_name: this._displayName.trim() || null }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._nameMsg = { type: 'ok', text: 'Saved.' };
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._nameMsg = { type: 'err', text: e.message };
|
||||
} finally {
|
||||
this._savingName = false;
|
||||
}
|
||||
}
|
||||
|
||||
async _savePassword() {
|
||||
if (this._savingPw) return;
|
||||
this._pwMsg = null;
|
||||
if (this._pwNew.length < 4) {
|
||||
this._pwMsg = { type: 'err', text: 'Password must be at least 4 characters.' };
|
||||
return;
|
||||
}
|
||||
if (this._pwNew !== this._pwConfirm) {
|
||||
this._pwMsg = { type: 'err', text: 'Passwords do not match.' };
|
||||
return;
|
||||
}
|
||||
this._savingPw = true;
|
||||
try {
|
||||
const res = await fetch('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
current_password: this._pwCurrent || null,
|
||||
new_password: this._pwNew,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._pwMsg = { type: 'ok', text: 'Password changed.' };
|
||||
this._pwCurrent = '';
|
||||
this._pwNew = '';
|
||||
this._pwConfirm = '';
|
||||
} catch (e) {
|
||||
this._pwMsg = { type: 'err', text: e.message };
|
||||
} finally {
|
||||
this._savingPw = false;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._open) return nothing;
|
||||
const me = this._me;
|
||||
|
||||
return html`
|
||||
<div class="um-page" style="display:flex">
|
||||
<div class="um-header">
|
||||
<h2 class="um-title"><i class="bi bi-person-circle me-2"></i>Profile</h2>
|
||||
</div>
|
||||
<div style="padding:0 24px 48px;max-width:480px">
|
||||
|
||||
${me ? html`
|
||||
<div class="card mb-4" style="background:var(--card-bg);border-color:var(--card-border);border-radius:8px">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title mb-3" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.03em;color:var(--placeholder-color)">Account</h6>
|
||||
<div class="mb-2">
|
||||
<label class="form-label" style="font-size:.82rem;font-weight:600">Username</label>
|
||||
<input class="form-control" .value=${me.username} disabled />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label" style="font-size:.82rem;font-weight:600">Role</label>
|
||||
<input class="form-control" .value=${me.role_id} disabled />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
` : nothing}
|
||||
|
||||
<div class="card mb-4" style="background:var(--card-bg);border-color:var(--card-border);border-radius:8px">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title mb-3" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.03em;color:var(--placeholder-color)">Display name</h6>
|
||||
<div class="mb-3">
|
||||
<input class="form-control" placeholder="Your name"
|
||||
.value=${this._displayName}
|
||||
@input=${e => this._displayName = e.target.value} />
|
||||
</div>
|
||||
${this._nameMsg ? html`<div class="alert alert-${this._nameMsg.type === 'ok' ? 'success' : 'danger'} py-2" style="font-size:.82rem">${this._nameMsg.text}</div>` : nothing}
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._saveName()} ?disabled=${this._savingName}>
|
||||
${this._savingName ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4" style="background:var(--card-bg);border-color:var(--card-border);border-radius:8px">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title mb-3" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.03em;color:var(--placeholder-color)">Change password</h6>
|
||||
${me?.role_id === 'admin' || me?.encrypted ? html`
|
||||
<div class="mb-3">
|
||||
<label class="form-label" style="font-size:.82rem;font-weight:600">Current password</label>
|
||||
<input type="password" class="form-control" autocomplete="current-password"
|
||||
.value=${this._pwCurrent}
|
||||
@input=${e => this._pwCurrent = e.target.value} />
|
||||
</div>
|
||||
` : nothing}
|
||||
<div class="mb-3">
|
||||
<label class="form-label" style="font-size:.82rem;font-weight:600">New password</label>
|
||||
<input type="password" class="form-control" autocomplete="new-password"
|
||||
.value=${this._pwNew}
|
||||
@input=${e => this._pwNew = e.target.value} />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" style="font-size:.82rem;font-weight:600">Confirm new password</label>
|
||||
<input type="password" class="form-control" autocomplete="new-password"
|
||||
.value=${this._pwConfirm}
|
||||
@input=${e => this._pwConfirm = e.target.value} />
|
||||
</div>
|
||||
${this._pwMsg ? html`<div class="alert alert-${this._pwMsg.type === 'ok' ? 'success' : 'danger'} py-2" style="font-size:.82rem">${this._pwMsg.text}</div>` : nothing}
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._savePassword()} ?disabled=${this._savingPw}>
|
||||
${this._savingPw ? 'Changing…' : 'Change password'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
|
||||
const ADMIN_ID = 'admin';
|
||||
|
||||
export class RolesPage extends LightElement {
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
_open: { state: true },
|
||||
_roles: { state: true },
|
||||
_groups: { state: true },
|
||||
_error: { state: true },
|
||||
_modal: { state: true }, // null | { mode: 'create'|'edit', role?, form }
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._open = false;
|
||||
this._roles = null;
|
||||
this._groups = null;
|
||||
this._error = null;
|
||||
this._modal = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === 'roles';
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
if (this._open) this._load();
|
||||
});
|
||||
}
|
||||
|
||||
async _load() {
|
||||
this._error = null;
|
||||
try {
|
||||
const [rRes, gRes] = await Promise.all([
|
||||
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}`);
|
||||
this._roles = await rRes.json();
|
||||
this._groups = await gRes.json();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Modal helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
_openCreate() {
|
||||
this._modal = {
|
||||
mode: 'create',
|
||||
form: { id: '', label: '', permission_group: this._groups?.[0]?.id ?? 'default', attrs: '' },
|
||||
};
|
||||
}
|
||||
|
||||
_openEdit(role) {
|
||||
this._modal = {
|
||||
mode: 'edit',
|
||||
role,
|
||||
form: { label: role.label, permission_group: role.permission_group, attrs: role.attrs ?? '' },
|
||||
};
|
||||
}
|
||||
|
||||
_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;
|
||||
this._error = null;
|
||||
|
||||
if (mode === 'create') {
|
||||
if (!form.id.trim() || !form.label.trim()) { this._error = 'ID and label are required.'; return; }
|
||||
try {
|
||||
const res = await fetch('/api/roles', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: form.id.trim(),
|
||||
label: form.label.trim(),
|
||||
permission_group: form.permission_group,
|
||||
attrs: form.attrs.trim() || null,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._closeModal();
|
||||
await this._load();
|
||||
} catch (e) { this._error = e.message; }
|
||||
} else {
|
||||
const { role } = this._modal;
|
||||
if (!form.label.trim()) { this._error = 'Label is required.'; return; }
|
||||
try {
|
||||
const res = await fetch(`/api/roles/${role.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
label: form.label.trim(),
|
||||
permission_group: form.permission_group,
|
||||
attrs: form.attrs.trim() || null,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._closeModal();
|
||||
await this._load();
|
||||
} catch (e) { this._error = e.message; }
|
||||
}
|
||||
}
|
||||
|
||||
async _delete(role) {
|
||||
if (!confirm(`Delete role "${role.label}"?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/roles/${role.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this._load();
|
||||
} catch (e) { this._error = e.message; }
|
||||
}
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_groupLabel(groupId) {
|
||||
return this._groups?.find(g => g.id === groupId)?.name ?? groupId;
|
||||
}
|
||||
|
||||
_renderModal() {
|
||||
if (!this._modal) return nothing;
|
||||
const { mode, form, role } = this._modal;
|
||||
const title = mode === 'create' ? 'New role' : `Edit ${role.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 ${mode === 'create' ? 'bi-plus-circle' : 'bi-pencil-square'}"></i>
|
||||
<span>${title}</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}
|
||||
|
||||
${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}
|
||||
@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>
|
||||
` : nothing}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">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>
|
||||
<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}
|
||||
@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-primary" @click=${() => this._save()}>
|
||||
<i class="bi bi-check-lg me-1"></i>${mode === 'create' ? 'Create' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._open) return nothing;
|
||||
const roles = this._roles ?? [];
|
||||
const loading = this._roles === null;
|
||||
|
||||
return html`
|
||||
<div class="um-page">
|
||||
<div class="um-header">
|
||||
<h2 class="um-title"><i class="bi bi-tags me-2"></i>Roles</h2>
|
||||
<div class="um-header-right">
|
||||
<span class="um-header-count">${roles.length} role${roles.length === 1 ? '' : 's'}</span>
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._openCreate()}>
|
||||
<i class="bi bi-plus-lg me-1"></i>New role
|
||||
</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> Loading…</div>` : roles.length === 0 ? html`
|
||||
<div class="um-empty"><i class="bi bi-tags"></i><p>No roles.</p></div>
|
||||
` : html`
|
||||
<table class="um-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Label</th>
|
||||
<th>Permission group</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${roles.map(r => {
|
||||
const isAdmin = r.id === ADMIN_ID;
|
||||
return html`
|
||||
<tr>
|
||||
<td><code>${r.id}</code></td>
|
||||
<td><strong>${r.label}</strong></td>
|
||||
<td>${this._groupLabel(r.permission_group)}</td>
|
||||
<td>
|
||||
<div class="um-actions">
|
||||
<button class="um-btn-icon" title=${isAdmin ? 'Built-in role — locked' : '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'}
|
||||
?disabled=${isAdmin}
|
||||
@click=${() => !isAdmin && this._delete(r)}>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
${this._renderModal()}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { html } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
|
||||
export class SetupPage extends LightElement {
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
_username: { state: true },
|
||||
_password: { state: true },
|
||||
_confirm: { state: true },
|
||||
_encrypted: { state: true },
|
||||
_error: { state: true },
|
||||
_busy: { state: true },
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._username = '';
|
||||
this._password = '';
|
||||
this._confirm = '';
|
||||
this._encrypted = true;
|
||||
this._error = null;
|
||||
this._busy = false;
|
||||
}
|
||||
|
||||
_submit(e) {
|
||||
e.preventDefault();
|
||||
if (this._busy) return;
|
||||
|
||||
this._error = null;
|
||||
|
||||
if (!this._username.trim()) {
|
||||
this._error = 'Choose a username.';
|
||||
return;
|
||||
}
|
||||
if (this._password.length < 4) {
|
||||
this._error = 'Password must be at least 4 characters.';
|
||||
return;
|
||||
}
|
||||
if (this._password !== this._confirm) {
|
||||
this._error = 'The two passwords do not match.';
|
||||
return;
|
||||
}
|
||||
|
||||
this._doCreate();
|
||||
}
|
||||
|
||||
async _doCreate() {
|
||||
this._busy = true;
|
||||
try {
|
||||
const res = await fetch('/api/setup/user', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: this._username.trim(),
|
||||
password: this._password,
|
||||
encrypted: this._encrypted,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
this._error = txt || `Request failed (${res.status}).`;
|
||||
return;
|
||||
}
|
||||
// First user created — reload into the app.
|
||||
window.location.reload();
|
||||
} catch {
|
||||
this._error = 'Network error — please try again.';
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const btnLabel = this._busy
|
||||
? html`<span class="setup-spinner"></span>Creating…`
|
||||
: 'Create account';
|
||||
|
||||
return html`
|
||||
<div class="setup-page">
|
||||
<form class="setup-card" @submit=${this._submit} autocomplete="off">
|
||||
<div class="setup-logo">
|
||||
<img src="/assets/icons/icon-192.png" alt="Skald" />
|
||||
</div>
|
||||
<h1 class="setup-title">Welcome to Skald</h1>
|
||||
<p class="setup-subtitle">Create the admin account to get started.</p>
|
||||
|
||||
${this._error ? html`<div class="setup-error">${this._error}</div>` : null}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
.value=${this._username}
|
||||
@input=${e => this._username = e.target.value}
|
||||
?disabled=${this._busy}
|
||||
required />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
.value=${this._password}
|
||||
@input=${e => this._password = e.target.value}
|
||||
?disabled=${this._busy}
|
||||
required />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Confirm password</label>
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
.value=${this._confirm}
|
||||
@input=${e => this._confirm = e.target.value}
|
||||
?disabled=${this._busy}
|
||||
required />
|
||||
</div>
|
||||
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="encrypt-chk"
|
||||
.checked=${this._encrypted}
|
||||
@change=${e => this._encrypted = e.target.checked}
|
||||
?disabled=${this._busy} />
|
||||
<label class="form-check-label" for="encrypt-chk">
|
||||
Encrypt my conversation history
|
||||
</label>
|
||||
</div>
|
||||
|
||||
${this._encrypted ? html`
|
||||
<div class="setup-warn">
|
||||
<strong>Warning:</strong> your password derives the encryption key.
|
||||
If you forget it, <strong>your entire conversation history will be
|
||||
permanently lost</strong> — there is no recovery.
|
||||
</div>
|
||||
` : null}
|
||||
|
||||
<button type="submit" class="btn btn-primary setup-submit" ?disabled=${this._busy}>
|
||||
${btnLabel}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -106,7 +106,7 @@ export class AppSidebar extends LightElement {
|
||||
// Segment ends at the first `/` (e.g. `#session/123`) or `?` (e.g. `#file_viewer?path=...`).
|
||||
const match = hash.match(/^([^/?]+)/);
|
||||
const segment = match ? match[1] : '';
|
||||
return ['inbox', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'config', 'llm-requests', 'session', 'tic', 'file_viewer'].includes(segment) ? segment : 'home';
|
||||
return ['inbox', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'profile', 'config', 'llm-requests', 'session', 'tic', 'file_viewer'].includes(segment) ? segment : 'home';
|
||||
}
|
||||
|
||||
_tasksSectionFromHash() {
|
||||
@@ -276,6 +276,16 @@ export class AppSidebar extends LightElement {
|
||||
<i class="bi bi-people"></i>
|
||||
<span class="sidebar-link-name">Agents</span>
|
||||
</a>
|
||||
<a href="#" class="sidebar-link ${this._activePage === 'users' ? 'active' : ''}"
|
||||
@click=${(e) => this._togglePage('users', e)}>
|
||||
<i class="bi bi-person-badge"></i>
|
||||
<span class="sidebar-link-name">Users</span>
|
||||
</a>
|
||||
<a href="#" class="sidebar-link ${this._activePage === 'roles' ? 'active' : ''}"
|
||||
@click=${(e) => this._togglePage('roles', e)}>
|
||||
<i class="bi bi-tags"></i>
|
||||
<span class="sidebar-link-name">Roles</span>
|
||||
</a>
|
||||
<a href="#" class="sidebar-link ${this._activePage === 'config' ? 'active' : ''}"
|
||||
@click=${(e) => this._togglePage('config', e)}>
|
||||
<i class="bi bi-gear"></i>
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { html } from 'lit';
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
|
||||
export class AppTopbar extends LightElement {
|
||||
static properties = {
|
||||
_theme: { state: true },
|
||||
_theme: { state: true },
|
||||
_copilotCollapsed: { state: true },
|
||||
_menuOpen: { state: true },
|
||||
_me: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._theme = document.documentElement.getAttribute('data-bs-theme') ?? 'light';
|
||||
this._copilotCollapsed = false;
|
||||
this._menuOpen = false;
|
||||
this._me = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
@@ -18,6 +22,19 @@ export class AppTopbar extends LightElement {
|
||||
window.addEventListener('copilot-collapsed', (e) => {
|
||||
this._copilotCollapsed = e.detail.collapsed;
|
||||
});
|
||||
this._loadMe();
|
||||
document.addEventListener('click', (e) => {
|
||||
if (this._menuOpen && !e.composedPath().includes(this)) {
|
||||
this._menuOpen = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async _loadMe() {
|
||||
try {
|
||||
const res = await fetch('/api/auth/me');
|
||||
if (res.ok) this._me = await res.json();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
_toggleTheme() {
|
||||
@@ -27,6 +44,27 @@ export class AppTopbar extends LightElement {
|
||||
localStorage.setItem('theme', next);
|
||||
}
|
||||
|
||||
_toggleMenu(e) {
|
||||
e.stopPropagation();
|
||||
this._menuOpen = !this._menuOpen;
|
||||
}
|
||||
|
||||
_goProfile() {
|
||||
this._menuOpen = false;
|
||||
history.pushState({ page: 'profile' }, '', '#profile');
|
||||
window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page: 'profile' } }));
|
||||
}
|
||||
|
||||
_logout() {
|
||||
this._menuOpen = false;
|
||||
fetch('/api/auth/logout', { method: 'POST' }).then(() => window.location.reload());
|
||||
}
|
||||
|
||||
get _initial() {
|
||||
const name = this._me?.display_name || this._me?.username || '?';
|
||||
return name.charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
render() {
|
||||
const isDark = this._theme === 'dark';
|
||||
return html`
|
||||
@@ -42,6 +80,25 @@ export class AppTopbar extends LightElement {
|
||||
@click=${() => this._toggleTheme()}>
|
||||
<i class="bi ${isDark ? 'bi-sun' : 'bi-moon-stars'}"></i>
|
||||
</button>
|
||||
<div class="topbar-profile-wrapper">
|
||||
<button class="topbar-avatar" title="Account" @click=${(e) => this._toggleMenu(e)}>
|
||||
${this._initial}
|
||||
</button>
|
||||
${this._menuOpen ? html`
|
||||
<div class="topbar-dropdown">
|
||||
<div class="topbar-dropdown-header">
|
||||
<div class="topbar-dropdown-name">${this._me?.display_name || this._me?.username || ''}</div>
|
||||
<div class="topbar-dropdown-sub">@${this._me?.username || ''}</div>
|
||||
</div>
|
||||
<button class="topbar-dropdown-item" @click=${() => this._goProfile()}>
|
||||
<i class="bi bi-person"></i> Profile
|
||||
</button>
|
||||
<button class="topbar-dropdown-item topbar-dropdown-logout" @click=${() => this._logout()}>
|
||||
<i class="bi bi-box-arrow-right"></i> Logout
|
||||
</button>
|
||||
</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
|
||||
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 }
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._open = false;
|
||||
this._users = null;
|
||||
this._roles = null;
|
||||
this._error = null;
|
||||
this._modal = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === 'users';
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
if (this._open) this._load();
|
||||
});
|
||||
}
|
||||
|
||||
async _load() {
|
||||
this._error = null;
|
||||
try {
|
||||
const [uRes, rRes] = await Promise.all([
|
||||
fetch('/api/users'),
|
||||
fetch('/api/roles'),
|
||||
]);
|
||||
if (!uRes.ok) throw new Error(`Users: HTTP ${uRes.status}`);
|
||||
if (!rRes.ok) throw new Error(`Roles: HTTP ${rRes.status}`);
|
||||
this._users = await uRes.json();
|
||||
this._roles = await rRes.json();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Modal helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
_openCreate() {
|
||||
this._modal = {
|
||||
mode: 'create',
|
||||
form: { username: '', display_name: '', role_id: this._roles?.[0]?.id ?? '', password: '', encrypted: false },
|
||||
};
|
||||
}
|
||||
|
||||
_openEdit(user) {
|
||||
this._modal = {
|
||||
mode: 'edit',
|
||||
user,
|
||||
form: { username: user.username, display_name: user.display_name ?? '', role_id: user.role_id, active: user.active },
|
||||
};
|
||||
}
|
||||
|
||||
_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;
|
||||
this._error = null;
|
||||
|
||||
if (mode === 'create') {
|
||||
if (!form.username.trim() || !form.password) { this._error = 'Username and password are required.'; 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,
|
||||
}),
|
||||
});
|
||||
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 = 'Username is required.'; 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,
|
||||
}),
|
||||
});
|
||||
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 = 'Password must not be 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(`Delete user "${user.username}"? This permanently erases their database and all conversation history.`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/users/${user.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this._load();
|
||||
} catch (e) { this._error = e.message; }
|
||||
}
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_roleLabel(roleId) {
|
||||
return this._roles?.find(r => r.id === roleId)?.label ?? roleId;
|
||||
}
|
||||
|
||||
_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}`;
|
||||
|
||||
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 ${mode === 'create' ? 'bi-person-plus' : mode === 'edit' ? 'bi-pencil-square' : 'bi-key'}"></i>
|
||||
<span>${title}</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}
|
||||
|
||||
${mode === 'create' ? html`
|
||||
<div class="mb-3">
|
||||
<label class="form-label">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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
` : nothing}
|
||||
` : mode === 'edit' ? html`
|
||||
<div class="mb-3">
|
||||
<label class="form-label">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>
|
||||
<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>
|
||||
<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="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">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.
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">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-primary" @click=${() => this._save()}>
|
||||
<i class="bi bi-check-lg me-1"></i>${mode === 'create' ? 'Create' : mode === 'edit' ? 'Save' : 'Reset'}
|
||||
</button>
|
||||
</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>Users</h2>
|
||||
<div class="um-header-right">
|
||||
<span class="um-header-count">${users.length} user${users.length === 1 ? '' : 's'}</span>
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._openCreate()}>
|
||||
<i class="bi bi-plus-lg me-1"></i>New user
|
||||
</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> Loading…</div>` : users.length === 0 ? html`
|
||||
<div class="um-empty"><i class="bi bi-people"></i><p>No users.</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></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">Encrypted</span>`
|
||||
: html`<span class="um-badge um-badge-clear">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>
|
||||
<td>
|
||||
<div class="um-actions">
|
||||
<button class="um-btn-icon" title="Reset password" @click=${() => this._openPassword(u)}>
|
||||
<i class="bi bi-key"></i>
|
||||
</button>
|
||||
<button class="um-btn-icon" title="Edit" @click=${() => this._openEdit(u)}>
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button class="um-btn-icon" title="Delete" @click=${() => this._delete(u)}>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`)}
|
||||
</tbody>
|
||||
</table>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
${this._renderModal()}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,18 @@ file-viewer-page {
|
||||
border-right: 1px solid var(--toolbar-border, #e5e9f0);
|
||||
}
|
||||
|
||||
users-page,
|
||||
roles-page,
|
||||
profile-page {
|
||||
display: none; /* toggled by JS */
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid var(--toolbar-border, #e5e9f0);
|
||||
}
|
||||
|
||||
/* ── Workspace placeholder ──────────────────────────────────────────────────── */
|
||||
|
||||
.app-workspace {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
.setup-page,
|
||||
.login-page {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bs-body-bg);
|
||||
overflow: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.setup-card,
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--card-shadow);
|
||||
padding: 36px 32px;
|
||||
}
|
||||
|
||||
.setup-logo,
|
||||
.login-logo {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.setup-logo img,
|
||||
.login-logo img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.setup-title,
|
||||
.login-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.setup-subtitle,
|
||||
.login-subtitle {
|
||||
text-align: center;
|
||||
color: var(--placeholder-color);
|
||||
font-size: .9rem;
|
||||
margin: 0 0 28px;
|
||||
}
|
||||
|
||||
.setup-error,
|
||||
.login-error {
|
||||
background: rgba(220, 53, 69, .1);
|
||||
border: 1px solid rgba(220, 53, 69, .35);
|
||||
border-radius: var(--card-radius);
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 18px;
|
||||
font-size: .85rem;
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.setup-warn {
|
||||
background: rgba(255, 193, 7, .1);
|
||||
border: 1px solid rgba(255, 193, 7, .35);
|
||||
border-radius: var(--card-radius);
|
||||
padding: 12px 14px;
|
||||
margin-top: 18px;
|
||||
font-size: .82rem;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
.setup-warn strong {
|
||||
color: #b8860b;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .setup-warn strong {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.setup-submit,
|
||||
.login-submit {
|
||||
width: 100%;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.setup-spinner,
|
||||
.login-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
animation: setup-spin .65s linear infinite;
|
||||
vertical-align: -3px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
@keyframes setup-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.login-spinner {
|
||||
animation-name: setup-spin;
|
||||
}
|
||||
@@ -69,3 +69,92 @@ app-topbar {
|
||||
.topbar-copilot-btn i {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* ── Profile avatar + dropdown ─────────────────────────────────────────────── */
|
||||
|
||||
.topbar-profile-wrapper {
|
||||
position: relative;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.topbar-avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, transform 0.1s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.topbar-avatar:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.topbar-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
min-width: 200px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--card-shadow);
|
||||
padding: 6px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.topbar-dropdown-header {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.topbar-dropdown-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.topbar-dropdown-sub {
|
||||
font-size: 0.75rem;
|
||||
color: var(--placeholder-color);
|
||||
}
|
||||
|
||||
.topbar-dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--bs-body-color);
|
||||
font-size: 0.82rem;
|
||||
padding: 8px 10px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.topbar-dropdown-item:hover {
|
||||
background: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
.topbar-dropdown-item i {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.topbar-dropdown-logout {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.topbar-dropdown-logout:hover {
|
||||
background: rgba(220, 53, 69, 0.08);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/* ── Users & Roles pages (shared) ───────────────────────────────────────────── */
|
||||
|
||||
.um-page {
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.um-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 24px 12px;
|
||||
}
|
||||
|
||||
.um-title {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.um-header-right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.um-header-count {
|
||||
font-size: .82rem;
|
||||
color: var(--placeholder-color);
|
||||
}
|
||||
|
||||
.um-table-wrap {
|
||||
padding: 0 24px 24px;
|
||||
}
|
||||
|
||||
.um-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: .88rem;
|
||||
}
|
||||
|
||||
.um-table th {
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
font-weight: 600;
|
||||
font-size: .78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .03em;
|
||||
color: var(--placeholder-color);
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
}
|
||||
|
||||
.um-table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
}
|
||||
|
||||
.um-table tr:hover td {
|
||||
background: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
.um-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: .72rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.um-badge-encrypted { background: rgba(99, 102, 241, .15); color: #6366f1; }
|
||||
.um-badge-clear { background: rgba(108, 117, 125, .15); color: #6c757d; }
|
||||
.um-badge-active { background: rgba(25, 135, 84, .15); color: #198754; }
|
||||
.um-badge-inactive { background: rgba(220, 53, 69, .15); color: #dc3545; }
|
||||
|
||||
.um-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.um-btn-icon {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--placeholder-color);
|
||||
font-size: .95rem;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
|
||||
.um-btn-icon:hover { background: var(--bs-tertiary-bg); color: var(--bs-body-color); }
|
||||
.um-btn-icon:disabled { opacity: .3; cursor: not-allowed; }
|
||||
|
||||
/* ── Modal form ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.um-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
background: rgba(0, 0, 0, .4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.um-modal {
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.um-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.um-modal-body { padding: 20px; }
|
||||
.um-modal-footer { padding: 12px 20px; display: flex; justify-content: flex-end; gap: 8px; }
|
||||
|
||||
.um-empty {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
color: var(--placeholder-color);
|
||||
}
|
||||
@@ -61,6 +61,8 @@
|
||||
<link rel="stylesheet" href="css/agent-inbox.css" />
|
||||
<link rel="stylesheet" href="css/home.css" />
|
||||
<link rel="stylesheet" href="css/llm-requests.css" />
|
||||
<link rel="stylesheet" href="css/setup-page.css" />
|
||||
<link rel="stylesheet" href="css/users-roles.css" />
|
||||
<link rel="stylesheet" href="css/projects/base.css" />
|
||||
<link rel="stylesheet" href="css/projects/board.css" />
|
||||
<link rel="stylesheet" href="css/file-viewer.css" />
|
||||
@@ -88,6 +90,9 @@
|
||||
<app-sidebar></app-sidebar>
|
||||
<div class="app-workspace" id="app-workspace"></div>
|
||||
<agents-page></agents-page>
|
||||
<users-page></users-page>
|
||||
<roles-page></roles-page>
|
||||
<profile-page style="display:none"></profile-page>
|
||||
<llm-providers-page></llm-providers-page>
|
||||
<models-hub-page></models-hub-page>
|
||||
<tasks-page></tasks-page>
|
||||
@@ -105,6 +110,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<setup-page style="display:none"></setup-page>
|
||||
<login-page style="display:none"></login-page>
|
||||
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user