Changeset 0.37.7 (#219)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
@@ -214,12 +214,14 @@ export function createDomains(restClient) {
|
||||
teams: {
|
||||
mine: () => rc.get('/api/v1/teams/mine'),
|
||||
get: (id) => rc.get(`/api/v1/teams/${id}`),
|
||||
update: (id, data) => rc.put(`/api/v1/teams/${id}`, data),
|
||||
members: (id) => rc.get(`/api/v1/teams/${id}/members`),
|
||||
addMember: (id, userId, role) => rc.post(`/api/v1/teams/${id}/members`, { user_id: userId, role }),
|
||||
updateMember:(id, memberId, role) => rc.put(`/api/v1/teams/${id}/members/${memberId}`, { role }),
|
||||
removeMember:(id, memberId) => rc.del(`/api/v1/teams/${id}/members/${memberId}`),
|
||||
personas: (id) => rc.get(`/api/v1/teams/${id}/personas`),
|
||||
createPersona:(id, data) => rc.post(`/api/v1/teams/${id}/personas`, data),
|
||||
updatePersona:(id, pid, data) => rc.put(`/api/v1/teams/${id}/personas/${pid}`, data),
|
||||
deletePersona:(id, pid) => rc.del(`/api/v1/teams/${id}/personas/${pid}`),
|
||||
personaKbs: (id, pid) => rc.get(`/api/v1/teams/${id}/personas/${pid}/knowledge-bases`),
|
||||
setPersonaKbs:(id, pid, data) => rc.put(`/api/v1/teams/${id}/personas/${pid}/knowledge-bases`, data),
|
||||
@@ -236,6 +238,22 @@ export function createDomains(restClient) {
|
||||
audit: (id, opts) => rc.get(`/api/v1/teams/${id}/audit` + _qs(opts)),
|
||||
auditActions:(id) => rc.get(`/api/v1/teams/${id}/audit/actions`),
|
||||
usage: (id, opts) => rc.get(`/api/v1/teams/${id}/usage` + _qs(opts)),
|
||||
// Team workflows
|
||||
workflows: (id, opts) => rc.get(`/api/v1/teams/${id}/workflows` + _qs(opts)),
|
||||
createWorkflow: (id, data) => rc.post(`/api/v1/teams/${id}/workflows`, data),
|
||||
getWorkflow: (id, wfId) => rc.get(`/api/v1/teams/${id}/workflows/${wfId}`),
|
||||
updateWorkflow: (id, wfId, data) => rc.put(`/api/v1/teams/${id}/workflows/${wfId}`, data),
|
||||
deleteWorkflow: (id, wfId) => rc.del(`/api/v1/teams/${id}/workflows/${wfId}`),
|
||||
publishWorkflow: (id, wfId) => rc.post(`/api/v1/teams/${id}/workflows/${wfId}/publish`, {}),
|
||||
// Team tasks
|
||||
tasks: (id, opts) => rc.get(`/api/v1/teams/${id}/tasks` + _qs(opts)),
|
||||
createTask: (id, data) => rc.post(`/api/v1/teams/${id}/tasks`, data),
|
||||
updateTask: (id, taskId, data) => rc.put(`/api/v1/teams/${id}/tasks/${taskId}`, data),
|
||||
deleteTask: (id, taskId) => rc.del(`/api/v1/teams/${id}/tasks/${taskId}`),
|
||||
runTask: (id, taskId) => rc.post(`/api/v1/teams/${id}/tasks/${taskId}/run`, {}),
|
||||
killTask: (id, taskId) => rc.post(`/api/v1/teams/${id}/tasks/${taskId}/kill`, {}),
|
||||
// Team knowledge bases
|
||||
knowledgeBases:(id) => rc.get(`/api/v1/teams/${id}/knowledge-bases`),
|
||||
},
|
||||
|
||||
// ── 15. Workflows ──────────────────────
|
||||
@@ -442,6 +460,21 @@ export function createDomains(restClient) {
|
||||
},
|
||||
},
|
||||
|
||||
// ── 19. Git Credentials ──────────────────
|
||||
git: {
|
||||
credentials: {
|
||||
list: () => rc.get('/api/v1/git-credentials'),
|
||||
create: (data) => rc.post('/api/v1/git-credentials', data),
|
||||
del: (id) => rc.del(`/api/v1/git-credentials/${id}`),
|
||||
},
|
||||
},
|
||||
|
||||
// ── 20. Data Portability ─────────────────
|
||||
dataPortability: {
|
||||
exportMe: () => rc.get('/api/v1/export/me'),
|
||||
deleteAccount: (data) => rc.post('/api/v1/profile/delete', data),
|
||||
},
|
||||
|
||||
// ── Misc (not domain-specific) ─────────
|
||||
folders: {
|
||||
list: () => rc.get('/api/v1/folders'),
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
59
src/js/sw/surfaces/settings/data.js
Normal file
59
src/js/sw/surfaces/settings/data.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
97
src/js/sw/surfaces/settings/gitkeys.js
Normal file
97
src/js/sw/surfaces/settings/gitkeys.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
182
src/js/sw/surfaces/settings/knowledge.js
Normal file
182
src/js/sw/surfaces/settings/knowledge.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
117
src/js/sw/surfaces/settings/memory.js
Normal file
117
src/js/sw/surfaces/settings/memory.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
96
src/js/sw/surfaces/settings/notifications.js
Normal file
96
src/js/sw/surfaces/settings/notifications.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
71
src/js/sw/surfaces/settings/tasks.js
Normal file
71
src/js/sw/surfaces/settings/tasks.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
88
src/js/sw/surfaces/settings/workflows.js
Normal file
88
src/js/sw/surfaces/settings/workflows.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
88
src/js/sw/surfaces/team-admin/activity.js
Normal file
88
src/js/sw/surfaces/team-admin/activity.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
37
src/js/sw/surfaces/team-admin/groups.js
Normal file
37
src/js/sw/surfaces/team-admin/groups.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
229
src/js/sw/surfaces/team-admin/index.js
Normal file
229
src/js/sw/surfaces/team-admin/index.js
Normal 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 };
|
||||
44
src/js/sw/surfaces/team-admin/knowledge.js
Normal file
44
src/js/sw/surfaces/team-admin/knowledge.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
114
src/js/sw/surfaces/team-admin/members.js
Normal file
114
src/js/sw/surfaces/team-admin/members.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
106
src/js/sw/surfaces/team-admin/personas.js
Normal file
106
src/js/sw/surfaces/team-admin/personas.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
123
src/js/sw/surfaces/team-admin/providers.js
Normal file
123
src/js/sw/surfaces/team-admin/providers.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
72
src/js/sw/surfaces/team-admin/settings.js
Normal file
72
src/js/sw/surfaces/team-admin/settings.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
111
src/js/sw/surfaces/team-admin/tasks.js
Normal file
111
src/js/sw/surfaces/team-admin/tasks.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
57
src/js/sw/surfaces/team-admin/usage.js
Normal file
57
src/js/sw/surfaces/team-admin/usage.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
171
src/js/sw/surfaces/team-admin/workflows.js
Normal file
171
src/js/sw/surfaces/team-admin/workflows.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user