Changeset 0.37.7 (#219)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-21 18:37:39 +00:00
committed by xcaliber
parent 5f1c733002
commit b6152fbf5e
50 changed files with 2105 additions and 8989 deletions

View File

@@ -1,32 +0,0 @@
/**
* BridgeSection — thin Preact wrapper for deferred settings sections
*
* Renders a container <div> with the expected DOM IDs, then calls
* the old JS loader function so legacy code can populate it.
*
* Props:
* id — mount div ID (e.g. 'settingsTasksMount')
* loader — function to call after mount (e.g. () => _loadSettingsTasks?.())
*/
const { html } = window;
const { useEffect, useRef } = hooks;
export function BridgeSection({ id, loader }) {
const ref = useRef(null);
useEffect(() => {
if (loader) {
// Defer to next tick so the DOM node is committed
requestAnimationFrame(() => {
try { loader(); }
catch (e) { console.warn('[settings/bridge]', id, e.message); }
});
}
}, [id, loader]);
return html`
<div ref=${ref} id=${id}>
<div class="settings-placeholder">Loading\u2026</div>
</div>
`;
}

View File

@@ -0,0 +1,59 @@
/**
* Settings > Data & Privacy — export and account deletion
*/
const { html } = window;
const { useState, useCallback } = hooks;
export default function DataSection() {
const [exporting, setExporting] = useState(false);
const [deleting, setDeleting] = useState(false);
const exportData = useCallback(async () => {
setExporting(true);
try {
await sw.api.dataPortability.exportMe();
sw.toast('Export started \u2014 you will receive a download link shortly', 'success');
} catch (e) { sw.toast(e.message, 'error'); }
finally { setExporting(false); }
}, []);
const deleteAccount = useCallback(async () => {
const ok = await sw.confirm(
'Are you sure you want to delete your account? This action is permanent and cannot be undone. All your data, conversations, and settings will be permanently deleted.',
true,
);
if (!ok) return;
setDeleting(true);
try {
await sw.api.dataPortability.deleteAccount({ confirm: true });
sw.toast('Account deletion initiated', 'success');
} catch (e) { sw.toast(e.message, 'error'); }
finally { setDeleting(false); }
}, []);
return html`
<div>
<div class="settings-section">
<h3>Export Your Data</h3>
<p class="text-muted" style="font-size:13px;margin:0 0 12px;">
Download a copy of all your data including conversations, settings, and memories.
The export will be prepared and a download link will be provided.
</p>
<button class="btn-md btn-primary" disabled=${exporting} onClick=${exportData}>
${exporting ? 'Preparing\u2026' : 'Export My Data'}
</button>
</div>
<div class="settings-section" style="margin-top:24px;border-top:1px solid var(--border);padding-top:20px;">
<h3 style="color:var(--danger);">Danger Zone</h3>
<p class="text-muted" style="font-size:13px;margin:0 0 12px;">
Permanently delete your account and all associated data. This action cannot be undone.
We recommend exporting your data before proceeding.
</p>
<button class="btn-md btn-danger" disabled=${deleting} onClick=${deleteAccount}>
${deleting ? 'Deleting\u2026' : 'Delete My Account'}
</button>
</div>
</div>
`;
}

View File

@@ -0,0 +1,97 @@
/**
* Settings > Git Keys — SSH credential management
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
function fmtDate(d) {
if (!d) return '\u2014';
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
}
export default function GitKeysSection() {
const [keys, setKeys] = useState([]);
const [loading, setLoading] = useState(true);
const [showAdd, setShowAdd] = useState(false);
const load = useCallback(async () => {
try {
const data = await sw.api.git.credentials.list();
setKeys(Array.isArray(data) ? data : data.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, []);
async function addKey(e) {
e.preventDefault();
const form = e.target;
const name = form.name.value.trim();
const key = form.key.value.trim();
if (!name) { sw.toast('Name is required', 'error'); return; }
try {
await sw.api.git.credentials.create({ name, public_key: key || undefined });
sw.toast('Credential added', 'success');
form.reset();
setShowAdd(false);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function deleteKey(id) {
const ok = await sw.confirm('Delete this SSH credential?', true);
if (!ok) return;
try {
await sw.api.git.credentials.del(id);
sw.toast('Credential deleted', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<span class="text-muted" style="font-size:12px;">${keys.length} credential${keys.length !== 1 ? 's' : ''}</span>
<div style="flex:1"></div>
<button class="btn-md btn-primary" onClick=${() => setShowAdd(!showAdd)}>
${showAdd ? 'Cancel' : '+ Add Key'}
</button>
</div>
${showAdd && html`
<form class="settings-section" style="margin-bottom:16px;" onSubmit=${addKey}>
<div class="form-group">
<label>Name</label>
<input name="name" placeholder="e.g. deploy-key-prod" required />
</div>
<div class="form-group">
<label>Public Key <span class="text-muted" style="font-weight:400;">(paste, or leave blank to generate)</span></label>
<textarea name="key" rows="3" placeholder="ssh-ed25519 AAAA..." style="font-family:var(--mono);font-size:12px;" />
</div>
<button type="submit" class="btn-small btn-primary">Add Credential</button>
</form>
`}
<div class="admin-list">
${keys.length === 0 && html`<div class="empty-hint">No SSH credentials configured.</div>`}
${keys.map(k => html`
<div class="admin-surface-row" key=${k.id}>
<div style="flex:1;min-width:0;">
<strong>${k.name}</strong>
${k.fingerprint && html`
<span class="text-muted" style="margin-left:8px;font-size:11px;font-family:var(--mono);">
${k.fingerprint.substring(0, 24)}\u2026
</span>
`}
</div>
<span class="text-muted" style="font-size:11px;">${fmtDate(k.created_at)}</span>
<button class="btn-small btn-danger" onClick=${() => deleteKey(k.id)}>Delete</button>
</div>
`)}
</div>
</div>
`;
}

View File

@@ -7,39 +7,34 @@
* __PAGE_DATA__ — { BYOKEnabled, ... } from Go template
*
* Layout: topbar + left nav + content area (same CSS classes as before).
* Preact sections are loaded lazily. Bridge sections delegate to old JS.
* All sections are native Preact components loaded lazily.
*/
const { html } = window;
const { useState, useEffect, useCallback, useMemo } = hooks;
const { render } = preact;
import { BridgeSection } from './bridge-section.js';
import { ToastContainer } from '../../primitives/toast.js';
import { DialogStack } from '../../shell/dialog-stack.js';
// ── Lazy section imports ────────────────────
// Each returns a Promise<{ default: Component }> or { SectionName: Component }
const sectionModules = {
general: () => import('./general.js'),
appearance: () => import('./appearance.js'),
profile: () => import('./profile.js'),
teams: () => import('./teams.js'),
models: () => import('./models.js'),
providers: () => import('./providers.js'),
personas: () => import('./personas.js'),
roles: () => import('./roles.js'),
usage: () => import('./usage.js'),
};
// ── Bridge section config (deferred to old JS) ──
const bridgeSections = {
workflows: { id: 'settingsDynamic', loader: () => typeof loadTeamWorkflows === 'function' && loadTeamWorkflows() },
tasks: { id: 'settingsTasksMount', loader: () => typeof _loadSettingsTasks === 'function' && _loadSettingsTasks() },
gitkeys: { id: 'settingsDynamic', loader: () => typeof _loadSettingsGitKeys === 'function' && _loadSettingsGitKeys() },
data: { id: 'dpMount', loader: () => typeof loadDataPrivacy === 'function' && loadDataPrivacy() },
knowledge: { id: 'settingsDynamic', loader: () => typeof KnowledgeUI !== 'undefined' && KnowledgeUI.openManagePanel?.() },
memory: { id: 'settingsDynamic', loader: () => typeof MemoryUI !== 'undefined' && MemoryUI.openSettingsPanel?.() },
notifications: { id: 'settingsDynamic', loader: () => typeof NotifPrefs !== 'undefined' && NotifPrefs.load?.() },
general: () => import('./general.js'),
appearance: () => import('./appearance.js'),
profile: () => import('./profile.js'),
teams: () => import('./teams.js'),
models: () => import('./models.js'),
providers: () => import('./providers.js'),
personas: () => import('./personas.js'),
roles: () => import('./roles.js'),
usage: () => import('./usage.js'),
workflows: () => import('./workflows.js'),
tasks: () => import('./tasks.js'),
gitkeys: () => import('./gitkeys.js'),
data: () => import('./data.js'),
knowledge: () => import('./knowledge.js'),
memory: () => import('./memory.js'),
notifications: () => import('./notifications.js'),
};
// ── Nav structure ───────────────────────────
@@ -52,8 +47,11 @@ const NAV_ITEMS = [
{ key: 'teams', label: 'Teams' },
{ key: 'workflows', label: 'Workflows' },
{ key: 'tasks', label: 'Tasks' },
{ key: 'gitkeys', label: 'Git Keys' },
{ key: 'data', label: 'Data & Privacy' },
{ key: 'gitkeys', label: 'Git Keys' },
{ key: 'knowledge', label: 'Knowledge Bases' },
{ key: 'memory', label: 'Memory' },
{ key: 'notifications', label: 'Notifications' },
{ key: 'data', label: 'Data & Privacy' },
];
const BYOK_ITEMS = [
@@ -198,12 +196,9 @@ function SettingsSurface() {
<div class="settings-content">
<h2>${title}</h2>
${bridgeSections[section]
? html`<${BridgeSection} id=${bridgeSections[section].id}
loader=${bridgeSections[section].loader} />`
: SectionComponent
? html`<${SectionComponent} />`
: html`<div class="settings-placeholder">Loading\u2026</div>`
${SectionComponent
? html`<${SectionComponent} />`
: html`<div class="settings-placeholder">Loading\u2026</div>`
}
</div>
</div>

View File

@@ -0,0 +1,182 @@
/**
* Settings > Knowledge — personal knowledge base management
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
function fmtDate(d) {
if (!d) return '\u2014';
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
}
function formatBytes(b) {
if (!b) return '0 B';
if (b < 1024) return b + ' B';
if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
return (b / 1048576).toFixed(1) + ' MB';
}
export default function KnowledgeSection() {
const [kbs, setKbs] = useState([]);
const [loading, setLoading] = useState(true);
const [detail, setDetail] = useState(null);
const [docs, setDocs] = useState([]);
const [showCreate, setShowCreate] = useState(false);
const load = useCallback(async () => {
try {
const data = await sw.api.knowledge.list();
setKbs(Array.isArray(data) ? data : data.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, []);
async function openDetail(kb) {
setDetail(kb);
try {
const d = await sw.api.knowledge.documents(kb.id);
setDocs(Array.isArray(d) ? d : d.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
}
async function createKb(e) {
e.preventDefault();
const form = e.target;
const name = form.name.value.trim();
if (!name) { sw.toast('Name is required', 'error'); return; }
try {
await sw.api.knowledge.create({
name,
description: form.description.value.trim(),
});
sw.toast('Knowledge base created', 'success');
form.reset();
setShowCreate(false);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function deleteKb(id) {
const ok = await sw.confirm('Delete this knowledge base and all its documents?', true);
if (!ok) return;
try {
await sw.api.knowledge.del(id);
sw.toast('Knowledge base deleted', 'success');
setDetail(null);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function uploadDoc() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.txt,.md,.csv,.html,.pdf';
input.onchange = async () => {
const file = input.files[0];
if (!file) return;
try {
await sw.api.knowledge.upload(detail.id, file);
sw.toast('Document uploaded', 'success');
const d = await sw.api.knowledge.documents(detail.id);
setDocs(Array.isArray(d) ? d : d.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
};
input.click();
}
async function deleteDoc(docId) {
const ok = await sw.confirm('Delete this document?', true);
if (!ok) return;
try {
await sw.api.knowledge.delDoc(detail.id, docId);
sw.toast('Document deleted', 'success');
setDocs(prev => prev.filter(d => d.id !== docId));
} catch (e) { sw.toast(e.message, 'error'); }
}
function statusBadge(status) {
const cls = status === 'ready' ? 'badge-active' : status === 'error' ? 'badge-inactive' : 'badge';
return html`<span class="badge ${cls}">${status}</span>`;
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
// Detail view
if (detail) return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<button class="btn-small" onClick=${() => { setDetail(null); setDocs([]); }}>\u2190 Back</button>
<h4 style="margin:0;">${detail.name}</h4>
${statusBadge(detail.status || 'active')}
<div style="flex:1"></div>
<button class="btn-small" onClick=${uploadDoc}>Upload Document</button>
<button class="btn-small btn-danger" onClick=${() => deleteKb(detail.id)}>Delete KB</button>
</div>
${detail.description && html`
<p class="text-muted" style="font-size:13px;margin:0 0 8px;">${detail.description}</p>
`}
<p class="text-muted" style="font-size:12px;margin-bottom:12px;">
${detail.document_count || 0} docs \u2022 Created ${fmtDate(detail.created_at)}
</p>
<div class="admin-list">
${docs.length === 0 && html`<div class="empty-hint">No documents yet. Upload one to get started.</div>`}
${docs.map(d => html`
<div class="admin-surface-row" key=${d.id}>
<div style="flex:1;min-width:0;">
<strong>${d.filename}</strong>
<span class="text-muted" style="margin-left:8px;font-size:11px;">${formatBytes(d.size_bytes)}</span>
${' '}${statusBadge(d.status || 'pending')}
</div>
<span class="text-muted" style="font-size:11px;">${fmtDate(d.created_at)}</span>
<button class="btn-small btn-danger" onClick=${() => deleteDoc(d.id)}>Delete</button>
</div>
`)}
</div>
</div>
`;
// List view
return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<span class="text-muted" style="font-size:12px;">${kbs.length} knowledge base${kbs.length !== 1 ? 's' : ''}</span>
<div style="flex:1"></div>
<button class="btn-md btn-primary" onClick=${() => setShowCreate(!showCreate)}>
${showCreate ? 'Cancel' : '+ Create'}
</button>
</div>
${showCreate && html`
<form class="settings-section" style="margin-bottom:16px;" onSubmit=${createKb}>
<div class="form-group">
<label>Name</label>
<input name="name" placeholder="e.g. Product Documentation" required />
</div>
<div class="form-group">
<label>Description</label>
<input name="description" placeholder="Optional description" />
</div>
<button type="submit" class="btn-small btn-primary">Create Knowledge Base</button>
</form>
`}
<div class="admin-list">
${kbs.length === 0 && html`<div class="empty-hint">No knowledge bases. Create one to get started.</div>`}
${kbs.map(k => html`
<div class="admin-surface-row" key=${k.id} style="cursor:pointer;" onClick=${() => openDetail(k)}>
<div style="flex:1;min-width:0;">
<strong>${k.name}</strong>
<span class="text-muted" style="margin-left:8px;font-size:12px;">${k.document_count || 0} docs</span>
</div>
<span class="text-muted" style="font-size:11px;">${fmtDate(k.created_at)}</span>
${statusBadge(k.status || 'active')}
</div>
`)}
</div>
</div>
`;
}

View File

@@ -0,0 +1,117 @@
/**
* Settings > Memory — personal memory management
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
function fmtDate(d) {
if (!d) return '\u2014';
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
}
function truncate(s, len) {
if (!s) return '';
return s.length > len ? s.substring(0, len) + '\u2026' : s;
}
export default function MemorySection() {
const [memories, setMemories] = useState([]);
const [loading, setLoading] = useState(true);
const [count, setCount] = useState(0);
const [editingId, setEditingId] = useState(null);
const [editValue, setEditValue] = useState('');
const load = useCallback(async () => {
try {
const [data, c] = await Promise.all([
sw.api.memory.list(),
sw.api.memory.count().catch(() => null),
]);
setMemories(Array.isArray(data) ? data : data.data || []);
if (c != null) setCount(typeof c === 'number' ? c : c.count || 0);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, []);
function startEdit(m) {
setEditingId(m.id);
setEditValue(m.value || m.content || '');
}
function cancelEdit() {
setEditingId(null);
setEditValue('');
}
async function saveEdit(id) {
try {
await sw.api.memory.update(id, { value: editValue, content: editValue });
sw.toast('Memory updated', 'success');
setEditingId(null);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function deleteMemory(id) {
const ok = await sw.confirm('Delete this memory?', true);
if (!ok) return;
try {
await sw.api.memory.del(id);
sw.toast('Memory deleted', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
function statusBadge(status) {
if (!status) return null;
const cls = status === 'approved' ? 'badge-active'
: status === 'rejected' ? 'badge-inactive'
: 'badge';
return html`<span class="badge ${cls}">${status}</span>`;
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<span class="text-muted" style="font-size:12px;">${count || memories.length} memor${count === 1 ? 'y' : 'ies'}</span>
<div style="flex:1"></div>
</div>
<div class="admin-list">
${memories.length === 0 && html`<div class="empty-hint">No memories stored.</div>`}
${memories.map(m => html`
<div class="admin-surface-row" key=${m.id} style="flex-direction:column;align-items:stretch;">
${editingId === m.id ? html`
<div>
<textarea rows="3" style="width:100%;font-size:13px;margin-bottom:8px;"
value=${editValue}
onInput=${e => setEditValue(e.target.value)} />
<div style="display:flex;gap:6px;">
<button class="btn-small btn-primary" onClick=${() => saveEdit(m.id)}>Save</button>
<button class="btn-small" onClick=${cancelEdit}>Cancel</button>
</div>
</div>
` : html`
<div style="display:flex;align-items:center;gap:8px;">
<div style="flex:1;min-width:0;">
${m.key && html`<strong style="margin-right:6px;">${m.key}</strong>`}
<span style="font-size:13px;color:var(--text-2);">${truncate(m.value || m.content || '', 120)}</span>
</div>
${statusBadge(m.status)}
<span class="text-muted" style="font-size:11px;white-space:nowrap;">${fmtDate(m.created_at)}</span>
</div>
<div style="display:flex;gap:6px;margin-top:6px;">
<button class="btn-small" onClick=${() => startEdit(m)}>Edit</button>
<button class="btn-small btn-danger" onClick=${() => deleteMemory(m.id)}>Delete</button>
</div>
`}
</div>
`)}
</div>
</div>
`;
}

View File

@@ -0,0 +1,96 @@
/**
* Settings > Notifications — notification preferences
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
export default function NotificationsSection() {
const [prefs, setPrefs] = useState(null);
const [saving, setSaving] = useState({});
const load = useCallback(async () => {
try {
const data = await sw.api.notifications.prefs();
// Normalize: could be an array of { type, enabled, ... } or an object keyed by type
if (Array.isArray(data)) {
const obj = {};
data.forEach(p => { obj[p.type] = p; });
setPrefs(obj);
} else {
setPrefs(data || {});
}
} catch (e) { sw.toast(e.message, 'error'); setPrefs({}); }
}, []);
useEffect(() => { load(); }, []);
async function togglePref(type, currentEnabled) {
setSaving(prev => ({ ...prev, [type]: true }));
try {
await sw.api.notifications.setPref(type, { enabled: !currentEnabled });
setPrefs(prev => ({
...prev,
[type]: { ...prev[type], enabled: !currentEnabled },
}));
sw.toast('Preference updated', 'success');
} catch (e) { sw.toast(e.message, 'error'); }
finally { setSaving(prev => ({ ...prev, [type]: false })); }
}
async function resetPref(type) {
setSaving(prev => ({ ...prev, [type]: true }));
try {
await sw.api.notifications.delPref(type);
sw.toast('Reset to default', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
finally { setSaving(prev => ({ ...prev, [type]: false })); }
}
if (prefs === null) return html`<div class="settings-placeholder">Loading\u2026</div>`;
const types = Object.keys(prefs);
function friendlyName(type) {
return type
.replace(/_/g, ' ')
.replace(/\b\w/g, c => c.toUpperCase());
}
return html`
<div>
${types.length === 0 && html`<div class="empty-hint">No notification preferences available.</div>`}
<div class="admin-list">
${types.map(type => {
const pref = prefs[type];
const enabled = pref.enabled !== false;
const isSaving = saving[type];
return html`
<div class="admin-surface-row" key=${type}>
<div style="flex:1;min-width:0;">
<strong>${friendlyName(type)}</strong>
${pref.description && html`
<div class="text-muted" style="font-size:12px;margin-top:2px;">${pref.description}</div>
`}
</div>
<label class="toggle-label" style="margin:0;">
<input type="checkbox"
checked=${enabled}
disabled=${isSaving}
onChange=${() => togglePref(type, enabled)} />
<span class="toggle-track"></span>
</label>
<button class="btn-small btn-ghost"
disabled=${isSaving}
onClick=${() => resetPref(type)}
title="Reset to default">
Reset
</button>
</div>
`;
})}
</div>
</div>
`;
}

View File

@@ -0,0 +1,71 @@
/**
* Settings > Tasks — user's tasks with run/stop controls
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
function fmtDate(d) {
if (!d) return '\u2014';
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
}
export default function TasksSection() {
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
const data = await sw.api.tasks.list();
setTasks(Array.isArray(data) ? data : data.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, []);
async function runTask(id) {
try {
await sw.api.tasks.start(id);
sw.toast('Task started', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function stopTask(id) {
try {
await sw.api.tasks.stop(id);
sw.toast('Task stopped', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
<div class="admin-list">
${tasks.length === 0 && html`<div class="empty-hint">No tasks.</div>`}
${tasks.map(t => html`
<div class="admin-surface-row" key=${t.id}>
<div style="flex:1;min-width:0;">
<strong>${t.name}</strong>
${t.schedule && html`
<span class="badge" style="margin-left:6px;">${t.schedule}</span>
`}
</div>
<span class="text-muted" style="font-size:11px;">
last: ${fmtDate(t.last_run_at)} \u2022 next: ${fmtDate(t.next_run_at)}
</span>
<span class="badge ${t.is_active ? 'badge-active' : 'badge-inactive'}">
${t.is_active ? 'running' : 'stopped'}
</span>
<div style="display:flex;gap:6px;">
<button class="btn-small btn-primary" onClick=${() => runTask(t.id)}>Run</button>
<button class="btn-small btn-ghost" onClick=${() => stopTask(t.id)}>Stop</button>
</div>
</div>
`)}
</div>
</div>
`;
}

View File

@@ -0,0 +1,88 @@
/**
* Settings > Workflows — read-only view of user's visible workflows
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
export default function WorkflowsSection() {
const [workflows, setWorkflows] = useState([]);
const [loading, setLoading] = useState(true);
const [detail, setDetail] = useState(null);
const [stages, setStages] = useState([]);
const load = useCallback(async () => {
try {
const data = await sw.api.workflows.list();
setWorkflows(Array.isArray(data) ? data : data.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, []);
async function openDetail(wf) {
setDetail(wf);
try {
const d = await sw.api.workflows.get(wf.id);
setStages(d.stages || []);
} catch (e) { sw.toast(e.message, 'error'); setStages([]); }
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
if (detail) return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<button class="btn-small" onClick=${() => { setDetail(null); setStages([]); }}>\u2190 Back</button>
<h4 style="margin:0;">${detail.name}</h4>
<span class="badge ${detail.is_active ? 'badge-active' : 'badge-inactive'}">
${detail.is_active ? 'active' : 'inactive'}
</span>
</div>
${detail.description && html`
<p class="text-muted" style="font-size:13px;margin:0 0 12px;">${detail.description}</p>
`}
<div style="display:flex;gap:12px;margin-bottom:16px;font-size:12px;color:var(--text-3);">
<span>Slug: <strong>/${detail.slug}</strong></span>
<span>Entry: <strong>${detail.entry_mode}</strong></span>
<span>Version: <strong>${detail.version || 0}</strong></span>
</div>
<h5 style="margin:0 0 8px;">Stages (${stages.length})</h5>
<div class="admin-list">
${stages.length === 0 && html`<div class="empty-hint">No stages defined</div>`}
${stages.map((s, i) => html`
<div class="admin-surface-row" key=${s.id}>
<span class="text-muted" style="font-size:11px;width:24px;">#${i + 1}</span>
<strong style="flex:1;">${s.name || `Stage ${i + 1}`}</strong>
<span class="badge">${s.stage_mode}</span>
${s.persona_id && html`<span class="badge badge-active">persona</span>`}
</div>
`)}
</div>
</div>
`;
return html`
<div>
<div class="admin-list">
${workflows.length === 0 && html`<div class="empty-hint">No workflows available.</div>`}
${workflows.map(w => html`
<div class="admin-surface-row" key=${w.id} style="cursor:pointer;" onClick=${() => openDetail(w)}>
<div style="flex:1;min-width:0;">
<strong>${w.name}</strong>
<span class="text-muted" style="margin-left:8px;font-size:11px;">/${w.slug}</span>
</div>
<span class="badge">${w.entry_mode}</span>
<span class="badge ${w.is_active ? 'badge-active' : 'badge-inactive'}">
${w.is_active ? 'active' : 'inactive'}
</span>
<span class="text-muted" style="font-size:11px;">v${w.version || 0}</span>
</div>
`)}
</div>
</div>
`;
}

View File

@@ -0,0 +1,88 @@
/**
* Team Admin > Activity (Audit Log)
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
export default function ActivitySection({ teamId }) {
const [entries, setEntries] = useState([]);
const [actions, setActions] = useState([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [filterAction, setFilterAction] = useState('');
const [filterResource, setFilterResource] = useState('');
const [loading, setLoading] = useState(true);
const perPage = 50;
const load = useCallback(async () => {
setLoading(true);
try {
const opts = { page, per_page: perPage };
if (filterAction) opts.action = filterAction;
if (filterResource) opts.resource_type = filterResource;
const data = await sw.api.teams.audit(teamId, opts);
setEntries(data.data || []);
setTotal(data.total || 0);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId, page, filterAction, filterResource]);
useEffect(() => { load(); }, [load]);
useEffect(() => {
sw.api.teams.auditActions(teamId).then(d => setActions(d.actions || [])).catch(() => {});
}, [teamId]);
const totalPages = Math.ceil(total / perPage) || 1;
function fmtDate(d) {
if (!d) return '\u2014';
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
}
// Extract unique resource types from actions (action format: resource.verb)
const resourceTypes = [...new Set(actions.map(a => a.split('.')[0]).filter(Boolean))];
return html`
<div>
<div class="form-row" style="margin-bottom:12px;">
<div class="form-group"><label>Action</label>
<select value=${filterAction} onChange=${e => { setFilterAction(e.target.value); setPage(1); }}>
<option value="">All</option>
${actions.map(a => html`<option key=${a} value=${a}>${a}</option>`)}
</select>
</div>
<div class="form-group"><label>Resource</label>
<select value=${filterResource} onChange=${e => { setFilterResource(e.target.value); setPage(1); }}>
<option value="">All</option>
${resourceTypes.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
</select>
</div>
</div>
${loading
? html`<div class="settings-placeholder">Loading\u2026</div>`
: html`
<div class="admin-list">
${entries.length === 0 && html`<div class="empty-hint">No activity entries</div>`}
${entries.map(e => html`
<div class="admin-surface-row" key=${e.id} style="font-size:12px;">
<span style="width:140px;">${fmtDate(e.created_at)}</span>
<span style="width:100px;"><strong>${e.actor_name || '\u2014'}</strong></span>
<span class="badge" style="margin:0 4px;">${e.action}</span>
<span style="flex:1;color:var(--text-3);">${e.resource_type}/${e.resource_id?.substring(0, 8) || '\u2014'}</span>
<span class="text-muted" style="width:100px;">${e.ip_address || ''}</span>
</div>
`)}
</div>
<div style="margin-top:12px;display:flex;align-items:center;gap:8px;">
<button class="btn-small" disabled=${page <= 1} onClick=${() => setPage(p => p - 1)}>\u2190 Prev</button>
<span class="text-muted" style="font-size:12px;">Page ${page} of ${totalPages} (${total} total)</span>
<button class="btn-small" disabled=${page >= totalPages} onClick=${() => setPage(p => p + 1)}>Next \u2192</button>
</div>
`
}
</div>
`;
}

View File

@@ -0,0 +1,37 @@
/**
* Team Admin > Groups
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
export default function GroupsSection({ teamId }) {
const [groups, setGroups] = useState([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
const data = await sw.api.teams.groups(teamId);
setGroups(Array.isArray(data) ? data : data.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId]);
useEffect(() => { load(); }, [load]);
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
<div class="admin-list">
${groups.length === 0 && html`<div class="empty-hint">No groups</div>`}
${groups.map(g => html`
<div class="admin-surface-row" key=${g.id}>
<strong style="flex:1;">${g.name}</strong>
<span class="text-muted" style="font-size:12px;">${g.member_count || 0} members</span>
<span class="badge">${(g.permissions || []).length} permissions</span>
</div>
`)}
</div>
</div>
`;
}

View File

@@ -0,0 +1,229 @@
/**
* TeamAdminSurface — root team admin surface
*
* Reads globals:
* __SECTION__ — active section name (string)
* __BASE__ — base path
*
* Layout: topbar (back + team name) + sidebar nav + content area.
* All 10 sections are native Preact components loaded lazily.
*/
const { html } = window;
const { useState, useEffect, useCallback, useMemo } = hooks;
const { render } = preact;
import { ToastContainer } from '../../primitives/toast.js';
import { DialogStack } from '../../shell/dialog-stack.js';
// ── Section config ──────────────────────────
const SECTIONS = [
{ key: 'members', label: 'Members' },
{ key: 'providers', label: 'Providers' },
{ key: 'personas', label: 'Personas' },
{ key: 'knowledge', label: 'Knowledge' },
{ key: 'groups', label: 'Groups' },
{ key: 'workflows', label: 'Workflows' },
{ key: 'tasks', label: 'Tasks' },
{ key: 'settings', label: 'Settings' },
{ key: 'usage', label: 'Usage' },
{ key: 'activity', label: 'Activity' },
];
// ── Lazy section imports ────────────────────
const sectionModules = {
members: () => import('./members.js'),
providers: () => import('./providers.js'),
personas: () => import('./personas.js'),
knowledge: () => import('./knowledge.js'),
groups: () => import('./groups.js'),
workflows: () => import('./workflows.js'),
tasks: () => import('./tasks.js'),
settings: () => import('./settings.js'),
usage: () => import('./usage.js'),
activity: () => import('./activity.js'),
};
function TeamAdminSurface() {
const BASE = window.__BASE__ || '';
const section = window.__SECTION__ || 'members';
const [SectionComponent, setSectionComponent] = useState(null);
const [selectedTeam, setSelectedTeam] = useState(null);
// Determine teams this user can admin
const adminTeams = useMemo(() => {
const teams = sw.auth?.teams || [];
if (sw.isAdmin) return teams;
return teams.filter(t => t.my_role === 'admin');
}, []);
// Auto-select if only one team
useEffect(() => {
if (adminTeams.length === 1 && !selectedTeam) {
setSelectedTeam(adminTeams[0]);
}
}, [adminTeams]);
// Load section component
useEffect(() => {
setSectionComponent(null);
const loader = sectionModules[section];
if (loader) {
loader().then(mod => {
const Comp = mod.default || Object.values(mod)[0];
setSectionComponent(() => Comp);
}).catch(e => console.error('[team-admin] Failed to load section:', section, e));
}
}, [section]);
// Back button with return URL stash
useEffect(() => {
const RETURN_KEY = 'sb_team_admin_return';
if (!sessionStorage.getItem(RETURN_KEY)) {
const ref = document.referrer;
if (ref && ref.startsWith(location.origin) && !ref.includes('/team-admin')) {
sessionStorage.setItem(RETURN_KEY, ref);
} else {
sessionStorage.setItem(RETURN_KEY, location.origin + BASE + '/');
}
}
}, []);
const backClick = useCallback((e) => {
e.preventDefault();
const RETURN_KEY = 'sb_team_admin_return';
const returnURL = sessionStorage.getItem(RETURN_KEY);
sessionStorage.removeItem(RETURN_KEY);
location.href = returnURL || BASE + '/';
}, []);
const navClick = useCallback((e) => {
e.preventDefault();
const href = e.currentTarget.getAttribute('href');
if (href) location.replace(href);
}, []);
const teamId = selectedTeam?.id || selectedTeam?.team_id || null;
const teamName = selectedTeam?.name || selectedTeam?.team_name || 'Team';
const sectionLabel = SECTIONS.find(s => s.key === section)?.label || 'Members';
// Team picker if multiple teams
if (!selectedTeam && adminTeams.length > 1) {
return html`
<div class="surface-settings">
<div class="settings-topbar">
<a href="${BASE}/" class="settings-topbar-back" onClick=${backClick}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12"/>
<polyline points="12 19 5 12 12 5"/>
</svg>
Back
</a>
<div class="settings-topbar-sep"></div>
<span class="settings-topbar-title">Team Administration</span>
</div>
<div style="padding:32px;max-width:480px;margin:0 auto;">
<h3 style="margin:0 0 16px;">Select a Team</h3>
<div class="admin-list">
${adminTeams.map(t => html`
<div class="admin-surface-row" key=${t.id || t.team_id}
style="cursor:pointer;" onClick=${() => setSelectedTeam(t)}>
<strong style="flex:1;">${t.name || t.team_name}</strong>
<span class="badge">${t.my_role || 'admin'}</span>
</div>
`)}
</div>
</div>
</div>
<${ToastContainer} />
<${DialogStack} />
`;
}
// No teams
if (!selectedTeam && adminTeams.length === 0) {
return html`
<div class="surface-settings">
<div class="settings-topbar">
<a href="${BASE}/" class="settings-topbar-back" onClick=${backClick}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12"/>
<polyline points="12 19 5 12 12 5"/>
</svg>
Back
</a>
<div class="settings-topbar-sep"></div>
<span class="settings-topbar-title">Team Administration</span>
</div>
<div style="padding:32px;text-align:center;">
<div class="empty-hint">You are not an admin of any team.</div>
</div>
</div>
<${ToastContainer} />
<${DialogStack} />
`;
}
return html`
<div class="surface-settings">
${/* Top Bar */``}
<div class="settings-topbar">
<a href="${BASE}/" class="settings-topbar-back" onClick=${backClick}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12"/>
<polyline points="12 19 5 12 12 5"/>
</svg>
Back
</a>
<div class="settings-topbar-sep"></div>
<span class="settings-topbar-title">
${teamName}
${adminTeams.length > 1 && html`
<button class="btn-small btn-ghost" style="margin-left:8px;font-size:11px;"
onClick=${() => setSelectedTeam(null)}>Switch Team</button>
`}
</span>
</div>
<div class="settings-body">
${/* Left Nav */``}
<div class="settings-nav">
${SECTIONS.map(s => html`
<a key=${s.key} href="${BASE}/team-admin/${s.key}"
class="settings-nav-link ${section === s.key ? 'active' : ''}"
onClick=${navClick}>
${s.label}
</a>
`)}
</div>
${/* Content */``}
<div class="settings-content">
<div class="settings-content-header">
<h2>${sectionLabel}</h2>
</div>
<div class="settings-content-body">
${SectionComponent && teamId
? html`<${SectionComponent} teamId=${teamId} />`
: html`<div class="settings-placeholder">Loading\u2026</div>`
}
</div>
</div>
</div>
</div>
<${ToastContainer} />
<${DialogStack} />
`;
}
// ── Mount ────────────────────────────────────
const mount = document.getElementById('team-admin-mount');
if (mount) {
render(html`<${TeamAdminSurface} />`, mount);
console.log('[team-admin] Surface mounted');
}
export { TeamAdminSurface };

View File

@@ -0,0 +1,44 @@
/**
* Team Admin > Knowledge
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
export default function KnowledgeSection({ teamId }) {
const [kbs, setKbs] = useState([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
const data = await sw.api.teams.knowledgeBases(teamId);
setKbs(Array.isArray(data) ? data : data.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId]);
useEffect(() => { load(); }, [load]);
function fmtDate(d) {
if (!d) return '\u2014';
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
<div class="admin-list">
${kbs.length === 0 && html`<div class="empty-hint">No knowledge bases</div>`}
${kbs.map(k => html`
<div class="admin-surface-row" key=${k.id}>
<div style="flex:1;min-width:0;">
<strong>${k.name}</strong>
<span class="text-muted" style="margin-left:8px;font-size:12px;">${k.document_count || 0} docs</span>
</div>
<span class="text-muted" style="font-size:11px;">${fmtDate(k.created_at)}</span>
</div>
`)}
</div>
</div>
`;
}

View File

@@ -0,0 +1,114 @@
/**
* Team Admin > Members
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
export default function MembersSection({ teamId }) {
const [members, setMembers] = useState([]);
const [loading, setLoading] = useState(true);
const [showAdd, setShowAdd] = useState(false);
const [users, setUsers] = useState([]);
const load = useCallback(async () => {
try {
const data = await sw.api.teams.members(teamId);
setMembers(Array.isArray(data) ? data : data.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId]);
useEffect(() => { load(); }, [load]);
async function openAdd() {
setShowAdd(true);
try {
const data = await sw.api.admin.users.list();
setUsers(Array.isArray(data) ? data : data.users || data.data || []);
} catch {
// non-admin may not have access — leave empty
setUsers([]);
}
}
async function addMember(e) {
e.preventDefault();
const form = e.target;
const userId = form.userId.value;
const role = form.role.value;
if (!userId) return;
try {
await sw.api.teams.addMember(teamId, userId, role);
sw.toast('Member added', 'success');
setShowAdd(false);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function updateRole(memberId, newRole) {
try {
await sw.api.teams.updateMember(teamId, memberId, newRole);
sw.toast('Role updated', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function removeMember(memberId) {
const ok = await sw.confirm('Remove this member from the team?', true);
if (!ok) return;
try {
await sw.api.teams.removeMember(teamId, memberId);
sw.toast('Member removed', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<div style="flex:1"></div>
<button class="btn-md btn-primary" onClick=${openAdd}>+ Add Member</button>
</div>
${showAdd && html`
<form class="admin-inline-form" style="margin-bottom:16px;" onSubmit=${addMember}>
<div class="form-row">
<div class="form-group"><label>User</label>
<select name="userId">
<option value="">Select\u2026</option>
${users.map(u => html`<option key=${u.id} value=${u.id}>${u.username || u.email}</option>`)}
</select>
</div>
<div class="form-group"><label>Role</label>
<select name="role">
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
<div class="form-row" style="margin-top:8px;gap:8px;">
<button type="submit" class="btn-small btn-primary">Add</button>
<button type="button" class="btn-small" onClick=${() => setShowAdd(false)}>Cancel</button>
</div>
</form>
`}
<div class="admin-list">
${members.length === 0 && html`<div class="empty-hint">No members</div>`}
${members.map(m => html`
<div class="admin-surface-row" key=${m.id || m.user_id}>
<strong style="flex:1;">${m.username || m.user_id}</strong>
<select value=${m.role || 'member'} style="width:100px;"
onChange=${e => updateRole(m.id || m.user_id, e.target.value)}>
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
<button class="btn-small btn-danger" onClick=${() => removeMember(m.id || m.user_id)}>Remove</button>
</div>
`)}
</div>
</div>
`;
}

View File

@@ -0,0 +1,106 @@
/**
* Team Admin > Personas
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
export default function PersonasSection({ teamId }) {
const [personas, setPersonas] = useState([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(null); // null | 'new' | persona object
const load = useCallback(async () => {
try {
const data = await sw.api.teams.personas(teamId);
setPersonas(Array.isArray(data) ? data : data.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId]);
useEffect(() => { load(); }, [load]);
async function savePersona(e) {
e.preventDefault();
const form = e.target;
const data = {
name: form.pname.value.trim(),
description: form.description.value.trim(),
system_prompt: form.system_prompt.value,
};
try {
if (editing === 'new') {
await sw.api.teams.createPersona(teamId, data);
sw.toast('Persona created', 'success');
} else {
await sw.api.teams.updatePersona(teamId, editing.id, data);
sw.toast('Persona updated', 'success');
}
setEditing(null);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function deletePersona(pid) {
const ok = await sw.confirm('Delete this persona?', true);
if (!ok) return;
try {
await sw.api.teams.deletePersona(teamId, pid);
sw.toast('Persona deleted', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
if (editing) {
const isNew = editing === 'new';
const p = isNew ? {} : editing;
return html`
<form onSubmit=${savePersona}>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>\u2190 Back</button>
<h4 style="margin:0;">${isNew ? 'Create Persona' : `Edit: ${p.name}`}</h4>
</div>
<div class="form-group"><label>Name</label>
<input name="pname" value=${p.name || ''} placeholder="Code Reviewer" />
</div>
<div class="form-group"><label>Description</label>
<input name="description" value=${p.description || ''} placeholder="Optional description" />
</div>
<div class="form-group"><label>System Prompt</label>
<textarea name="system_prompt" rows="6" placeholder="System prompt\u2026">${p.system_prompt || ''}</textarea>
</div>
<div class="form-row" style="margin-top:12px;gap:8px;">
<button type="submit" class="btn-small btn-primary">${isNew ? 'Create' : 'Save'}</button>
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>Cancel</button>
</div>
</form>
`;
}
return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<div style="flex:1"></div>
<button class="btn-md btn-primary" onClick=${() => setEditing('new')}>+ Add</button>
</div>
<div class="admin-list">
${personas.length === 0 && html`<div class="empty-hint">No personas</div>`}
${personas.map(p => html`
<div class="admin-surface-row" key=${p.id}>
<div style="flex:1;min-width:0;">
<strong>${p.name}</strong>
${p.description && html`<span class="text-muted" style="margin-left:8px;font-size:11px;">${p.description.substring(0, 60)}${p.description.length > 60 ? '\u2026' : ''}</span>`}
</div>
<span class="text-muted" style="font-size:11px;">${p.kb_count || 0} KBs</span>
<div class="admin-actions-cell">
<button class="btn-small" onClick=${() => setEditing(p)}>Edit</button>
<button class="btn-small btn-danger" onClick=${() => deletePersona(p.id)}>Delete</button>
</div>
</div>
`)}
</div>
</div>
`;
}

View File

@@ -0,0 +1,123 @@
/**
* Team Admin > Providers
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
export default function ProvidersSection({ teamId }) {
const [configs, setConfigs] = useState([]);
const [providerTypes, setProviderTypes] = useState([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(null); // null | 'new' | config object
const load = useCallback(async () => {
try {
const [c, t] = await Promise.all([
sw.api.teams.providers(teamId),
sw.api.admin.providers.types().catch(() => ({ types: [] })),
]);
setConfigs(Array.isArray(c) ? c : c.data || []);
setProviderTypes(t.types || t.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId]);
useEffect(() => { load(); }, [load]);
async function saveProvider(e) {
e.preventDefault();
const form = e.target;
const data = {
name: form.pname.value.trim(),
provider: form.provider.value,
endpoint: form.endpoint.value.trim(),
api_key: form.api_key.value || undefined,
};
try {
if (editing === 'new') {
await sw.api.teams.createProvider(teamId, data);
sw.toast('Provider created', 'success');
} else {
await sw.api.teams.updateProvider(teamId, editing.id, data);
sw.toast('Provider updated', 'success');
}
setEditing(null);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function syncModels(pid) {
try {
const result = await sw.api.teams.providerModels(teamId, pid);
sw.toast(`Models synced: ${result.added || result.count || 0}`, 'success');
} catch (e) { sw.toast(e.message, 'error'); }
}
async function deleteProvider(pid) {
const ok = await sw.confirm('Delete this provider config?', true);
if (!ok) return;
try {
await sw.api.teams.deleteProvider(teamId, pid);
sw.toast('Provider deleted', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<div style="flex:1"></div>
<button class="btn-md btn-primary" onClick=${() => setEditing('new')}>+ Add</button>
</div>
${editing && html`
<form class="admin-inline-form" style="margin-bottom:16px;padding:12px;border:1px solid var(--border);border-radius:var(--radius);" onSubmit=${saveProvider}>
<h4 style="margin:0 0 12px;">${editing === 'new' ? 'Add Provider' : 'Edit Provider'}</h4>
<div class="form-row">
<div class="form-group"><label>Name</label>
<input name="pname" placeholder="My OpenAI" value=${editing !== 'new' ? editing.name || '' : ''} />
</div>
<div class="form-group"><label>Type</label>
<select name="provider">
${providerTypes.map(t => html`<option key=${t.name} value=${t.name} selected=${editing !== 'new' && editing.provider === t.name}>${t.display_name || t.name}</option>`)}
</select>
</div>
</div>
<div class="form-row">
<div class="form-group" style="flex:2;"><label>Endpoint</label>
<input name="endpoint" placeholder="https://api.openai.com/v1" value=${editing !== 'new' ? editing.endpoint || '' : ''} />
</div>
<div class="form-group" style="flex:1;"><label>API Key</label>
<input name="api_key" type="password" placeholder="${editing !== 'new' ? '(unchanged)' : 'sk-...'}" autocomplete="off" />
</div>
</div>
<div class="form-row" style="margin-top:12px;gap:8px;">
<button type="submit" class="btn-small btn-primary">${editing === 'new' ? 'Create' : 'Save'}</button>
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>Cancel</button>
</div>
</form>
`}
<div class="provider-cards">
${configs.length === 0 && html`<div class="empty-hint">No provider configs</div>`}
${configs.map(c => html`
<div class="provider-card" key=${c.id}>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<strong>${c.name}</strong>
<span class="badge">${c.provider}</span>
${c.has_key ? html`<span class="badge badge-active">key set</span>` : html`<span class="badge badge-inactive">no key</span>`}
</div>
<div class="text-muted" style="font-size:12px;margin-bottom:8px;word-break:break-all;">${c.endpoint || '(default)'}</div>
<div style="display:flex;gap:6px;">
<button class="btn-small" onClick=${() => setEditing(c)}>Edit</button>
<button class="btn-small" onClick=${() => syncModels(c.id)}>Sync Models</button>
<button class="btn-small btn-danger" onClick=${() => deleteProvider(c.id)}>Delete</button>
</div>
</div>
`)}
</div>
</div>
`;
}

View File

@@ -0,0 +1,72 @@
/**
* Team Admin > Settings
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
export default function SettingsSection({ teamId }) {
const [team, setTeam] = useState(null);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
try {
const data = await sw.api.teams.get(teamId);
setTeam(data);
setName(data.name || '');
setDescription(data.description || '');
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId]);
useEffect(() => { load(); }, [load]);
async function save() {
setSaving(true);
try {
await sw.api.teams.update(teamId, {
name: name.trim(),
description: description.trim(),
});
sw.toast('Settings saved', 'success');
} catch (e) { sw.toast(e.message, 'error'); }
finally { setSaving(false); }
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div class="admin-settings-form">
<div class="settings-section">
<h3>Team Details</h3>
<div class="form-group">
<label>Team Name</label>
<input value=${name} onInput=${e => setName(e.target.value)} placeholder="My Team" />
</div>
<div class="form-group">
<label>Description</label>
<textarea rows="3" value=${description}
onInput=${e => setDescription(e.target.value)}
placeholder="Optional description\u2026"></textarea>
</div>
</div>
${team && html`
<div class="settings-section">
<h3>Info</h3>
<div class="text-muted" style="font-size:12px;">
<div>ID: ${team.id}</div>
${team.created_at && html`<div>Created: ${new Date(team.created_at).toLocaleString()}</div>`}
${team.member_count != null && html`<div>Members: ${team.member_count}</div>`}
</div>
</div>
`}
<div style="display:flex;gap:8px;align-items:center;margin-top:16px;">
<button class="btn-md btn-primary" onClick=${save} disabled=${saving}>${saving ? 'Saving\u2026' : 'Save Settings'}</button>
</div>
</div>
`;
}

View File

@@ -0,0 +1,111 @@
/**
* Team Admin > Tasks
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
export default function TasksSection({ teamId }) {
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const load = useCallback(async () => {
try {
const data = await sw.api.teams.tasks(teamId);
setTasks(Array.isArray(data) ? data : data.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId]);
useEffect(() => { load(); }, [load]);
async function createTask(e) {
e.preventDefault();
const form = e.target;
try {
await sw.api.teams.createTask(teamId, {
name: form.name.value.trim(),
schedule: form.schedule.value.trim(),
description: form.description.value.trim(),
});
sw.toast('Task created', 'success');
setShowCreate(false);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function runTask(id) {
try {
await sw.api.teams.runTask(teamId, id);
sw.toast('Task triggered', 'success');
} catch (e) { sw.toast(e.message, 'error'); }
}
async function killTask(id) {
try {
await sw.api.teams.killTask(teamId, id);
sw.toast('Task cancelled', 'success');
} catch (e) { sw.toast(e.message, 'error'); }
}
async function deleteTask(id) {
const ok = await sw.confirm('Delete this task?', true);
if (!ok) return;
try {
await sw.api.teams.deleteTask(teamId, id);
sw.toast('Task deleted', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
function fmtDate(d) {
if (!d) return '\u2014';
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<div style="flex:1"></div>
<button class="btn-md btn-primary" onClick=${() => setShowCreate(true)}>+ Add</button>
</div>
${showCreate && html`
<form class="admin-inline-form" style="margin-bottom:16px;" onSubmit=${createTask}>
<div class="form-row">
<div class="form-group"><label>Name</label><input name="name" placeholder="Daily Sync" /></div>
<div class="form-group"><label>Schedule</label><input name="schedule" placeholder="0 9 * * *" /></div>
</div>
<div class="form-group"><label>Description</label><input name="description" placeholder="Optional" /></div>
<div class="form-row" style="margin-top:8px;gap:8px;">
<button type="submit" class="btn-small btn-primary">Create</button>
<button type="button" class="btn-small" onClick=${() => setShowCreate(false)}>Cancel</button>
</div>
</form>
`}
<div class="admin-list">
${tasks.length === 0 && html`<div class="empty-hint">No tasks</div>`}
${tasks.map(t => html`
<div class="admin-surface-row" key=${t.id}>
<div style="flex:1;min-width:0;">
<strong>${t.name}</strong>
${t.schedule && html`<span class="badge" style="margin-left:6px;">${t.schedule}</span>`}
</div>
<span class="text-muted" style="font-size:11px;">
last: ${fmtDate(t.last_run_at)} \u2022 next: ${fmtDate(t.next_run_at)}
</span>
<span class="badge ${t.is_active ? 'badge-active' : 'badge-inactive'}">${t.is_active ? 'active' : 'paused'}</span>
<div class="admin-actions-cell">
<button class="btn-small btn-primary" onClick=${() => runTask(t.id)}>Run</button>
<button class="btn-small" onClick=${() => killTask(t.id)}>Kill</button>
<button class="btn-small btn-danger" onClick=${() => deleteTask(t.id)}>Delete</button>
</div>
</div>
`)}
</div>
</div>
`;
}

View File

@@ -0,0 +1,57 @@
/**
* Team Admin > Usage
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
export default function UsageSection({ teamId }) {
const [usage, setUsage] = useState(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
const data = await sw.api.teams.usage(teamId);
setUsage(data);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId]);
useEffect(() => { load(); }, [load]);
function fmtNum(n) {
if (n == null) return '\u2014';
return n.toLocaleString();
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
${usage?.totals && html`
<div class="stat-cards-grid" style="margin-bottom:16px;">
<div class="stat-card"><div class="stat-value">${fmtNum(usage.totals.total_tokens)}</div><div class="stat-label">Total Tokens</div></div>
<div class="stat-card"><div class="stat-value">${fmtNum(usage.totals.total_requests)}</div><div class="stat-label">Total Requests</div></div>
<div class="stat-card"><div class="stat-value">$${(usage.totals.total_cost || 0).toFixed(2)}</div><div class="stat-label">Total Cost</div></div>
</div>
`}
${usage?.results && usage.results.length > 0 && html`
<h4 style="margin:16px 0 8px;">Usage by Day</h4>
<div class="admin-list">
${usage.results.map((r, i) => html`
<div class="admin-surface-row" key=${i}>
<span style="width:100px;">${r.date || r.day || '\u2014'}</span>
<span style="flex:1;">${fmtNum(r.tokens)} tokens</span>
<span class="text-muted" style="font-size:12px;">${r.requests || 0} requests</span>
<span class="text-muted" style="font-size:12px;">$${(r.cost || 0).toFixed(2)}</span>
</div>
`)}
</div>
`}
${(!usage?.totals && !usage?.results) && html`
<div class="empty-hint">No usage data available</div>
`}
</div>
`;
}

View File

@@ -0,0 +1,171 @@
/**
* Team Admin > Workflows
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
const ENTRY_MODES = ['public_link', 'team_only'];
export default function WorkflowsSection({ teamId }) {
const [workflows, setWorkflows] = useState([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(null); // null | 'new' | workflow
const [stages, setStages] = useState([]);
const [showCreate, setShowCreate] = useState(false);
const load = useCallback(async () => {
try {
const data = await sw.api.teams.workflows(teamId);
setWorkflows(Array.isArray(data) ? data : data.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId]);
useEffect(() => { load(); }, [load]);
async function openEdit(wf) {
setEditing(wf);
try {
const detail = await sw.api.teams.workflows(teamId);
const match = (Array.isArray(detail) ? detail : detail.data || []).find(w => w.id === wf.id);
setStages(match?.stages || wf.stages || []);
} catch (e) { sw.toast(e.message, 'error'); setStages([]); }
}
async function createWorkflow(e) {
e.preventDefault();
const form = e.target;
try {
await sw.api.teams.createWorkflow(teamId, {
name: form.name.value.trim(),
slug: form.slug.value.trim(),
entry_mode: form.entry_mode.value,
description: form.description.value.trim(),
});
sw.toast('Workflow created', 'success');
setShowCreate(false);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function updateWorkflow(e) {
e.preventDefault();
const form = e.target;
try {
await sw.api.teams.updateWorkflow(teamId, editing.id, {
name: form.name.value.trim(),
description: form.description.value.trim(),
entry_mode: form.entry_mode.value,
is_active: form.is_active.checked,
});
sw.toast('Workflow updated', 'success');
setEditing(null);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function publishWorkflow(wfId) {
try {
await sw.api.teams.publishWorkflow(teamId, wfId);
sw.toast('Workflow published', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function deleteWorkflow(id) {
const ok = await sw.confirm('Delete this workflow? All stages, versions, and instances will be deleted.', true);
if (!ok) return;
try {
await sw.api.teams.deleteWorkflow(teamId, id);
sw.toast('Workflow deleted', 'success');
setEditing(null);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
if (editing) return html`
<form onSubmit=${updateWorkflow}>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>\u2190 Back</button>
<h4 style="margin:0;">Edit: ${editing.name}</h4>
<div style="flex:1"></div>
<button type="button" class="btn-small" onClick=${() => publishWorkflow(editing.id)}>Publish</button>
<button type="button" class="btn-small btn-danger" onClick=${() => deleteWorkflow(editing.id)}>Delete</button>
</div>
<div class="form-row">
<div class="form-group"><label>Name</label><input name="name" value=${editing.name || ''} /></div>
<div class="form-group"><label>Entry Mode</label>
<select name="entry_mode">
${ENTRY_MODES.map(m => html`<option key=${m} value=${m} selected=${editing.entry_mode === m}>${m}</option>`)}
</select>
</div>
</div>
<div class="form-group"><label>Description</label><input name="description" value=${editing.description || ''} /></div>
<label class="toggle-label" style="margin:8px 0;">
<input type="checkbox" name="is_active" checked=${editing.is_active !== false} />
<span class="toggle-track"></span><span>Active</span>
</label>
<h5 style="margin:16px 0 8px;">Stages (${stages.length})</h5>
<div class="admin-list">
${stages.map((s, i) => html`
<div class="admin-surface-row" key=${s.id || i}>
<span class="text-muted" style="font-size:11px;width:24px;">#${i + 1}</span>
<strong style="flex:1;">${s.name || `Stage ${i + 1}`}</strong>
<span class="badge">${s.stage_mode || '\u2014'}</span>
</div>
`)}
${stages.length === 0 && html`<div class="empty-hint">No stages defined</div>`}
</div>
<div class="form-row" style="margin-top:16px;gap:8px;">
<button type="submit" class="btn-small btn-primary">Save</button>
</div>
</form>
`;
return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<div style="flex:1"></div>
<button class="btn-md btn-primary" onClick=${() => setShowCreate(true)}>+ Add</button>
</div>
${showCreate && html`
<form class="admin-inline-form" style="margin-bottom:16px;" onSubmit=${createWorkflow}>
<div class="form-row">
<div class="form-group"><label>Name</label><input name="name" placeholder="Customer Intake" /></div>
<div class="form-group"><label>Slug</label><input name="slug" placeholder="customer-intake" /></div>
<div class="form-group"><label>Entry Mode</label>
<select name="entry_mode">
${ENTRY_MODES.map(m => html`<option key=${m} value=${m}>${m}</option>`)}
</select>
</div>
</div>
<div class="form-group"><label>Description</label><input name="description" placeholder="Optional" /></div>
<div class="form-row" style="margin-top:8px;gap:8px;">
<button type="submit" class="btn-small btn-primary">Create</button>
<button type="button" class="btn-small" onClick=${() => setShowCreate(false)}>Cancel</button>
</div>
</form>
`}
<div class="admin-list">
${workflows.length === 0 && html`<div class="empty-hint">No workflows</div>`}
${workflows.map(w => html`
<div class="admin-surface-row" key=${w.id} style="cursor:pointer;" onClick=${() => openEdit(w)}>
<div style="flex:1;min-width:0;">
<strong>${w.name}</strong>
<span class="text-muted" style="margin-left:8px;font-size:11px;">/${w.slug}</span>
</div>
<span class="badge">${w.entry_mode}</span>
<span class="badge ${w.is_active ? 'badge-active' : 'badge-inactive'}">${w.is_active ? 'active' : 'inactive'}</span>
<span class="text-muted" style="font-size:11px;">v${w.version || 0}</span>
</div>
`)}
</div>
</div>
`;
}