feat(plugins): plugin pages, per-user config, capabilities gate, mobile/telegram refactors

- Plugin HTTP routes + web pages (plugin-page-host, plugin-catalog, plugin-detail)
- Plugin access grants + per-user config (DB tables + API + frontend forms)
- Capabilities-based guard (caps.rs) replacing role-id checks
- Mobile connector: message routing, payload types, router refactor
- Telegram bot: auth flow, event handling improvements
- Honcho plugin: substantial rework
- Sidebar: plugin pages integration, role-driven visibility
- i18n: new strings for plugins, connectors, capabilities
- Remove unused mascot asset
This commit is contained in:
2026-07-19 20:47:09 +01:00
parent f85876350e
commit ba911ae8cb
50 changed files with 3186 additions and 305 deletions
@@ -0,0 +1,51 @@
// Shared helpers for the mobile-connector console fragments.
//
// Served at `/api/plugin/mobile-connector/web/common.js` and imported by the
// two page fragments via a relative `./common.js` specifier. Everything the
// fragments need is self-contained here — the host injects no APIs (see
// `Plugin::web_pages` contract): they talk only to `/api/plugin/<id>/…` and,
// for the user directory used by the reassign dropdown, the host `/api/users`
// (the fragment runs with the logged-in admin's full session privileges).
import { LitElement } from 'lit';
/// JSON fetch that throws the server's error text on non-2xx and tolerates an
/// empty (204) body.
export async function jf(url, opts = {}) {
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
...opts,
});
if (!res.ok) {
const txt = await res.text().catch(() => '');
throw new Error(txt || `HTTP ${res.status}`);
}
if (res.status === 204) return null;
const ct = res.headers.get('content-type') || '';
return ct.includes('application/json') ? res.json() : res.text();
}
/// Base for the console fragments: renders into light DOM (so Bootstrap classes
/// and the app's theme CSS variables apply) and exposes the plugin's API root
/// from the host-set `plugin-id` attribute.
export class MobileBase extends LitElement {
createRenderRoot() { return this; }
get api() { return `/api/plugin/${this.getAttribute('plugin-id') || 'mobile-connector'}`; }
}
/// Human-friendly "time ago" for a Unix-ms timestamp (or "—" when absent).
export function ago(ms) {
if (!ms) return '—';
const s = Math.max(0, Math.floor((Date.now() - ms) / 1000));
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
/// Best-effort device label from the `device_info` JSON a phone sends on hello.
export function deviceLabel(d) {
const info = d.device_info || {};
return info.name || info.model || info.device || d.platform || 'Unknown device';
}
@@ -0,0 +1,143 @@
// Mobile-connector "Mobile devices" console (page_id `devices`).
//
// Lists every paired device with its state and bound user, and lets an admin
// reassign a device to another user (`POST /devices/bind`) or revoke it
// (`POST /devices/revoke`). The user directory for the reassign dropdown comes
// from the host `/api/users` (the fragment runs with the admin's session).
// Default-exports the element class; the host registers it.
import { html, nothing } from 'lit';
import { MobileBase, jf, ago, deviceLabel } from './common.js';
export default class MobileDevicesPage extends MobileBase {
static get properties() {
return {
_devices: { state: true }, // [] | null (loading)
_users: { state: true }, // [{id, username, display_name}]
_error: { state: true },
_pick: { state: true }, // { [pubkey]: user_id } reassign selections
};
}
constructor() {
super();
this._devices = null;
this._users = [];
this._error = null;
this._pick = {};
this._poll = null;
}
connectedCallback() {
super.connectedCallback();
this._load();
this._poll = setInterval(() => this._load(true), 5000);
}
disconnectedCallback() {
super.disconnectedCallback();
if (this._poll) { clearInterval(this._poll); this._poll = null; }
}
async _load(quiet = false) {
if (!quiet) this._error = null;
try {
const [d, u] = await Promise.all([
jf(`${this.api}/devices`),
this._users.length ? Promise.resolve({ list: this._users }) : jf('/api/users').then(list => ({ list })),
]);
this._devices = d.devices || [];
if (u.list) this._users = u.list;
} catch (e) {
if (!quiet) this._error = e.message;
}
}
_userName(id) {
const u = this._users.find(x => x.id === id);
return u ? (u.display_name || u.username) : id;
}
async _bind(pubkey) {
const user_id = this._pick[pubkey];
if (!user_id) return;
try {
await jf(`${this.api}/devices/bind`, { method: 'POST', body: JSON.stringify({ pubkey, user_id }) });
await this._load();
} catch (e) { this._error = e.message; }
}
async _revoke(pubkey) {
if (!confirm('Revoke this device? It loses access immediately.')) return;
try {
await jf(`${this.api}/devices/revoke`, { method: 'POST', body: JSON.stringify({ pubkey }) });
await this._load();
} catch (e) { this._error = e.message; }
}
render() {
const loading = this._devices === null && !this._error;
return html`
<div class="um-page">
<div class="um-header d-flex justify-content-between align-items-center">
<h2 class="um-title"><i class="bi bi-phone me-2"></i>Mobile devices</h2>
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._load()}>
<i class="bi bi-arrow-repeat me-1"></i>Refresh</button>
</div>
<div style="padding:0 1.25rem 1.5rem">
${this._error ? html`<div class="alert alert-danger py-2" style="font-size:.85rem">${this._error}</div>` : nothing}
${loading ? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> Loading…</div>` : this._renderList()}
</div>
</div>`;
}
_renderList() {
const rows = this._devices || [];
if (!rows.length) {
return html`<div class="um-empty" style="padding:1rem">
<i class="bi bi-phone"></i><p>No paired devices yet.</p>
<p style="font-size:.8rem;opacity:.7">Use the <em>Pair a device</em> page to add one.</p>
</div>`;
}
return html`
<div class="table-responsive">
<table class="table align-middle" style="font-size:.88rem">
<thead><tr>
<th>Device</th><th>State</th><th>Bound to</th><th>Last seen</th><th class="text-end">Actions</th>
</tr></thead>
<tbody>${rows.map(d => this._renderRow(d))}</tbody>
</table>
</div>`;
}
_renderRow(d) {
const authorized = d.state === 'authorized';
return html`
<tr>
<td>
<div>${deviceLabel(d)}</div>
<div class="text-body-secondary" style="font-size:.72rem; font-family:var(--font-mono,monospace)">
${d.pubkey.slice(0, 16)}…</div>
</td>
<td>
<span class="badge ${authorized ? 'text-bg-success' : 'text-bg-secondary'}">${d.state}</span>
</td>
<td>${d.bound_user ? this._userName(d.bound_user) : html`<span class="text-body-secondary">—</span>`}</td>
<td class="text-body-secondary">${ago(d.last_seen)}</td>
<td class="text-end">
<div class="d-inline-flex gap-1 align-items-center">
<select class="form-select form-select-sm" style="width:auto"
.value=${this._pick[d.pubkey] || d.bound_user || ''}
@change=${(e) => { this._pick = { ...this._pick, [d.pubkey]: e.target.value }; }}>
<option value="">Assign to…</option>
${this._users.map(u => html`<option value=${u.id}>${u.display_name || u.username}</option>`)}
</select>
<button class="btn btn-sm btn-primary"
?disabled=${!this._pick[d.pubkey] || this._pick[d.pubkey] === d.bound_user}
@click=${() => this._bind(d.pubkey)}>Bind</button>
<button class="btn btn-sm btn-outline-danger" @click=${() => this._revoke(d.pubkey)}>
<i class="bi bi-trash"></i></button>
</div>
</td>
</tr>`;
}
}
@@ -0,0 +1,112 @@
// Mobile-connector "Pair a device" console (page_id `pairing`).
//
// Opens a pairing window on the plugin (`POST /pairing`), shows the QR the phone
// scans, and counts down to expiry. A device that pairs in this window is
// auto-bound to the admin who opened it (server-side, on `ClientPaired`) — so it
// is usable on the phone immediately and can be reassigned later from the
// Devices page. Default-exports the element class; the host registers it.
import { html, nothing } from 'lit';
import { MobileBase, jf } from './common.js';
export default class MobilePairingPage extends MobileBase {
static get properties() {
return {
_session: { state: true }, // { url, code, expires_at } | null
_remain: { state: true }, // seconds until expiry
_busy: { state: true },
_error: { state: true },
};
}
constructor() {
super();
this._session = null;
this._remain = 0;
this._busy = false;
this._error = null;
this._timer = null;
}
disconnectedCallback() {
super.disconnectedCallback();
this._stopTimer();
// Best-effort close so a forgotten window does not linger.
if (this._session) jf(`${this.api}/pairing`, { method: 'DELETE' }).catch(() => {});
}
_stopTimer() { if (this._timer) { clearInterval(this._timer); this._timer = null; } }
_startTimer() {
this._stopTimer();
const tick = () => {
const remain = Math.max(0, Math.round((this._session.expires_at - Date.now()) / 1000));
this._remain = remain;
if (remain <= 0) { this._stopTimer(); }
};
tick();
this._timer = setInterval(tick, 1000);
}
async _open() {
this._busy = true;
this._error = null;
try {
this._session = await jf(`${this.api}/pairing`, { method: 'POST', body: JSON.stringify({}) });
this._startTimer();
} catch (e) {
this._error = e.message;
this._session = null;
} finally {
this._busy = false;
}
}
async _stop() {
this._stopTimer();
const had = this._session;
this._session = null;
if (had) { try { await jf(`${this.api}/pairing`, { method: 'DELETE' }); } catch { /* ignore */ } }
}
render() {
const expired = this._session && this._remain <= 0;
return html`
<div class="um-page">
<div class="um-header">
<h2 class="um-title"><i class="bi bi-qr-code me-2"></i>Pair a device</h2>
</div>
<div style="padding:0 1.25rem 1.5rem; max-width:640px">
${this._error ? html`<div class="alert alert-danger py-2" style="font-size:.85rem">${this._error}</div>` : nothing}
${!this._session ? html`
<p class="text-body-secondary" style="font-size:.9rem">
Open a pairing window, then scan the QR code with the Skald mobile app.
The device is linked to <strong>you</strong> and works immediately — you can
reassign it to another user from the <em>Mobile devices</em> page.
</p>
<button class="btn btn-primary" ?disabled=${this._busy} @click=${() => this._open()}>
<i class="bi bi-qr-code-scan me-1"></i>${this._busy ? 'Opening…' : 'Open pairing window'}
</button>
` : html`
<div class="d-flex flex-column align-items-center gap-3 p-3"
style="border:1px solid var(--border-color,#ddd); border-radius:var(--radius-md,12px)">
<img src=${this._session.url} alt="Pairing QR" width="256" height="256"
style="image-rendering:pixelated; ${expired ? 'opacity:.25' : ''}" />
${expired
? html`<div class="text-danger" style="font-size:.9rem"><i class="bi bi-clock-history me-1"></i>Window expired</div>`
: html`<div class="text-body-secondary" style="font-size:.9rem">
Scan within <strong>${this._remain}s</strong>
</div>`}
<div class="d-flex gap-2">
${expired
? html`<button class="btn btn-primary btn-sm" @click=${() => this._open()}>
<i class="bi bi-arrow-repeat me-1"></i>New code</button>`
: html`<button class="btn btn-outline-secondary btn-sm" @click=${() => this._stop()}>
<i class="bi bi-x-lg me-1"></i>Close</button>`}
</div>
</div>
`}
</div>
</div>`;
}
}