All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
368 lines
19 KiB
JavaScript
368 lines
19 KiB
JavaScript
/**
|
|
* Admin > System > Packages
|
|
*
|
|
* Full package lifecycle: list, install (upload + registry), enable/disable,
|
|
* settings, export, delete. Uses /api/v1/admin/packages endpoints.
|
|
*/
|
|
const { html } = window;
|
|
const { useState, useEffect, useCallback } = hooks;
|
|
import { Dropdown } from '../../primitives/dropdown.js';
|
|
|
|
const TYPE_OPTIONS = ['all', 'surface', 'extension', 'full', 'workflow', 'library'];
|
|
const CORE_IDS = new Set(['admin']);
|
|
|
|
function typeBadge(type) {
|
|
const cls = type === 'surface' ? 'badge-active'
|
|
: type === 'extension' ? 'badge-pending' : '';
|
|
return html`<span class="badge ${cls}">${type || '?'}</span>`;
|
|
}
|
|
|
|
function tierBadge(tier) {
|
|
const cls = tier === 'browser' ? 'badge-active' : tier === 'starlark' ? 'badge-pending' : '';
|
|
return html`<span class="badge ${cls}">${tier || 'unknown'}</span>`;
|
|
}
|
|
|
|
function sourceBadge(source) {
|
|
if (!source || source === 'extension') return null;
|
|
const cls = source === 'core' ? 'badge-active' : source === 'registry' ? 'badge-pending' : '';
|
|
return html`<span class="badge ${cls}">${source}</span>`;
|
|
}
|
|
|
|
function statusBadge(status) {
|
|
if (status === 'dormant') return html`<span class="badge" style="opacity:0.6;">dormant</span>`;
|
|
if (status === 'pending_review') return html`<span class="badge" style="background:var(--warning,#e6a817);color:#000;">review</span>`;
|
|
if (status === 'suspended') return html`<span class="badge" style="background:var(--danger,#d33);color:#fff;">suspended</span>`;
|
|
return null;
|
|
}
|
|
|
|
export default function PackagesSection() {
|
|
const [packages, setPackages] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [typeFilter, setTypeFilter] = useState('all');
|
|
const [expandedId, setExpandedId] = useState(null);
|
|
const [pkgSettings, setPkgSettings] = useState(null);
|
|
const [settingsDraft, setSettingsDraft] = useState({});
|
|
const [registryOpen, setRegistryOpen] = useState(false);
|
|
const [registryPkgs, setRegistryPkgs] = useState([]);
|
|
const [registryLoading, setRegistryLoading] = useState(false);
|
|
const [installing, setInstalling] = useState(false);
|
|
const [permsId, setPermsId] = useState(null); // v0.5.0: permissions drawer
|
|
const [perms, setPerms] = useState([]);
|
|
|
|
const BASE = window.__BASE__ || '';
|
|
|
|
// ── Load packages ───────────────────────────
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const opts = typeFilter !== 'all' ? { type: typeFilter } : undefined;
|
|
const data = await sw.api.admin.packages.list(opts);
|
|
setPackages(data || []);
|
|
} catch (e) { sw.toast(e.message, 'error'); }
|
|
finally { setLoading(false); }
|
|
}, [typeFilter]);
|
|
|
|
useEffect(() => { setLoading(true); load(); }, [typeFilter]);
|
|
|
|
// ── Actions ─────────────────────────────────
|
|
async function toggleEnabled(pkg) {
|
|
if (CORE_IDS.has(pkg.id)) return;
|
|
if (pkg.status === 'dormant') {
|
|
sw.toast('This package requires chat — it cannot be enabled yet', 'error');
|
|
return;
|
|
}
|
|
try {
|
|
if (pkg.enabled) {
|
|
await sw.api.admin.packages.disable(pkg.id);
|
|
sw.toast(`${pkg.title || pkg.id} disabled`, 'success');
|
|
} else {
|
|
await sw.api.admin.packages.enable(pkg.id);
|
|
sw.toast(`${pkg.title || pkg.id} enabled`, 'success');
|
|
}
|
|
load();
|
|
} catch (e) { sw.toast(e.message, 'error'); }
|
|
}
|
|
|
|
async function deletePkg(pkg) {
|
|
if (pkg.source === 'core') return;
|
|
const ok = await sw.confirm(`Delete "${pkg.title || pkg.id}"? This cannot be undone.`, true);
|
|
if (!ok) return;
|
|
try {
|
|
await sw.api.admin.packages.del(pkg.id);
|
|
sw.toast('Package deleted', 'success');
|
|
if (expandedId === pkg.id) { setExpandedId(null); setPkgSettings(null); }
|
|
load();
|
|
} catch (e) { sw.toast(e.message, 'error'); }
|
|
}
|
|
|
|
function uploadPackage() {
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.accept = '.pkg,.surface,.zip';
|
|
input.onchange = async () => {
|
|
const file = input.files[0];
|
|
if (!file) return;
|
|
setInstalling(true);
|
|
try {
|
|
await sw.api.admin.packages.install(file);
|
|
sw.toast('Package installed', 'success');
|
|
load();
|
|
} catch (e) { sw.toast(e.message, 'error'); }
|
|
finally { setInstalling(false); }
|
|
};
|
|
input.click();
|
|
}
|
|
|
|
function exportPkg(id) {
|
|
window.open(`${BASE}/api/v1/admin/packages/${id}/export`, '_blank');
|
|
}
|
|
|
|
// ── Settings drawer ─────────────────────────
|
|
async function toggleSettings(pkgId) {
|
|
if (expandedId === pkgId) {
|
|
setExpandedId(null); setPkgSettings(null); setSettingsDraft({});
|
|
return;
|
|
}
|
|
try {
|
|
const data = await sw.api.admin.packages.settings(pkgId);
|
|
const settings = data?.data || {};
|
|
setPkgSettings(settings);
|
|
setSettingsDraft({ ...settings });
|
|
setExpandedId(pkgId);
|
|
} catch (e) {
|
|
sw.toast(e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function saveSettings(pkgId) {
|
|
try {
|
|
await sw.api.admin.packages.updateSettings(pkgId, settingsDraft);
|
|
sw.toast('Settings saved', 'success');
|
|
setPkgSettings({ ...settingsDraft });
|
|
} catch (e) { sw.toast(e.message, 'error'); }
|
|
}
|
|
|
|
// ── Permissions drawer (v0.5.0) ────────────
|
|
async function togglePerms(pkgId) {
|
|
if (permsId === pkgId) { setPermsId(null); setPerms([]); return; }
|
|
try {
|
|
const data = await sw.api.admin.packages.permissions(pkgId);
|
|
setPerms((data && data.data) || data || []);
|
|
setPermsId(pkgId);
|
|
// Close settings if open
|
|
if (expandedId === pkgId) { setExpandedId(null); setPkgSettings(null); }
|
|
} catch (e) { sw.toast(e.message, 'error'); }
|
|
}
|
|
|
|
async function grantPerm(pkgId, perm) {
|
|
try {
|
|
await sw.api.admin.packages.grantPerm(pkgId, perm);
|
|
const data = await sw.api.admin.packages.permissions(pkgId);
|
|
setPerms((data && data.data) || data || []);
|
|
load();
|
|
} catch (e) { sw.toast(e.message, 'error'); }
|
|
}
|
|
|
|
async function revokePerm(pkgId, perm) {
|
|
try {
|
|
await sw.api.admin.packages.revokePerm(pkgId, perm);
|
|
const data = await sw.api.admin.packages.permissions(pkgId);
|
|
setPerms((data && data.data) || data || []);
|
|
load();
|
|
} catch (e) { sw.toast(e.message, 'error'); }
|
|
}
|
|
|
|
async function grantAllPerms(pkgId) {
|
|
try {
|
|
await sw.api.admin.packages.grantAllPerms(pkgId);
|
|
const data = await sw.api.admin.packages.permissions(pkgId);
|
|
setPerms((data && data.data) || data || []);
|
|
sw.toast('All permissions granted', 'success');
|
|
load();
|
|
} catch (e) { sw.toast(e.message, 'error'); }
|
|
}
|
|
|
|
// ── Registry ────────────────────────────────
|
|
async function toggleRegistry() {
|
|
if (registryOpen) { setRegistryOpen(false); return; }
|
|
setRegistryOpen(true);
|
|
setRegistryLoading(true);
|
|
try {
|
|
const data = await sw.api.admin.packages.registry();
|
|
setRegistryPkgs(data?.data || []);
|
|
} catch (e) {
|
|
sw.toast('Registry unavailable: ' + e.message, 'error');
|
|
setRegistryPkgs([]);
|
|
}
|
|
finally { setRegistryLoading(false); }
|
|
}
|
|
|
|
async function installFromRegistry(url) {
|
|
try {
|
|
await sw.api.admin.packages.registryInstall(url);
|
|
sw.toast('Package installed from registry', 'success');
|
|
load();
|
|
} catch (e) { sw.toast(e.message, 'error'); }
|
|
}
|
|
|
|
// ── Render ───────────────────────────────────
|
|
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
|
|
|
const enabled = packages.filter(p => p.enabled).length;
|
|
const dormant = packages.filter(p => p.status === 'dormant').length;
|
|
const hasManifestSettings = (pkg) =>
|
|
pkg.manifest?.settings || pkg.package_settings;
|
|
const hasDeclaredPerms = (pkg) =>
|
|
pkg.manifest?.permissions && pkg.manifest.permissions.length > 0;
|
|
|
|
return html`
|
|
<div>
|
|
${/* ── Stat cards ─────────────── */``}
|
|
<div class="stat-cards-grid" style="margin-bottom:16px;">
|
|
<div class="stat-card"><div class="stat-value">${packages.length}</div><div class="stat-label">Total</div></div>
|
|
<div class="stat-card"><div class="stat-value">${enabled}</div><div class="stat-label">Enabled</div></div>
|
|
${dormant > 0 && html`<div class="stat-card"><div class="stat-value">${dormant}</div><div class="stat-label">Dormant</div></div>`}
|
|
</div>
|
|
|
|
${/* ── Action bar ─────────────── */``}
|
|
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:14px;">
|
|
<${Dropdown}
|
|
value=${typeFilter}
|
|
onChange=${setTypeFilter}
|
|
options=${TYPE_OPTIONS.map(t => ({ label: t.charAt(0).toUpperCase() + t.slice(1), value: t }))}
|
|
/>
|
|
<span style="flex:1;" />
|
|
<button class="btn-small" onClick=${uploadPackage} disabled=${installing}>
|
|
${installing ? 'Installing\u2026' : 'Install Package'}
|
|
</button>
|
|
<button class="btn-small" onClick=${toggleRegistry}>
|
|
${registryOpen ? 'Close Registry' : 'Browse Registry'}
|
|
</button>
|
|
</div>
|
|
|
|
${/* ── Package list ────────────── */``}
|
|
<div class="admin-list">
|
|
${packages.length === 0 && html`<div class="empty-hint">No packages found</div>`}
|
|
${packages.map(pkg => html`
|
|
<div key=${pkg.id}>
|
|
<div class="admin-surface-row">
|
|
<div style="flex:1;min-width:0;">
|
|
<strong>${pkg.title || pkg.id}</strong>
|
|
<span class="text-muted" style="margin-left:6px;font-size:11px;">v${pkg.version || '?'}</span>
|
|
${' '}${typeBadge(pkg.type)}
|
|
${' '}${tierBadge(pkg.tier)}
|
|
${sourceBadge(pkg.source)}
|
|
${statusBadge(pkg.status)}
|
|
${pkg.is_system && html`<span class="badge" style="margin-left:4px;">system</span>`}
|
|
</div>
|
|
<div class="admin-actions-cell">
|
|
<button class="btn-small"
|
|
onClick=${() => toggleEnabled(pkg)}
|
|
disabled=${CORE_IDS.has(pkg.id) || pkg.status === 'dormant' || pkg.status === 'pending_review'}
|
|
title=${pkg.status === 'dormant' ? 'Requires chat' : pkg.status === 'pending_review' ? 'Grant permissions to activate' : ''}>
|
|
${pkg.enabled ? 'Disable' : 'Enable'}
|
|
</button>
|
|
${hasManifestSettings(pkg) && html`
|
|
<button class="btn-small" onClick=${() => toggleSettings(pkg.id)}>
|
|
${expandedId === pkg.id ? 'Close' : 'Settings'}
|
|
</button>
|
|
`}
|
|
${hasDeclaredPerms(pkg) && html`
|
|
<button class="btn-small" onClick=${() => togglePerms(pkg.id)}>
|
|
${permsId === pkg.id ? 'Close' : 'Permissions'}
|
|
</button>
|
|
`}
|
|
<button class="btn-small" onClick=${() => exportPkg(pkg.id)}>Export</button>
|
|
${pkg.source !== 'core' && !pkg.is_system && html`
|
|
<button class="btn-small btn-danger" onClick=${() => deletePkg(pkg)}>Delete</button>
|
|
`}
|
|
</div>
|
|
</div>
|
|
|
|
${/* ── Inline settings drawer ── */``}
|
|
${expandedId === pkg.id && pkgSettings != null && html`
|
|
<div style="padding:8px 12px 12px 24px;background:var(--bg-2);border-bottom:1px solid var(--border);">
|
|
${Object.keys(pkgSettings).length === 0
|
|
? html`<span class="text-muted" style="font-size:12px;">No configurable settings</span>`
|
|
: html`
|
|
${Object.entries(settingsDraft).map(([k, v]) => html`
|
|
<div key=${k} style="display:flex;gap:8px;align-items:center;margin-bottom:6px;">
|
|
<label style="min-width:120px;font-size:12px;color:var(--text-muted);">${k}</label>
|
|
<input type="text"
|
|
value=${v ?? ''}
|
|
onInput=${e => setSettingsDraft(prev => ({ ...prev, [k]: e.target.value }))}
|
|
style="flex:1;padding:4px 8px;background:var(--bg-1,var(--bg));color:var(--text);border:1px solid var(--border);border-radius:4px;font-size:12px;" />
|
|
</div>
|
|
`)}
|
|
<button class="btn-small" style="margin-top:4px;" onClick=${() => saveSettings(pkg.id)}>Save</button>
|
|
`
|
|
}
|
|
</div>
|
|
`}
|
|
|
|
${/* ── Inline permissions drawer (v0.5.0) ── */``}
|
|
${permsId === pkg.id && html`
|
|
<div style="padding:8px 12px 12px 24px;background:var(--bg-2);border-bottom:1px solid var(--border);">
|
|
${perms.length === 0
|
|
? html`<span class="text-muted" style="font-size:12px;">No permissions declared</span>`
|
|
: html`
|
|
<div style="margin-bottom:8px;">
|
|
${perms.map(p => html`
|
|
<div key=${p.permission} style="display:flex;gap:8px;align-items:center;margin-bottom:4px;">
|
|
<code style="min-width:140px;font-size:12px;">${p.permission}</code>
|
|
<button class="btn-small ${p.granted ? 'btn-danger' : 'btn-primary'}"
|
|
style="min-width:64px;font-size:11px;"
|
|
onClick=${() => p.granted ? revokePerm(pkg.id, p.permission) : grantPerm(pkg.id, p.permission)}>
|
|
${p.granted ? 'Revoke' : 'Grant'}
|
|
</button>
|
|
${p.granted && p.granted_by && html`
|
|
<span class="text-muted" style="font-size:11px;">by ${p.granted_by}</span>
|
|
`}
|
|
</div>
|
|
`)}
|
|
</div>
|
|
${perms.some(p => !p.granted) && html`
|
|
<button class="btn-small btn-primary" onClick=${() => grantAllPerms(pkg.id)}>Grant All</button>
|
|
`}
|
|
`
|
|
}
|
|
</div>
|
|
`}
|
|
</div>
|
|
`)}
|
|
</div>
|
|
|
|
${/* ── Registry panel ──────────── */``}
|
|
${registryOpen && html`
|
|
<div class="admin-section" style="margin-top:20px;padding:12px;border:1px solid var(--border);border-radius:6px;">
|
|
<h4 style="margin:0 0 8px;">Package Registry</h4>
|
|
${registryLoading
|
|
? html`<div class="settings-placeholder">Loading registry\u2026</div>`
|
|
: registryPkgs.length === 0
|
|
? html`<div class="empty-hint">No packages available in registry</div>`
|
|
: html`
|
|
<div class="admin-list">
|
|
${registryPkgs.map(rp => html`
|
|
<div class="admin-surface-row" key=${rp.id}>
|
|
<div style="flex:1;min-width:0;">
|
|
<strong>${rp.title || rp.id}</strong>
|
|
<span class="text-muted" style="margin-left:6px;font-size:11px;">v${rp.version || '?'}</span>
|
|
${rp.description && html`
|
|
<div class="text-muted" style="font-size:11px;margin-top:2px;">${rp.description}</div>
|
|
`}
|
|
</div>
|
|
<div class="admin-actions-cell">
|
|
${typeBadge(rp.type)}
|
|
<button class="btn-small btn-primary"
|
|
onClick=${() => installFromRegistry(rp.download_url)}>Install</button>
|
|
</div>
|
|
</div>
|
|
`)}
|
|
</div>
|
|
`
|
|
}
|
|
</div>
|
|
`}
|
|
</div>
|
|
`;
|
|
}
|