@bhooai/nexus-cli 2.0.7 → 2.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/templates/base/apps/admin/package.json.ejs +3 -3
- package/templates/base/apps/admin/src/App.tsx +0 -38
- package/templates/base/apps/admin/src/alertCenter.tsx +0 -151
- package/templates/base/apps/admin/src/api.ts +0 -474
- package/templates/base/apps/admin/src/assets/bhooai-nexus-logo.svg +0 -25
- package/templates/base/apps/admin/src/components/ThemeCentre.tsx +0 -201
- package/templates/base/apps/admin/src/components/shell/AppearancePanel.tsx +0 -37
- package/templates/base/apps/admin/src/components/shell/Dashboard.tsx +0 -441
- package/templates/base/apps/admin/src/components/shell/Login.tsx +0 -69
- package/templates/base/apps/admin/src/components/shell/NotifyPanel.tsx +0 -102
- package/templates/base/apps/admin/src/components/shell/RailPanel.tsx +0 -133
- package/templates/base/apps/admin/src/components/shell/SettingsDialog.tsx +0 -217
- package/templates/base/apps/admin/src/components/ui/ConfirmDialog.tsx +0 -38
- package/templates/base/apps/admin/src/components/ui/LintChecks.tsx +0 -54
- package/templates/base/apps/admin/src/components/ui/PageHead.tsx +0 -13
- package/templates/base/apps/admin/src/components/ui/PageHero.tsx +0 -15
- package/templates/base/apps/admin/src/icons.tsx +0 -104
- package/templates/base/apps/admin/src/index.css +0 -3539
- package/templates/base/apps/admin/src/lib/constants.ts +0 -94
- package/templates/base/apps/admin/src/lib/theme.ts +0 -80
- package/templates/base/apps/admin/src/lib/types.ts +0 -39
- package/templates/base/apps/admin/src/lib/utils.ts +0 -147
- package/templates/base/apps/admin/src/pages/Config.tsx +0 -108
- package/templates/base/apps/admin/src/pages/Databases.tsx +0 -122
- package/templates/base/apps/admin/src/pages/Environment.tsx +0 -69
- package/templates/base/apps/admin/src/pages/Logs.tsx +0 -270
- package/templates/base/apps/admin/src/pages/Monitoring.tsx +0 -88
- package/templates/base/apps/admin/src/pages/Overview.tsx +0 -108
- package/templates/base/apps/admin/src/pages/Payments.tsx +0 -68
- package/templates/base/apps/admin/src/pages/Plugins.tsx +0 -36
- package/templates/base/apps/admin/src/pages/Processes.tsx +0 -67
- package/templates/base/apps/admin/src/pages/Schema.tsx +0 -130
- package/templates/base/apps/admin/src/pages/Users.tsx +0 -144
- package/templates/base/apps/admin/src/pages/ai/AddProviderDialog.tsx +0 -150
- package/templates/base/apps/admin/src/pages/ai/AiAgents.tsx +0 -31
- package/templates/base/apps/admin/src/pages/ai/AiChatPlayground.tsx +0 -208
- package/templates/base/apps/admin/src/pages/ai/AiProviders.tsx +0 -276
- package/templates/base/apps/admin/src/pages/ai/AiSettings.tsx +0 -84
- package/templates/base/apps/admin/src/pages/ai/ProviderCard.tsx +0 -145
- package/templates/base/apps/admin/src/pages/ai/ProviderGroup.tsx +0 -50
- package/templates/base/apps/admin/src/pages/ai/ProviderKeyDialog.tsx +0 -66
- package/templates/base/apps/admin/src/pages/monitoring/RequestSeriesChart.tsx +0 -70
- package/templates/base/apps/admin/src/pages/overview/TrafficChart.tsx +0 -32
- package/templates/base/apps/admin/src/pages/payments/OrdersTable.tsx +0 -27
- package/templates/base/apps/admin/src/pages/payments/PaymentKeysDialog.tsx +0 -74
- package/templates/base/apps/admin/src/pages/payments/TestConsole.tsx +0 -173
- package/templates/base/apps/admin/src/pages/payments/TransactionsTable.tsx +0 -26
- package/templates/base/apps/admin/src/style.css +0 -6517
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import React, { useState, useEffect } from 'react';
|
|
2
|
-
import { getPlugins } from '../api.js';
|
|
3
|
-
import { PageHead } from '../components/ui/PageHead.js';
|
|
4
|
-
import { PageHero } from '../components/ui/PageHero.js';
|
|
5
|
-
|
|
6
|
-
export function Plugins() {
|
|
7
|
-
const [data, setData] = useState<{ pages: any[]; slots: Record<string, any[]> } | null>(null);
|
|
8
|
-
useEffect(() => { getPlugins().then(setData).catch(() => {}); }, []);
|
|
9
|
-
if (!data) return <p className="text-slate-400">Loading…</p>;
|
|
10
|
-
return (
|
|
11
|
-
<div className="space-y-6">
|
|
12
|
-
<PageHead title="Plugins" />
|
|
13
|
-
<PageHero kicker="EXTENSION GALLERY" title={<>Extend Nexus, <em>effortlessly.</em></>} desc="Every admin page, slot and surface that plugin modules register — a living index of what's available to your build." glyph="puzzle" art="grid" />
|
|
14
|
-
<div className="glass-card space-y-4">
|
|
15
|
-
<h3 className="glass-h3">Admin pages</h3>
|
|
16
|
-
<ul className="space-y-1">
|
|
17
|
-
{data.pages.map((p) => (
|
|
18
|
-
<li key={p.path} className="flex items-center gap-2 font-mono text-sm text-slate-200">
|
|
19
|
-
<span className="chip">{p.group ?? 'Plugins'}</span> {p.title} —{' '}
|
|
20
|
-
<a href={p.path} target="_blank" rel="noreferrer" className="text-slate-400 underline decoration-dotted underline-offset-4 transition-colors hover:text-sky-300">
|
|
21
|
-
{p.path}
|
|
22
|
-
</a>
|
|
23
|
-
</li>
|
|
24
|
-
))}
|
|
25
|
-
{!data.pages.length && <li className="text-sm text-slate-500">No plugin pages registered.</li>}
|
|
26
|
-
</ul>
|
|
27
|
-
{Object.keys(data.slots).length > 0 && (
|
|
28
|
-
<>
|
|
29
|
-
<h3 className="glass-h3">Slots</h3>
|
|
30
|
-
<pre className="glass-code overflow-auto p-3">{JSON.stringify(data.slots, null, 2)}</pre>
|
|
31
|
-
</>
|
|
32
|
-
)}
|
|
33
|
-
</div>
|
|
34
|
-
</div>
|
|
35
|
-
);
|
|
36
|
-
}
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
import React, { useState, useEffect } from 'react';
|
|
2
|
-
import { getServices, controlService, getServiceLogs, type ServiceState } from '../api.js';
|
|
3
|
-
import { Icon } from '../icons.js';
|
|
4
|
-
import { PageHead } from '../components/ui/PageHead.js';
|
|
5
|
-
import { PageHero } from '../components/ui/PageHero.js';
|
|
6
|
-
|
|
7
|
-
export function Processes() {
|
|
8
|
-
const [services, setServices] = useState<ServiceState[]>([]);
|
|
9
|
-
const [logs, setLogs] = useState<Record<string, string[]>>({});
|
|
10
|
-
const [sel, setSel] = useState<string | null>(null);
|
|
11
|
-
|
|
12
|
-
const refresh = async () => {
|
|
13
|
-
try { setServices(await getServices()); } catch { setServices([]); }
|
|
14
|
-
};
|
|
15
|
-
useEffect(() => { refresh(); const t = setInterval(refresh, 3000); return () => clearInterval(t); }, []);
|
|
16
|
-
|
|
17
|
-
const showLogs = async (name: string) => {
|
|
18
|
-
setSel(name);
|
|
19
|
-
setLogs({ ...logs, [name]: await getServiceLogs(name) });
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
const color = (s: string) => (s === 'running' ? 'text-emerald-400' : s === 'errored' ? 'text-rose-400' : 'text-slate-400');
|
|
23
|
-
|
|
24
|
-
return (
|
|
25
|
-
<div className="space-y-6">
|
|
26
|
-
<PageHead title="Processes">
|
|
27
|
-
<button onClick={refresh} className="glass-chip-btn"><Icon name="refresh" size={12} /> refresh</button>
|
|
28
|
-
</PageHead>
|
|
29
|
-
<PageHero kicker="SERVICE CONTROL PLANE" title={<>Your services, <em>in check.</em></>} desc="Every backend, frontend and AI process the supervisor is tending — live status, uptime, PID and one-click control." glyph="server" art="bars" />
|
|
30
|
-
<div className="glass-panel overflow-hidden p-2">
|
|
31
|
-
<table className="glass-table">
|
|
32
|
-
<thead><tr className="text-slate-400"><th>Service</th><th className="text-center">Status</th><th className="text-center">PID</th><th className="text-center">Uptime</th><th className="text-center">Actions</th></tr></thead>
|
|
33
|
-
<tbody>
|
|
34
|
-
{services.map((s) => (
|
|
35
|
-
<tr key={s.name}>
|
|
36
|
-
<td className="font-mono text-slate-200">{s.name}</td>
|
|
37
|
-
<td className={`text-center ${color(s.status)}`}>
|
|
38
|
-
<span className="inline-flex items-center gap-2">
|
|
39
|
-
<span className={`inline-block h-2 w-2 rounded-full ${s.status === 'running' ? 'bg-emerald-400 shadow-[0_0_8px_rgba(52,211,153,0.9)]' : s.status === 'errored' ? 'bg-rose-500' : 'bg-slate-500'}`} />
|
|
40
|
-
{s.status}
|
|
41
|
-
</span>
|
|
42
|
-
</td>
|
|
43
|
-
<td className="text-center text-slate-400">{s.pid ?? '—'}</td>
|
|
44
|
-
<td className="text-center text-slate-400">{s.startedAt ? `${Math.round((Date.now() - s.startedAt) / 1000)}s` : '—'}</td>
|
|
45
|
-
<td className="text-center">
|
|
46
|
-
<span className="inline-flex gap-1.5">
|
|
47
|
-
<button onClick={() => controlService('start', s.name)} className="glass-chip-btn">start</button>
|
|
48
|
-
<button onClick={() => controlService('stop', s.name)} className="glass-chip-btn-danger">stop</button>
|
|
49
|
-
<button onClick={() => controlService('restart', s.name)} className="glass-chip-btn">restart</button>
|
|
50
|
-
<button onClick={() => showLogs(s.name)} className="glass-chip-btn">logs</button>
|
|
51
|
-
</span>
|
|
52
|
-
</td>
|
|
53
|
-
</tr>
|
|
54
|
-
))}
|
|
55
|
-
{!services.length && <tr><td colSpan={5} className="py-8 text-center text-slate-500">No services reported by the supervisor.</td></tr>}
|
|
56
|
-
</tbody>
|
|
57
|
-
</table>
|
|
58
|
-
</div>
|
|
59
|
-
{sel && (
|
|
60
|
-
<div className="glass-card space-y-2">
|
|
61
|
-
<h3 className="glass-h3">{sel} logs</h3>
|
|
62
|
-
<pre className="glass-code max-h-80 overflow-auto p-3">{((logs[sel] ?? []).join('\n')) || '— empty —'}</pre>
|
|
63
|
-
</div>
|
|
64
|
-
)}
|
|
65
|
-
</div>
|
|
66
|
-
);
|
|
67
|
-
}
|
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
import React, { useState, useEffect } from 'react';
|
|
2
|
-
import { getDatabases, createCollection, generateSchema, getAiStatus, type DatabaseInfo, type GeneratedSchema, type AiStatus } from '../api.js';
|
|
3
|
-
import { useAdminAlert } from '../alertCenter.js';
|
|
4
|
-
import { PageHead } from '../components/ui/PageHead.js';
|
|
5
|
-
import { PageHero } from '../components/ui/PageHero.js';
|
|
6
|
-
|
|
7
|
-
export function Schema() {
|
|
8
|
-
const [prompt, setPrompt] = useState('');
|
|
9
|
-
const [databases, setDatabases] = useState<DatabaseInfo[]>([]);
|
|
10
|
-
const [db, setDb] = useState('');
|
|
11
|
-
const [result, setResult] = useState<GeneratedSchema | null>(null);
|
|
12
|
-
const [edited, setEdited] = useState('');
|
|
13
|
-
const [busy, setBusy] = useState(false);
|
|
14
|
-
const [msg, setMsg] = useAdminAlert('schema');
|
|
15
|
-
const [aiStatus, setAiStatus] = useState<AiStatus | null>(null);
|
|
16
|
-
const [aiErr, setAiErr] = useState('');
|
|
17
|
-
|
|
18
|
-
useEffect(() => { getDatabases().then((d) => setDatabases(d.databases ?? [])).catch(() => {}); }, []);
|
|
19
|
-
|
|
20
|
-
const checkAi = async () => {
|
|
21
|
-
setAiErr('');
|
|
22
|
-
try { setAiStatus(await getAiStatus()); } catch (e: any) { setAiErr(String(e?.message ?? e)); }
|
|
23
|
-
};
|
|
24
|
-
useEffect(() => { checkAi(); }, []);
|
|
25
|
-
|
|
26
|
-
const generate = async () => {
|
|
27
|
-
if (!prompt.trim()) { setMsg('Describe the data you want to model, e.g. "products with a name, price and category".'); return; }
|
|
28
|
-
setBusy(true); setMsg('');
|
|
29
|
-
try {
|
|
30
|
-
const g = await generateSchema(prompt);
|
|
31
|
-
setResult(g);
|
|
32
|
-
setEdited(JSON.stringify(g.jsonSchema, null, 2));
|
|
33
|
-
setMsg(`Schema generated (${g.model}) — review and create.`);
|
|
34
|
-
} catch (e: any) { setMsg(`Generation failed: ${e?.message ?? e} — check the AI status below (is the AI server running on :8000?).`); }
|
|
35
|
-
finally { setBusy(false); }
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
const create = async () => {
|
|
39
|
-
setMsg('');
|
|
40
|
-
let schema: unknown;
|
|
41
|
-
try { schema = JSON.parse(edited); } catch { setMsg('Edited schema is not valid JSON.'); return; }
|
|
42
|
-
const target = db || databases[0]?.name;
|
|
43
|
-
if (!target) { setMsg('Create a database first (Databases tab), then pick it here.'); return; }
|
|
44
|
-
try {
|
|
45
|
-
await createCollection(target, { name: result?.collection ?? 'ai_schema', jsonSchema: schema });
|
|
46
|
-
setMsg(`Created collection "${result?.collection ?? 'ai_schema'}" in "${target}" with the validator attached.`);
|
|
47
|
-
} catch (e: any) { setMsg(`Create failed: ${e?.message ?? e}`); }
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
return (
|
|
51
|
-
<div className="space-y-6">
|
|
52
|
-
<PageHead title="AI Schema Creation" />
|
|
53
|
-
<PageHero kicker="AI WORKSPACE" title={<>Schemas, <em>born from language.</em></>} desc="Describe the data you need in plain English and let the model return a validated MongoDB schema you can review before it's live." glyph="sparkles" art="bolt" />
|
|
54
|
-
<p className="text-sm text-slate-400">Describe the data in plain English — the AI returns a MongoDB <code className="rounded bg-white/10 px-1 font-mono text-xs">$jsonSchema</code> validator plus a field list, which you review and create as a validator-protected collection.</p>
|
|
55
|
-
|
|
56
|
-
<section className="glass-card space-y-3">
|
|
57
|
-
<div className="flex items-center justify-between">
|
|
58
|
-
<h3 className="glass-h3">AI provider status</h3>
|
|
59
|
-
<button onClick={checkAi} className="glass-chip-btn">re-check</button>
|
|
60
|
-
</div>
|
|
61
|
-
{aiErr && <p className="text-xs text-rose-400">{aiErr}</p>}
|
|
62
|
-
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
|
63
|
-
{aiStatus ? (
|
|
64
|
-
<>
|
|
65
|
-
{([
|
|
66
|
-
['AI server', aiStatus.aiServer, aiStatus.aiServer.detail ? `health: ${aiStatus.aiServer.detail.status ?? 'unknown'}` : undefined],
|
|
67
|
-
['Ollama', aiStatus.ollama, aiStatus.ollama.detail?.modelCount != null ? `${aiStatus.ollama.detail.modelCount} model(s) reachable` : undefined],
|
|
68
|
-
] as const).map(([label, s, detail]) => (
|
|
69
|
-
<div key={label} className="glass flex items-center gap-2 rounded-xl px-3 py-2 text-sm">
|
|
70
|
-
<span className={s.ok ? 'dot-ok' : 'dot-bad'} />
|
|
71
|
-
<span className="font-medium text-slate-200">{label}</span>
|
|
72
|
-
<span className={`text-xs ${s.ok ? 'text-emerald-400' : 'text-rose-400'}`}>{s.ok ? (detail ?? 'ok') : (s.error ?? 'down')}</span>
|
|
73
|
-
</div>
|
|
74
|
-
))}
|
|
75
|
-
</>
|
|
76
|
-
) : (
|
|
77
|
-
<p className="col-span-3 text-xs text-slate-500">No status yet{aiErr ? '' : ' — run a check'}.</p>
|
|
78
|
-
)}
|
|
79
|
-
</div>
|
|
80
|
-
{aiStatus && <p className="text-xs text-slate-500">"auto" currently resolves to <span className="font-mono text-slate-300">{aiStatus.autoResolvesTo}</span> on the AI server.</p>}
|
|
81
|
-
</section>
|
|
82
|
-
|
|
83
|
-
<div className="glass-card space-y-3">
|
|
84
|
-
<div className="flex items-center justify-between">
|
|
85
|
-
<h3 className="glass-h3">Describe your collection</h3>
|
|
86
|
-
<span className="inline-flex items-center gap-1.5 rounded-full border border-indigo-300/30 bg-indigo-400/10 px-2.5 py-1 text-[0.6rem] font-semibold uppercase tracking-wider text-indigo-300"><i className="dot-ok" /> AI auto</span>
|
|
87
|
-
</div>
|
|
88
|
-
<textarea className="glass-code h-32 w-full p-3 text-sm" placeholder='e.g. "a products collection with name, price (number), in-stock flag, category from a fixed enum, created date and an optional description"' value={prompt} onChange={(e) => setPrompt(e.target.value)} />
|
|
89
|
-
<div className="flex flex-wrap items-center gap-2">
|
|
90
|
-
<label className="mr-1.5 text-sm text-slate-400">Database</label>
|
|
91
|
-
<select className="glass-input w-48 !py-1.5 text-sm" value={db} onChange={(e) => setDb(e.target.value)}>
|
|
92
|
-
<option value="" className="bg-slate-900">{databases.length ? 'pick a database…' : 'no databases yet'}</option>
|
|
93
|
-
{databases.map((d) => <option key={d.name} value={d.name} className="bg-slate-900">{d.name}</option>)}
|
|
94
|
-
</select>
|
|
95
|
-
<span className="text-xs text-slate-500">Provider & model auto-selected on the AI server.</span>
|
|
96
|
-
<button onClick={generate} disabled={busy} className="glass-btn-primary ml-auto">{busy ? 'Generating…' : 'Generate with AI'}</button>
|
|
97
|
-
</div>
|
|
98
|
-
</div>
|
|
99
|
-
|
|
100
|
-
{result && (
|
|
101
|
-
<div className="space-y-3">
|
|
102
|
-
<div className="glass-card space-y-3">
|
|
103
|
-
<h3 className="glass-h3">Generated schema — <span className="font-mono text-indigo-300">{result.collection}</span></h3>
|
|
104
|
-
<div className="max-h-48 overflow-auto rounded-xl border border-white/10">
|
|
105
|
-
<table className="glass-table">
|
|
106
|
-
<thead><tr><th>Field</th><th>Type</th><th className="text-center">Required</th><th className="text-center">Unique</th><th>Description</th></tr></thead>
|
|
107
|
-
<tbody>
|
|
108
|
-
{result.fields.map((f) => (
|
|
109
|
-
<tr key={f.name}>
|
|
110
|
-
<td className="font-mono text-slate-200">{f.name}</td>
|
|
111
|
-
<td className="text-slate-300">{f.type}</td>
|
|
112
|
-
<td className="text-center text-emerald-400">{f.required ? '✓' : ''}</td>
|
|
113
|
-
<td className="text-center text-indigo-300">{f.unique ? '✓' : ''}</td>
|
|
114
|
-
<td className="text-slate-400">{f.description ?? ''}</td>
|
|
115
|
-
</tr>
|
|
116
|
-
))}
|
|
117
|
-
</tbody>
|
|
118
|
-
</table>
|
|
119
|
-
</div>
|
|
120
|
-
<div>
|
|
121
|
-
<h4 className="glass-h3 mb-2">$jsonSchema validator <span className="ml-1 font-normal normal-case text-slate-500">(editable before creating)</span></h4>
|
|
122
|
-
<textarea className="glass-code h-56 w-full p-3 text-xs" value={edited} onChange={(e) => setEdited(e.target.value)} spellCheck={false} />
|
|
123
|
-
</div>
|
|
124
|
-
<button onClick={create} className="glass-btn-success">Create collection with validator</button>
|
|
125
|
-
</div>
|
|
126
|
-
</div>
|
|
127
|
-
)}
|
|
128
|
-
</div>
|
|
129
|
-
);
|
|
130
|
-
}
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
import React, { useState, useEffect, useCallback } from 'react';
|
|
2
|
-
import { getUsers, getRoles, putUserRoles, type UserRecord, type RoleDefinition } from '../api.js';
|
|
3
|
-
import { useAdminAlert } from '../alertCenter.js';
|
|
4
|
-
import { PageHead } from '../components/ui/PageHead.js';
|
|
5
|
-
import { PageHero } from '../components/ui/PageHero.js';
|
|
6
|
-
|
|
7
|
-
export function Users({ currentUserEmail }: { currentUserEmail?: string }) {
|
|
8
|
-
const [data, setData] = useState<{ users: UserRecord[] } | null>(null);
|
|
9
|
-
const [roles, setRoles] = useState<RoleDefinition[]>([]);
|
|
10
|
-
const [msg, setMsg] = useAdminAlert('users');
|
|
11
|
-
const [editing, setEditing] = useState<UserRecord | null>(null);
|
|
12
|
-
const [draft, setDraft] = useState<string[]>([]);
|
|
13
|
-
const [busy, setBusy] = useState(false);
|
|
14
|
-
|
|
15
|
-
const reload = useCallback(async () => {
|
|
16
|
-
try {
|
|
17
|
-
const [u, r] = await Promise.all([getUsers(), getRoles()]);
|
|
18
|
-
setData(u);
|
|
19
|
-
setRoles(r?.roles ?? []);
|
|
20
|
-
} catch (e: any) {
|
|
21
|
-
setMsg({ kind: 'err', text: `Could not load users or roles: ${e?.message ?? 'unknown error'}` });
|
|
22
|
-
}
|
|
23
|
-
}, []);
|
|
24
|
-
|
|
25
|
-
useEffect(() => { void reload(); }, [reload]);
|
|
26
|
-
|
|
27
|
-
const openEditor = (u: UserRecord) => { setEditing(u); setDraft([...(u.roles ?? [])]); setMsg(null); };
|
|
28
|
-
const toggleRole = (id: string) => setDraft((d) => (d.includes(id) ? d.filter((r) => r !== id) : [...d, id]));
|
|
29
|
-
|
|
30
|
-
const saveRoles = async () => {
|
|
31
|
-
if (!editing) return;
|
|
32
|
-
if (!draft.length) { setMsg({ kind: 'err', text: 'A user must keep at least one role.' }); return; }
|
|
33
|
-
setBusy(true);
|
|
34
|
-
try {
|
|
35
|
-
await putUserRoles(editing._id, draft);
|
|
36
|
-
setMsg({ kind: 'ok', text: `Saved roles for ${editing.email}.` });
|
|
37
|
-
setEditing(null);
|
|
38
|
-
await reload();
|
|
39
|
-
} catch (e: any) {
|
|
40
|
-
const raw = String(e?.message ?? 'Update failed');
|
|
41
|
-
const clean = raw.replace(/^.*\/admin\/users[^\s]*\s*\d+\s*/i, '').trim();
|
|
42
|
-
setMsg({ kind: 'err', text: clean || 'Update failed.' });
|
|
43
|
-
} finally { setBusy(false); }
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
const roleDef = (id: string) => roles.find((r) => r.id === id);
|
|
47
|
-
const roleChip = (id: string) => {
|
|
48
|
-
const def = roleDef(id);
|
|
49
|
-
const accent = def?.accent ?? '#8ba0bd';
|
|
50
|
-
return <span key={id} className="role-chip" style={{ color: accent, borderColor: `${accent}66`, background: `${accent}1a` }}>{def?.icon ?? '◌'} {def?.label ?? id}</span>;
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
if (!data) return <p className="text-slate-400">Loading…</p>;
|
|
54
|
-
|
|
55
|
-
return (
|
|
56
|
-
<div className="space-y-6">
|
|
57
|
-
<PageHead title="Users & Roles" />
|
|
58
|
-
<PageHero kicker="ACCESS & PERMISSIONS" title={<>Your team, <em>in control.</em></>} desc="Every account that can reach this Nexus surface — with role grants and restrictions applied in one built-in permission catalog." glyph="users" art="people" />
|
|
59
|
-
|
|
60
|
-
<div className="glass-panel overflow-hidden p-2">
|
|
61
|
-
<table className="glass-table">
|
|
62
|
-
<thead><tr><th>Email</th><th className="text-center">Name</th><th>Roles</th><th className="text-center">Verified</th><th className="text-right">Actions</th></tr></thead>
|
|
63
|
-
<tbody>
|
|
64
|
-
{data.users.map((u) => (
|
|
65
|
-
<tr key={String(u._id)}>
|
|
66
|
-
<td className="text-slate-200">{u.email}{u.email === currentUserEmail && <span className="you-tag">you</span>}</td>
|
|
67
|
-
<td className="text-slate-300">{u.name ?? '—'}</td>
|
|
68
|
-
<td>
|
|
69
|
-
{(u.roles ?? []).map(roleChip)}
|
|
70
|
-
{!(u.roles ?? []).length && '—'}
|
|
71
|
-
</td>
|
|
72
|
-
<td className="text-center text-emerald-400">{u.emailVerified ? '✓' : <span className="text-slate-500">—</span>}</td>
|
|
73
|
-
<td className="text-right"><button className="glass-chip-btn" onClick={() => openEditor(u)}>â—ˆ Edit roles</button></td>
|
|
74
|
-
</tr>
|
|
75
|
-
))}
|
|
76
|
-
{!data.users.length && <tr><td colSpan={5} className="py-8 text-center text-slate-500">No users registered.</td></tr>}
|
|
77
|
-
</tbody>
|
|
78
|
-
</table>
|
|
79
|
-
</div>
|
|
80
|
-
|
|
81
|
-
<div className="role-catalog">
|
|
82
|
-
{roles.map((r) => (
|
|
83
|
-
<div className="role-box" key={r.id} style={{ ['--role-accent' as string]: r.accent }}>
|
|
84
|
-
<div className="role-box-head">
|
|
85
|
-
<span className="role-box-icon" style={{ color: r.accent }}>{r.icon}</span>
|
|
86
|
-
<div>
|
|
87
|
-
<strong style={{ color: r.accent }}>{r.label}</strong>
|
|
88
|
-
<code className="role-box-id">{r.id}</code>
|
|
89
|
-
</div>
|
|
90
|
-
</div>
|
|
91
|
-
<p className="role-box-desc">{r.description}</p>
|
|
92
|
-
<div className="role-section">
|
|
93
|
-
<span className="role-section-title is-can">✓ Can do</span>
|
|
94
|
-
<ul className="role-list">
|
|
95
|
-
{r.grants.map((g) => <li key={g.label}><b>{g.label}</b><span>{g.detail}</span></li>)}
|
|
96
|
-
</ul>
|
|
97
|
-
</div>
|
|
98
|
-
<div className="role-section">
|
|
99
|
-
<span className="role-section-title is-no">✕ Restricted from</span>
|
|
100
|
-
<ul className="role-list">
|
|
101
|
-
{r.restricts.map((g) => <li key={g.label}><b>{g.label}</b><span>{g.detail}</span></li>)}
|
|
102
|
-
</ul>
|
|
103
|
-
</div>
|
|
104
|
-
</div>
|
|
105
|
-
))}
|
|
106
|
-
</div>
|
|
107
|
-
|
|
108
|
-
{editing && (
|
|
109
|
-
<div className="confirm-overlay" onClick={() => !busy && setEditing(null)}>
|
|
110
|
-
<div className="confirm-dialog role-dialog" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
|
111
|
-
<div className="confirm-dialog-head">
|
|
112
|
-
<span className="confirm-dialog-mark">â—Ž</span>
|
|
113
|
-
<strong>Edit roles · {editing.email}</strong>
|
|
114
|
-
<button className="confirm-dialog-close" onClick={() => !busy && setEditing(null)} aria-label="Close">×</button>
|
|
115
|
-
</div>
|
|
116
|
-
<p className="confirm-dialog-message">Choose which roles this account holds — every selected role applies.</p>
|
|
117
|
-
<div className="role-options">
|
|
118
|
-
{roles.map((r) => {
|
|
119
|
-
const on = draft.includes(r.id);
|
|
120
|
-
return (
|
|
121
|
-
<button key={r.id} type="button" className={`role-option${on ? ' is-on' : ''}`} style={{ ['--role-accent' as string]: r.accent }} onClick={() => toggleRole(r.id)}>
|
|
122
|
-
<span className="role-option-icon" style={{ color: r.accent }}>{on ? '✓' : r.icon}</span>
|
|
123
|
-
<span className="role-option-text">
|
|
124
|
-
<b>{r.label}</b>
|
|
125
|
-
<small>{r.description}</small>
|
|
126
|
-
</span>
|
|
127
|
-
<span className="role-option-check">{on ? '✓' : ''}</span>
|
|
128
|
-
</button>
|
|
129
|
-
);
|
|
130
|
-
})}
|
|
131
|
-
</div>
|
|
132
|
-
{editing.email === currentUserEmail && (
|
|
133
|
-
<p className="role-hint">Editing your own account — removing <b>Administrator</b> from yourself is blocked server-side.</p>
|
|
134
|
-
)}
|
|
135
|
-
<div className="confirm-dialog-actions">
|
|
136
|
-
<button className="glass-chip-btn" onClick={() => setEditing(null)} disabled={busy}>Cancel</button>
|
|
137
|
-
<button className="glass-chip-btn-danger confirm-dialog-primary" onClick={saveRoles} disabled={busy}>{busy ? 'Saving…' : 'Save roles'}</button>
|
|
138
|
-
</div>
|
|
139
|
-
</div>
|
|
140
|
-
</div>
|
|
141
|
-
)}
|
|
142
|
-
</div>
|
|
143
|
-
);
|
|
144
|
-
}
|
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
import React, { useEffect, useState } from 'react';
|
|
2
|
-
import { POPULAR_PROVIDERS, PRESET_BY_ID } from '../../lib/constants.js';
|
|
3
|
-
import type { ProviderPreset } from '../../lib/types.js';
|
|
4
|
-
|
|
5
|
-
export function AddProviderDialog({ value, onChange, onSubmit, onCancel, busy }: {
|
|
6
|
-
value: { id: string; label: string; baseUrl: string; apiKey: string; defaultModel: string };
|
|
7
|
-
onChange: (v: { id: string; label: string; baseUrl: string; apiKey: string; defaultModel: string }) => void;
|
|
8
|
-
onSubmit: () => void;
|
|
9
|
-
onCancel: () => void;
|
|
10
|
-
busy: boolean;
|
|
11
|
-
}) {
|
|
12
|
-
const [presetId, setPresetId] = useState('');
|
|
13
|
-
|
|
14
|
-
useEffect(() => {
|
|
15
|
-
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onCancel(); };
|
|
16
|
-
window.addEventListener('keydown', onKey);
|
|
17
|
-
return () => window.removeEventListener('keydown', onKey);
|
|
18
|
-
}, [onCancel]);
|
|
19
|
-
|
|
20
|
-
// Track which field the user is actively editing so we don't clobber their
|
|
21
|
-
// manual input when the preset changes the model list.
|
|
22
|
-
const preset = presetId ? PRESET_BY_ID[presetId] : undefined;
|
|
23
|
-
|
|
24
|
-
const applyPreset = (id: string) => {
|
|
25
|
-
setPresetId(id);
|
|
26
|
-
if (!id) return; // "— select —"
|
|
27
|
-
if (id === 'other') {
|
|
28
|
-
// Clear fields for a fully custom provider; user fills everything.
|
|
29
|
-
onChange({ id: '', label: '', baseUrl: '', apiKey: value.apiKey, defaultModel: '' });
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
const p = PRESET_BY_ID[id];
|
|
33
|
-
if (p) {
|
|
34
|
-
onChange({ ...value, id: p.id, label: p.label, baseUrl: p.baseUrl, defaultModel: p.defaultModel });
|
|
35
|
-
}
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
const missing = {
|
|
39
|
-
id: !value.id.trim(),
|
|
40
|
-
label: !value.label.trim(),
|
|
41
|
-
baseUrl: !value.baseUrl.trim(),
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
return (
|
|
45
|
-
<div className="provider-modal-backdrop" onClick={onCancel}>
|
|
46
|
-
<div className="provider-modal space-y-4" onClick={(e) => e.stopPropagation()}>
|
|
47
|
-
<div className="flex items-center justify-between">
|
|
48
|
-
<h3 className="glass-h3">Add custom provider</h3>
|
|
49
|
-
<button onClick={onCancel} className="glass-chip-btn text-xs">✕</button>
|
|
50
|
-
</div>
|
|
51
|
-
|
|
52
|
-
<div className="space-y-3">
|
|
53
|
-
{/* Preset selector — pick a popular provider or "Other" */}
|
|
54
|
-
<label className="block">
|
|
55
|
-
<span className="admin-overline">Choose a provider</span>
|
|
56
|
-
<select
|
|
57
|
-
className="glass-input mt-1 text-sm"
|
|
58
|
-
value={presetId}
|
|
59
|
-
onChange={(e) => applyPreset(e.target.value)}
|
|
60
|
-
>
|
|
61
|
-
<option value="" className="bg-slate-900">— select —</option>
|
|
62
|
-
{POPULAR_PROVIDERS.map((p) => (
|
|
63
|
-
<option key={p.id} value={p.id} className="bg-slate-900">{p.label}</option>
|
|
64
|
-
))}
|
|
65
|
-
<option value="other" className="bg-slate-900">Other (custom)</option>
|
|
66
|
-
</select>
|
|
67
|
-
<span className="text-[10px] text-slate-500">Selecting a provider autofills the fields below — all editable.</span>
|
|
68
|
-
</label>
|
|
69
|
-
|
|
70
|
-
<label className="block">
|
|
71
|
-
<span className="admin-overline">Provider ID <span className="text-rose-400">*</span></span>
|
|
72
|
-
<input
|
|
73
|
-
className={`glass-input mt-1 text-sm font-mono ${missing.id ? '!border-amber-500/50' : ''}`}
|
|
74
|
-
placeholder="e.g. myai"
|
|
75
|
-
value={value.id}
|
|
76
|
-
onChange={(e) => onChange({ ...value, id: e.target.value })}
|
|
77
|
-
/>
|
|
78
|
-
{missing.id && <span className="text-[10px] text-amber-400">required — lowercase, no spaces</span>}
|
|
79
|
-
</label>
|
|
80
|
-
|
|
81
|
-
<label className="block">
|
|
82
|
-
<span className="admin-overline">Label <span className="text-rose-400">*</span></span>
|
|
83
|
-
<input
|
|
84
|
-
className={`glass-input mt-1 text-sm ${missing.label ? '!border-amber-500/50' : ''}`}
|
|
85
|
-
placeholder="e.g. My AI Service"
|
|
86
|
-
value={value.label}
|
|
87
|
-
onChange={(e) => onChange({ ...value, label: e.target.value })}
|
|
88
|
-
/>
|
|
89
|
-
{missing.label && <span className="text-[10px] text-amber-400">required</span>}
|
|
90
|
-
</label>
|
|
91
|
-
|
|
92
|
-
<label className="block">
|
|
93
|
-
<span className="admin-overline">Base URL <span className="text-rose-400">*</span></span>
|
|
94
|
-
<input
|
|
95
|
-
className={`glass-input mt-1 text-sm font-mono ${missing.baseUrl ? '!border-amber-500/50' : ''}`}
|
|
96
|
-
placeholder="https://api.example.com/v1"
|
|
97
|
-
value={value.baseUrl}
|
|
98
|
-
onChange={(e) => onChange({ ...value, baseUrl: e.target.value })}
|
|
99
|
-
/>
|
|
100
|
-
{missing.baseUrl && <span className="text-[10px] text-amber-400">required</span>}
|
|
101
|
-
</label>
|
|
102
|
-
|
|
103
|
-
<label className="block">
|
|
104
|
-
<span className="admin-overline">API key <span className="text-slate-500">(optional)</span></span>
|
|
105
|
-
<input
|
|
106
|
-
className="glass-input mt-1 text-sm"
|
|
107
|
-
type="password"
|
|
108
|
-
placeholder="paste API key"
|
|
109
|
-
value={value.apiKey}
|
|
110
|
-
onChange={(e) => onChange({ ...value, apiKey: e.target.value })}
|
|
111
|
-
/>
|
|
112
|
-
</label>
|
|
113
|
-
|
|
114
|
-
{/* Default model — text input + a "pick from popular" select */}
|
|
115
|
-
<label className="block">
|
|
116
|
-
<span className="admin-overline">Default model <span className="text-slate-500">(optional)</span></span>
|
|
117
|
-
<input
|
|
118
|
-
className="glass-input mt-1 text-sm font-mono"
|
|
119
|
-
placeholder="e.g. gpt-4o-mini"
|
|
120
|
-
value={value.defaultModel}
|
|
121
|
-
onChange={(e) => onChange({ ...value, defaultModel: e.target.value })}
|
|
122
|
-
/>
|
|
123
|
-
</label>
|
|
124
|
-
{preset && preset.models.length > 0 && (
|
|
125
|
-
<label className="block">
|
|
126
|
-
<span className="admin-overline">…or pick a model</span>
|
|
127
|
-
<select
|
|
128
|
-
className="glass-input mt-1 text-sm font-mono"
|
|
129
|
-
value=""
|
|
130
|
-
onChange={(e) => { if (e.target.value) onChange({ ...value, defaultModel: e.target.value }); }}
|
|
131
|
-
>
|
|
132
|
-
<option value="" className="bg-slate-900">{preset.models.length} popular model{preset.models.length === 1 ? '' : 's'} — click to use</option>
|
|
133
|
-
{preset.models.map((m) => (
|
|
134
|
-
<option key={m} value={m} className="bg-slate-900">{m}</option>
|
|
135
|
-
))}
|
|
136
|
-
</select>
|
|
137
|
-
</label>
|
|
138
|
-
)}
|
|
139
|
-
</div>
|
|
140
|
-
|
|
141
|
-
<div className="flex justify-end gap-2 pt-2 border-t border-sky-400/20">
|
|
142
|
-
<button onClick={onCancel} disabled={busy} className="glass-chip-btn">Cancel</button>
|
|
143
|
-
<button onClick={onSubmit} disabled={busy || missing.id || missing.label || missing.baseUrl} className="glass-btn-primary !py-1.5 text-sm">
|
|
144
|
-
{busy ? 'Adding…' : '+ Add provider'}
|
|
145
|
-
</button>
|
|
146
|
-
</div>
|
|
147
|
-
</div>
|
|
148
|
-
</div>
|
|
149
|
-
);
|
|
150
|
-
}
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import React, { useState } from 'react';
|
|
2
|
-
import { Icon } from '../../icons.js';
|
|
3
|
-
import type { ToggleStyle } from '../../lib/types.js';
|
|
4
|
-
import { PageHead } from '../../components/ui/PageHead.js';
|
|
5
|
-
import { PageHero } from '../../components/ui/PageHero.js';
|
|
6
|
-
import { AiSettings } from './AiSettings.js';
|
|
7
|
-
import { AiProviders } from './AiProviders.js';
|
|
8
|
-
import { AiChatPlayground } from './AiChatPlayground.js';
|
|
9
|
-
|
|
10
|
-
export function AiAgents({ toggle }: { toggle: ToggleStyle }) {
|
|
11
|
-
const [aiTab, setAiTab] = useState<'settings' | 'providers' | 'chat'>('settings');
|
|
12
|
-
return (
|
|
13
|
-
<div className="space-y-6">
|
|
14
|
-
<PageHead title="AI Agents" />
|
|
15
|
-
<PageHero kicker="AI WORKSPACE" title={<>Agents, <em>at your command.</em></>} desc="Manage AI settings, configure providers with API keys, and test agents with a live chat playground." glyph="sparkles" art="spark" />
|
|
16
|
-
|
|
17
|
-
{/* Sub-tab nav */}
|
|
18
|
-
<div className="workspace-tabs">
|
|
19
|
-
{([['settings', 'AI Settings', 'settings'], ['providers', 'AI Providers', 'grid'], ['chat', 'Chat Playground', 'message']] as const).map(([id, label, icon]) => (
|
|
20
|
-
<button key={id} onClick={() => setAiTab(id)} className={aiTab === id ? 'is-active' : ''}>
|
|
21
|
-
<span className="mr-1.5"><Icon name={icon} size={13} /></span>{label}
|
|
22
|
-
</button>
|
|
23
|
-
))}
|
|
24
|
-
</div>
|
|
25
|
-
|
|
26
|
-
{aiTab === 'settings' && <AiSettings />}
|
|
27
|
-
{aiTab === 'providers' && <AiProviders toggle={toggle} />}
|
|
28
|
-
{aiTab === 'chat' && <AiChatPlayground />}
|
|
29
|
-
</div>
|
|
30
|
-
);
|
|
31
|
-
}
|