@agenticmail/enterprise 0.2.1
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/ARCHITECTURE.md +183 -0
- package/agenticmail-enterprise.db +0 -0
- package/dashboards/README.md +120 -0
- package/dashboards/dotnet/Program.cs +261 -0
- package/dashboards/express/app.js +146 -0
- package/dashboards/go/main.go +513 -0
- package/dashboards/html/index.html +535 -0
- package/dashboards/java/AgenticMailDashboard.java +376 -0
- package/dashboards/php/index.php +414 -0
- package/dashboards/python/app.py +273 -0
- package/dashboards/ruby/app.rb +195 -0
- package/dist/chunk-77IDQJL3.js +7 -0
- package/dist/chunk-7RGCCHIT.js +115 -0
- package/dist/chunk-DXNKR3TG.js +1355 -0
- package/dist/chunk-IQWA44WT.js +970 -0
- package/dist/chunk-LCUZGIDH.js +965 -0
- package/dist/chunk-N2JVTNNJ.js +2553 -0
- package/dist/chunk-O462UJBH.js +363 -0
- package/dist/chunk-PNKVD2UK.js +26 -0
- package/dist/cli.js +218 -0
- package/dist/dashboard/index.html +558 -0
- package/dist/db-adapter-DEWEFNIV.js +7 -0
- package/dist/dynamodb-CCGL2E77.js +426 -0
- package/dist/engine/index.js +1261 -0
- package/dist/index.js +522 -0
- package/dist/mongodb-ODTXIVPV.js +319 -0
- package/dist/mysql-RM3S2FV5.js +521 -0
- package/dist/postgres-LN7A6MGQ.js +518 -0
- package/dist/routes-2JEPIIKC.js +441 -0
- package/dist/routes-74ZLKJKP.js +399 -0
- package/dist/server.js +7 -0
- package/dist/sqlite-3K5YOZ4K.js +439 -0
- package/dist/turso-LDWODSDI.js +442 -0
- package/package.json +49 -0
- package/src/admin/routes.ts +331 -0
- package/src/auth/routes.ts +130 -0
- package/src/cli.ts +260 -0
- package/src/dashboard/index.html +558 -0
- package/src/db/adapter.ts +230 -0
- package/src/db/dynamodb.ts +456 -0
- package/src/db/factory.ts +51 -0
- package/src/db/mongodb.ts +360 -0
- package/src/db/mysql.ts +472 -0
- package/src/db/postgres.ts +479 -0
- package/src/db/sql-schema.ts +123 -0
- package/src/db/sqlite.ts +391 -0
- package/src/db/turso.ts +411 -0
- package/src/deploy/fly.ts +368 -0
- package/src/deploy/managed.ts +213 -0
- package/src/engine/activity.ts +474 -0
- package/src/engine/agent-config.ts +429 -0
- package/src/engine/agenticmail-bridge.ts +296 -0
- package/src/engine/approvals.ts +278 -0
- package/src/engine/db-adapter.ts +682 -0
- package/src/engine/db-schema.ts +335 -0
- package/src/engine/deployer.ts +595 -0
- package/src/engine/index.ts +134 -0
- package/src/engine/knowledge.ts +486 -0
- package/src/engine/lifecycle.ts +635 -0
- package/src/engine/openclaw-hook.ts +371 -0
- package/src/engine/routes.ts +528 -0
- package/src/engine/skills.ts +473 -0
- package/src/engine/tenant.ts +345 -0
- package/src/engine/tool-catalog.ts +189 -0
- package/src/index.ts +64 -0
- package/src/lib/resilience.ts +326 -0
- package/src/middleware/index.ts +286 -0
- package/src/server.ts +310 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>AgenticMail Enterprise</title>
|
|
7
|
+
<style>
|
|
8
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
9
|
+
:root {
|
|
10
|
+
--bg: #0a0a0f; --surface: #12121a; --border: #1e1e2e; --border-hover: #2e2e4e;
|
|
11
|
+
--text: #e4e4ef; --text-dim: #8888a0; --text-muted: #55556a;
|
|
12
|
+
--primary: #6366f1; --primary-hover: #818cf8; --primary-dim: rgba(99,102,241,0.15);
|
|
13
|
+
--success: #22c55e; --warning: #f59e0b; --danger: #ef4444;
|
|
14
|
+
--radius: 8px; --radius-lg: 12px;
|
|
15
|
+
}
|
|
16
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; }
|
|
17
|
+
#root { display: flex; min-height: 100vh; }
|
|
18
|
+
|
|
19
|
+
/* Sidebar */
|
|
20
|
+
.sidebar { width: 240px; background: var(--surface); border-right: 1px solid var(--border); padding: 20px 0; display: flex; flex-direction: column; position: fixed; top: 0; left: 0; bottom: 0; z-index: 10; }
|
|
21
|
+
.sidebar-logo { padding: 0 20px 20px; border-bottom: 1px solid var(--border); margin-bottom: 8px; }
|
|
22
|
+
.sidebar-logo h1 { font-size: 16px; font-weight: 700; letter-spacing: -0.02em; }
|
|
23
|
+
.sidebar-logo span { color: var(--primary); }
|
|
24
|
+
.sidebar-logo p { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
|
|
25
|
+
.nav-item { display: flex; align-items: center; gap: 10px; padding: 10px 20px; font-size: 13px; color: var(--text-dim); cursor: pointer; transition: all 0.15s; border: none; background: none; width: 100%; text-align: left; }
|
|
26
|
+
.nav-item:hover { color: var(--text); background: rgba(255,255,255,0.03); }
|
|
27
|
+
.nav-item.active { color: var(--primary); background: var(--primary-dim); border-right: 2px solid var(--primary); }
|
|
28
|
+
.nav-section { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); padding: 16px 20px 6px; }
|
|
29
|
+
|
|
30
|
+
/* Main content */
|
|
31
|
+
.main { flex: 1; margin-left: 240px; padding: 32px; max-width: 1200px; }
|
|
32
|
+
.page-title { font-size: 22px; font-weight: 700; margin-bottom: 4px; letter-spacing: -0.02em; }
|
|
33
|
+
.page-desc { font-size: 13px; color: var(--text-dim); margin-bottom: 24px; }
|
|
34
|
+
|
|
35
|
+
/* Cards */
|
|
36
|
+
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 20px; margin-bottom: 16px; }
|
|
37
|
+
.card-title { font-size: 13px; color: var(--text-dim); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
38
|
+
|
|
39
|
+
/* Stats grid */
|
|
40
|
+
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
|
41
|
+
.stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 20px; }
|
|
42
|
+
.stat-card .label { font-size: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
|
43
|
+
.stat-card .value { font-size: 28px; font-weight: 700; margin-top: 4px; letter-spacing: -0.02em; }
|
|
44
|
+
.stat-card .value.primary { color: var(--primary); }
|
|
45
|
+
.stat-card .value.success { color: var(--success); }
|
|
46
|
+
|
|
47
|
+
/* Table */
|
|
48
|
+
.table-wrap { overflow-x: auto; }
|
|
49
|
+
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
50
|
+
th { text-align: left; padding: 10px 12px; color: var(--text-muted); font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; border-bottom: 1px solid var(--border); }
|
|
51
|
+
td { padding: 12px; border-bottom: 1px solid var(--border); }
|
|
52
|
+
tr:hover td { background: rgba(255,255,255,0.02); }
|
|
53
|
+
|
|
54
|
+
/* Badge */
|
|
55
|
+
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; }
|
|
56
|
+
.badge-active { background: rgba(34,197,94,0.15); color: var(--success); }
|
|
57
|
+
.badge-archived { background: rgba(136,136,160,0.15); color: var(--text-dim); }
|
|
58
|
+
.badge-suspended { background: rgba(239,68,68,0.15); color: var(--danger); }
|
|
59
|
+
.badge-owner { background: rgba(245,158,11,0.15); color: var(--warning); }
|
|
60
|
+
.badge-admin { background: rgba(99,102,241,0.15); color: var(--primary); }
|
|
61
|
+
.badge-member { background: rgba(136,136,160,0.1); color: var(--text-dim); }
|
|
62
|
+
|
|
63
|
+
/* Buttons */
|
|
64
|
+
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: var(--radius); font-size: 13px; font-weight: 500; cursor: pointer; border: 1px solid var(--border); background: var(--surface); color: var(--text); transition: all 0.15s; }
|
|
65
|
+
.btn:hover { border-color: var(--border-hover); background: rgba(255,255,255,0.05); }
|
|
66
|
+
.btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; }
|
|
67
|
+
.btn-primary:hover { background: var(--primary-hover); }
|
|
68
|
+
.btn-danger { color: var(--danger); }
|
|
69
|
+
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
|
70
|
+
|
|
71
|
+
/* Input */
|
|
72
|
+
.input { padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 13px; width: 100%; outline: none; transition: border-color 0.15s; }
|
|
73
|
+
.input:focus { border-color: var(--primary); }
|
|
74
|
+
.form-group { margin-bottom: 14px; }
|
|
75
|
+
.form-label { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 4px; }
|
|
76
|
+
select.input { appearance: none; }
|
|
77
|
+
|
|
78
|
+
/* Modal */
|
|
79
|
+
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
|
80
|
+
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 24px; width: 440px; max-width: 90vw; }
|
|
81
|
+
.modal-title { font-size: 16px; font-weight: 700; margin-bottom: 16px; }
|
|
82
|
+
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
|
|
83
|
+
|
|
84
|
+
/* Audit log */
|
|
85
|
+
.audit-item { padding: 12px; border-bottom: 1px solid var(--border); font-size: 13px; }
|
|
86
|
+
.audit-item:last-child { border-bottom: none; }
|
|
87
|
+
.audit-time { font-size: 11px; color: var(--text-muted); }
|
|
88
|
+
.audit-action { color: var(--primary); font-weight: 500; }
|
|
89
|
+
|
|
90
|
+
/* Toast */
|
|
91
|
+
.toast { position: fixed; bottom: 24px; right: 24px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 20px; font-size: 13px; z-index: 200; animation: slideUp 0.2s ease; }
|
|
92
|
+
.toast.success { border-color: var(--success); }
|
|
93
|
+
.toast.error { border-color: var(--danger); }
|
|
94
|
+
@keyframes slideUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
|
95
|
+
|
|
96
|
+
/* Empty state */
|
|
97
|
+
.empty { text-align: center; padding: 48px 20px; color: var(--text-muted); }
|
|
98
|
+
.empty-icon { font-size: 32px; margin-bottom: 8px; }
|
|
99
|
+
|
|
100
|
+
/* Responsive */
|
|
101
|
+
@media (max-width: 768px) {
|
|
102
|
+
.sidebar { width: 60px; }
|
|
103
|
+
.sidebar-logo h1, .sidebar-logo p, .nav-item span, .nav-section { display: none; }
|
|
104
|
+
.nav-item { justify-content: center; padding: 12px; }
|
|
105
|
+
.main { margin-left: 60px; padding: 16px; }
|
|
106
|
+
}
|
|
107
|
+
</style>
|
|
108
|
+
</head>
|
|
109
|
+
<body>
|
|
110
|
+
<div id="root"></div>
|
|
111
|
+
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
|
112
|
+
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
|
113
|
+
<script>
|
|
114
|
+
const { useState, useEffect, useCallback, createElement: h, Fragment } = React;
|
|
115
|
+
|
|
116
|
+
// ─── API Client ─────────────────────────────────────
|
|
117
|
+
const API_BASE = '/api';
|
|
118
|
+
let authToken = localStorage.getItem('am_token');
|
|
119
|
+
|
|
120
|
+
async function api(path, opts = {}) {
|
|
121
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
122
|
+
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
|
|
123
|
+
const resp = await fetch(`${API_BASE}${path}`, { ...opts, headers: { ...headers, ...opts.headers } });
|
|
124
|
+
const data = await resp.json();
|
|
125
|
+
if (!resp.ok) throw new Error(data.error || 'Request failed');
|
|
126
|
+
return data;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ─── Toast ──────────────────────────────────────────
|
|
130
|
+
function Toast({ message, type, onDone }) {
|
|
131
|
+
useEffect(() => { const t = setTimeout(onDone, 3000); return () => clearTimeout(t); }, []);
|
|
132
|
+
return h('div', { className: `toast ${type}` }, message);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ─── Login ──────────────────────────────────────────
|
|
136
|
+
function LoginPage({ onLogin }) {
|
|
137
|
+
const [email, setEmail] = useState('');
|
|
138
|
+
const [password, setPassword] = useState('');
|
|
139
|
+
const [error, setError] = useState('');
|
|
140
|
+
const [loading, setLoading] = useState(false);
|
|
141
|
+
|
|
142
|
+
async function handleSubmit(e) {
|
|
143
|
+
e.preventDefault();
|
|
144
|
+
setLoading(true); setError('');
|
|
145
|
+
try {
|
|
146
|
+
const data = await fetch('/auth/login', {
|
|
147
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
148
|
+
body: JSON.stringify({ email, password }),
|
|
149
|
+
}).then(r => r.json());
|
|
150
|
+
if (data.error) throw new Error(data.error);
|
|
151
|
+
authToken = data.token;
|
|
152
|
+
localStorage.setItem('am_token', data.token);
|
|
153
|
+
onLogin(data.user);
|
|
154
|
+
} catch (err) { setError(err.message); }
|
|
155
|
+
setLoading(false);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', padding: 20 } },
|
|
159
|
+
h('form', { onSubmit: handleSubmit, style: { width: 360 } },
|
|
160
|
+
h('div', { style: { textAlign: 'center', marginBottom: 32 } },
|
|
161
|
+
h('h1', { style: { fontSize: 20, fontWeight: 700 } }, '🏢 ', h('span', { style: { color: 'var(--primary)' } }, 'AgenticMail'), ' Enterprise'),
|
|
162
|
+
h('p', { style: { fontSize: 13, color: 'var(--text-dim)', marginTop: 4 } }, 'Sign in to your dashboard'),
|
|
163
|
+
),
|
|
164
|
+
error && h('div', { style: { background: 'rgba(239,68,68,0.1)', border: '1px solid var(--danger)', borderRadius: 'var(--radius)', padding: '8px 12px', marginBottom: 16, fontSize: 13, color: 'var(--danger)' } }, error),
|
|
165
|
+
h('div', { className: 'form-group' }, h('label', { className: 'form-label' }, 'Email'), h('input', { className: 'input', type: 'email', value: email, onChange: e => setEmail(e.target.value), required: true, autoFocus: true })),
|
|
166
|
+
h('div', { className: 'form-group' }, h('label', { className: 'form-label' }, 'Password'), h('input', { className: 'input', type: 'password', value: password, onChange: e => setPassword(e.target.value), required: true })),
|
|
167
|
+
h('button', { className: 'btn btn-primary', type: 'submit', disabled: loading, style: { width: '100%', justifyContent: 'center', marginTop: 8 } }, loading ? 'Signing in...' : 'Sign In'),
|
|
168
|
+
)
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ─── Modal ──────────────────────────────────────────
|
|
173
|
+
function Modal({ title, children, onClose, actions }) {
|
|
174
|
+
return h('div', { className: 'modal-overlay', onClick: e => e.target === e.currentTarget && onClose() },
|
|
175
|
+
h('div', { className: 'modal' },
|
|
176
|
+
h('div', { className: 'modal-title' }, title),
|
|
177
|
+
children,
|
|
178
|
+
actions && h('div', { className: 'modal-actions' }, actions),
|
|
179
|
+
)
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ─── Dashboard Page ─────────────────────────────────
|
|
184
|
+
function DashboardPage() {
|
|
185
|
+
const [stats, setStats] = useState(null);
|
|
186
|
+
const [audit, setAudit] = useState([]);
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
api('/stats').then(setStats).catch(() => {});
|
|
189
|
+
api('/audit?limit=10').then(d => setAudit(d.events || [])).catch(() => {});
|
|
190
|
+
}, []);
|
|
191
|
+
if (!stats) return h('div', { className: 'page-desc' }, 'Loading...');
|
|
192
|
+
return h(Fragment, null,
|
|
193
|
+
h('h2', { className: 'page-title' }, 'Dashboard'),
|
|
194
|
+
h('p', { className: 'page-desc' }, 'Overview of your AgenticMail instance'),
|
|
195
|
+
h('div', { className: 'stats-grid' },
|
|
196
|
+
h('div', { className: 'stat-card' }, h('div', { className: 'label' }, 'Total Agents'), h('div', { className: 'value primary' }, stats.totalAgents)),
|
|
197
|
+
h('div', { className: 'stat-card' }, h('div', { className: 'label' }, 'Active Agents'), h('div', { className: 'value success' }, stats.activeAgents)),
|
|
198
|
+
h('div', { className: 'stat-card' }, h('div', { className: 'label' }, 'Users'), h('div', { className: 'value' }, stats.totalUsers)),
|
|
199
|
+
h('div', { className: 'stat-card' }, h('div', { className: 'label' }, 'Audit Events'), h('div', { className: 'value' }, stats.totalAuditEvents)),
|
|
200
|
+
),
|
|
201
|
+
h('div', { className: 'card' },
|
|
202
|
+
h('div', { className: 'card-title' }, 'Recent Activity'),
|
|
203
|
+
audit.length === 0
|
|
204
|
+
? h('div', { className: 'empty' }, h('div', { className: 'empty-icon' }, '📋'), 'No activity yet')
|
|
205
|
+
: audit.map(e => h('div', { className: 'audit-item', key: e.id },
|
|
206
|
+
h('span', { className: 'audit-action' }, e.action), ' on ', h('span', null, e.resource),
|
|
207
|
+
h('div', { className: 'audit-time' }, new Date(e.timestamp).toLocaleString(), e.ip ? ` · ${e.ip}` : ''),
|
|
208
|
+
))
|
|
209
|
+
),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ─── Agents Page ────────────────────────────────────
|
|
214
|
+
function AgentsPage({ showToast }) {
|
|
215
|
+
const [agents, setAgents] = useState([]);
|
|
216
|
+
const [showCreate, setShowCreate] = useState(false);
|
|
217
|
+
const [form, setForm] = useState({ name: '', email: '', role: 'assistant' });
|
|
218
|
+
const [loading, setLoading] = useState(false);
|
|
219
|
+
|
|
220
|
+
const load = useCallback(() => { api('/agents').then(d => setAgents(d.agents || [])).catch(() => {}); }, []);
|
|
221
|
+
useEffect(load, []);
|
|
222
|
+
|
|
223
|
+
async function handleCreate(e) {
|
|
224
|
+
e.preventDefault(); setLoading(true);
|
|
225
|
+
try {
|
|
226
|
+
await api('/agents', { method: 'POST', body: JSON.stringify(form) });
|
|
227
|
+
showToast('Agent created', 'success');
|
|
228
|
+
setShowCreate(false); setForm({ name: '', email: '', role: 'assistant' }); load();
|
|
229
|
+
} catch (err) { showToast(err.message, 'error'); }
|
|
230
|
+
setLoading(false);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function archiveAgent(id) {
|
|
234
|
+
try { await api(`/agents/${id}/archive`, { method: 'POST' }); showToast('Agent archived', 'success'); load(); }
|
|
235
|
+
catch (err) { showToast(err.message, 'error'); }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return h(Fragment, null,
|
|
239
|
+
h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 } },
|
|
240
|
+
h('div', null, h('h2', { className: 'page-title' }, 'Agents'), h('p', { className: 'page-desc', style: { marginBottom: 0 } }, 'Manage AI agent identities')),
|
|
241
|
+
h('button', { className: 'btn btn-primary', onClick: () => setShowCreate(true) }, '+ New Agent'),
|
|
242
|
+
),
|
|
243
|
+
h('div', { className: 'card' },
|
|
244
|
+
h('div', { className: 'table-wrap' },
|
|
245
|
+
agents.length === 0
|
|
246
|
+
? h('div', { className: 'empty' }, h('div', { className: 'empty-icon' }, '🤖'), 'No agents yet')
|
|
247
|
+
: h('table', null,
|
|
248
|
+
h('thead', null, h('tr', null, h('th', null, 'Name'), h('th', null, 'Email'), h('th', null, 'Role'), h('th', null, 'Status'), h('th', null, 'Created'), h('th', null, ''))),
|
|
249
|
+
h('tbody', null, agents.map(a => h('tr', { key: a.id },
|
|
250
|
+
h('td', { style: { fontWeight: 600 } }, a.name),
|
|
251
|
+
h('td', { style: { color: 'var(--text-dim)' } }, a.email),
|
|
252
|
+
h('td', null, a.role),
|
|
253
|
+
h('td', null, h('span', { className: `badge badge-${a.status}` }, a.status)),
|
|
254
|
+
h('td', { style: { color: 'var(--text-muted)', fontSize: 12 } }, new Date(a.createdAt).toLocaleDateString()),
|
|
255
|
+
h('td', null, a.status === 'active' && h('button', { className: 'btn btn-sm btn-danger', onClick: () => archiveAgent(a.id) }, 'Archive')),
|
|
256
|
+
)))
|
|
257
|
+
)
|
|
258
|
+
)
|
|
259
|
+
),
|
|
260
|
+
showCreate && h(Modal, { title: 'Create Agent', onClose: () => setShowCreate(false),
|
|
261
|
+
actions: [
|
|
262
|
+
h('button', { className: 'btn', onClick: () => setShowCreate(false) }, 'Cancel'),
|
|
263
|
+
h('button', { className: 'btn btn-primary', onClick: handleCreate, disabled: loading }, loading ? 'Creating...' : 'Create'),
|
|
264
|
+
] },
|
|
265
|
+
h('form', { onSubmit: handleCreate },
|
|
266
|
+
h('div', { className: 'form-group' }, h('label', { className: 'form-label' }, 'Name'), h('input', { className: 'input', value: form.name, onChange: e => setForm({ ...form, name: e.target.value }), required: true, autoFocus: true, placeholder: 'e.g. researcher' })),
|
|
267
|
+
h('div', { className: 'form-group' }, h('label', { className: 'form-label' }, 'Email (optional)'), h('input', { className: 'input', value: form.email, onChange: e => setForm({ ...form, email: e.target.value }), placeholder: 'auto-generated if blank' })),
|
|
268
|
+
h('div', { className: 'form-group' }, h('label', { className: 'form-label' }, 'Role'), h('select', { className: 'input', value: form.role, onChange: e => setForm({ ...form, role: e.target.value }) },
|
|
269
|
+
['assistant', 'secretary', 'researcher', 'writer', 'custom'].map(r => h('option', { key: r, value: r }, r))
|
|
270
|
+
)),
|
|
271
|
+
)
|
|
272
|
+
),
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ─── Users Page ─────────────────────────────────────
|
|
277
|
+
function UsersPage({ showToast }) {
|
|
278
|
+
const [users, setUsers] = useState([]);
|
|
279
|
+
const [showCreate, setShowCreate] = useState(false);
|
|
280
|
+
const [form, setForm] = useState({ email: '', name: '', role: 'member', password: '' });
|
|
281
|
+
const [loading, setLoading] = useState(false);
|
|
282
|
+
|
|
283
|
+
const load = useCallback(() => { api('/users').then(d => setUsers(d.users || [])).catch(() => {}); }, []);
|
|
284
|
+
useEffect(load, []);
|
|
285
|
+
|
|
286
|
+
async function handleCreate(e) {
|
|
287
|
+
e.preventDefault(); setLoading(true);
|
|
288
|
+
try {
|
|
289
|
+
await api('/users', { method: 'POST', body: JSON.stringify(form) });
|
|
290
|
+
showToast('User created', 'success');
|
|
291
|
+
setShowCreate(false); setForm({ email: '', name: '', role: 'member', password: '' }); load();
|
|
292
|
+
} catch (err) { showToast(err.message, 'error'); }
|
|
293
|
+
setLoading(false);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return h(Fragment, null,
|
|
297
|
+
h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 } },
|
|
298
|
+
h('div', null, h('h2', { className: 'page-title' }, 'Users'), h('p', { className: 'page-desc', style: { marginBottom: 0 } }, 'Manage team members')),
|
|
299
|
+
h('button', { className: 'btn btn-primary', onClick: () => setShowCreate(true) }, '+ New User'),
|
|
300
|
+
),
|
|
301
|
+
h('div', { className: 'card' },
|
|
302
|
+
h('div', { className: 'table-wrap' },
|
|
303
|
+
users.length === 0
|
|
304
|
+
? h('div', { className: 'empty' }, h('div', { className: 'empty-icon' }, '👥'), 'No users yet')
|
|
305
|
+
: h('table', null,
|
|
306
|
+
h('thead', null, h('tr', null, h('th', null, 'Name'), h('th', null, 'Email'), h('th', null, 'Role'), h('th', null, 'Last Login'))),
|
|
307
|
+
h('tbody', null, users.map(u => h('tr', { key: u.id },
|
|
308
|
+
h('td', { style: { fontWeight: 600 } }, u.name),
|
|
309
|
+
h('td', { style: { color: 'var(--text-dim)' } }, u.email),
|
|
310
|
+
h('td', null, h('span', { className: `badge badge-${u.role}` }, u.role)),
|
|
311
|
+
h('td', { style: { color: 'var(--text-muted)', fontSize: 12 } }, u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString() : 'Never'),
|
|
312
|
+
)))
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
),
|
|
316
|
+
showCreate && h(Modal, { title: 'Create User', onClose: () => setShowCreate(false),
|
|
317
|
+
actions: [
|
|
318
|
+
h('button', { className: 'btn', onClick: () => setShowCreate(false) }, 'Cancel'),
|
|
319
|
+
h('button', { className: 'btn btn-primary', onClick: handleCreate, disabled: loading }, loading ? 'Creating...' : 'Create'),
|
|
320
|
+
] },
|
|
321
|
+
h('form', { onSubmit: handleCreate },
|
|
322
|
+
h('div', { className: 'form-group' }, h('label', { className: 'form-label' }, 'Name'), h('input', { className: 'input', value: form.name, onChange: e => setForm({ ...form, name: e.target.value }), required: true, autoFocus: true })),
|
|
323
|
+
h('div', { className: 'form-group' }, h('label', { className: 'form-label' }, 'Email'), h('input', { className: 'input', type: 'email', value: form.email, onChange: e => setForm({ ...form, email: e.target.value }), required: true })),
|
|
324
|
+
h('div', { className: 'form-group' }, h('label', { className: 'form-label' }, 'Role'), h('select', { className: 'input', value: form.role, onChange: e => setForm({ ...form, role: e.target.value }) },
|
|
325
|
+
['owner', 'admin', 'member', 'viewer'].map(r => h('option', { key: r, value: r }, r))
|
|
326
|
+
)),
|
|
327
|
+
h('div', { className: 'form-group' }, h('label', { className: 'form-label' }, 'Password'), h('input', { className: 'input', type: 'password', value: form.password, onChange: e => setForm({ ...form, password: e.target.value }), required: true, minLength: 8 })),
|
|
328
|
+
)
|
|
329
|
+
),
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ─── API Keys Page ──────────────────────────────────
|
|
334
|
+
function ApiKeysPage({ showToast }) {
|
|
335
|
+
const [keys, setKeys] = useState([]);
|
|
336
|
+
const [showCreate, setShowCreate] = useState(false);
|
|
337
|
+
const [newKeyPlaintext, setNewKeyPlaintext] = useState('');
|
|
338
|
+
const [form, setForm] = useState({ name: '' });
|
|
339
|
+
const [loading, setLoading] = useState(false);
|
|
340
|
+
|
|
341
|
+
const load = useCallback(() => { api('/api-keys').then(d => setKeys(d.keys || [])).catch(() => {}); }, []);
|
|
342
|
+
useEffect(load, []);
|
|
343
|
+
|
|
344
|
+
async function handleCreate(e) {
|
|
345
|
+
e.preventDefault(); setLoading(true);
|
|
346
|
+
try {
|
|
347
|
+
const data = await api('/api-keys', { method: 'POST', body: JSON.stringify({ name: form.name }) });
|
|
348
|
+
setNewKeyPlaintext(data.plaintext);
|
|
349
|
+
showToast('API key created', 'success');
|
|
350
|
+
setForm({ name: '' }); load();
|
|
351
|
+
} catch (err) { showToast(err.message, 'error'); }
|
|
352
|
+
setLoading(false);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function revokeKey(id) {
|
|
356
|
+
try { await api(`/api-keys/${id}`, { method: 'DELETE' }); showToast('Key revoked', 'success'); load(); }
|
|
357
|
+
catch (err) { showToast(err.message, 'error'); }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return h(Fragment, null,
|
|
361
|
+
h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 } },
|
|
362
|
+
h('div', null, h('h2', { className: 'page-title' }, 'API Keys'), h('p', { className: 'page-desc', style: { marginBottom: 0 } }, 'Manage programmatic access')),
|
|
363
|
+
h('button', { className: 'btn btn-primary', onClick: () => setShowCreate(true) }, '+ New Key'),
|
|
364
|
+
),
|
|
365
|
+
newKeyPlaintext && h('div', { className: 'card', style: { borderColor: 'var(--warning)', background: 'rgba(245,158,11,0.05)' } },
|
|
366
|
+
h('div', { style: { fontSize: 13, fontWeight: 600, color: 'var(--warning)', marginBottom: 8 } }, '⚠️ Copy this key now — it won\'t be shown again'),
|
|
367
|
+
h('code', { style: { display: 'block', background: 'var(--bg)', padding: '10px 14px', borderRadius: 'var(--radius)', fontSize: 13, wordBreak: 'break-all', cursor: 'pointer' }, onClick: () => { navigator.clipboard.writeText(newKeyPlaintext); showToast('Copied!', 'success'); } }, newKeyPlaintext),
|
|
368
|
+
h('button', { className: 'btn btn-sm', style: { marginTop: 8 }, onClick: () => setNewKeyPlaintext('') }, 'Dismiss'),
|
|
369
|
+
),
|
|
370
|
+
h('div', { className: 'card' },
|
|
371
|
+
h('div', { className: 'table-wrap' },
|
|
372
|
+
keys.length === 0
|
|
373
|
+
? h('div', { className: 'empty' }, h('div', { className: 'empty-icon' }, '🔑'), 'No API keys')
|
|
374
|
+
: h('table', null,
|
|
375
|
+
h('thead', null, h('tr', null, h('th', null, 'Name'), h('th', null, 'Key Prefix'), h('th', null, 'Scopes'), h('th', null, 'Last Used'), h('th', null, 'Status'), h('th', null, ''))),
|
|
376
|
+
h('tbody', null, keys.map(k => h('tr', { key: k.id },
|
|
377
|
+
h('td', { style: { fontWeight: 600 } }, k.name),
|
|
378
|
+
h('td', null, h('code', { style: { fontSize: 12 } }, k.keyPrefix + '...')),
|
|
379
|
+
h('td', { style: { fontSize: 12 } }, (k.scopes || []).join(', ') || '*'),
|
|
380
|
+
h('td', { style: { color: 'var(--text-muted)', fontSize: 12 } }, k.lastUsedAt ? new Date(k.lastUsedAt).toLocaleString() : 'Never'),
|
|
381
|
+
h('td', null, h('span', { className: `badge ${k.revoked ? 'badge-archived' : 'badge-active'}` }, k.revoked ? 'revoked' : 'active')),
|
|
382
|
+
h('td', null, !k.revoked && h('button', { className: 'btn btn-sm btn-danger', onClick: () => revokeKey(k.id) }, 'Revoke')),
|
|
383
|
+
)))
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
),
|
|
387
|
+
showCreate && h(Modal, { title: 'Create API Key', onClose: () => setShowCreate(false),
|
|
388
|
+
actions: [
|
|
389
|
+
h('button', { className: 'btn', onClick: () => setShowCreate(false) }, 'Cancel'),
|
|
390
|
+
h('button', { className: 'btn btn-primary', onClick: handleCreate, disabled: loading }, loading ? 'Creating...' : 'Create'),
|
|
391
|
+
] },
|
|
392
|
+
h('form', { onSubmit: handleCreate },
|
|
393
|
+
h('div', { className: 'form-group' }, h('label', { className: 'form-label' }, 'Key Name'), h('input', { className: 'input', value: form.name, onChange: e => setForm({ ...form, name: e.target.value }), required: true, autoFocus: true, placeholder: 'e.g. CI/CD pipeline' })),
|
|
394
|
+
)
|
|
395
|
+
),
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ─── Audit Page ─────────────────────────────────────
|
|
400
|
+
function AuditPage() {
|
|
401
|
+
const [events, setEvents] = useState([]);
|
|
402
|
+
const [total, setTotal] = useState(0);
|
|
403
|
+
const [page, setPage] = useState(0);
|
|
404
|
+
const pageSize = 25;
|
|
405
|
+
|
|
406
|
+
useEffect(() => {
|
|
407
|
+
api(`/audit?limit=${pageSize}&offset=${page * pageSize}`).then(d => { setEvents(d.events || []); setTotal(d.total || 0); }).catch(() => {});
|
|
408
|
+
}, [page]);
|
|
409
|
+
|
|
410
|
+
return h(Fragment, null,
|
|
411
|
+
h('h2', { className: 'page-title' }, 'Audit Log'),
|
|
412
|
+
h('p', { className: 'page-desc' }, `${total} total events`),
|
|
413
|
+
h('div', { className: 'card' },
|
|
414
|
+
events.length === 0
|
|
415
|
+
? h('div', { className: 'empty' }, h('div', { className: 'empty-icon' }, '📋'), 'No audit events')
|
|
416
|
+
: h(Fragment, null,
|
|
417
|
+
h('div', { className: 'table-wrap' },
|
|
418
|
+
h('table', null,
|
|
419
|
+
h('thead', null, h('tr', null, h('th', null, 'Time'), h('th', null, 'Actor'), h('th', null, 'Action'), h('th', null, 'Resource'), h('th', null, 'IP'))),
|
|
420
|
+
h('tbody', null, events.map(e => h('tr', { key: e.id },
|
|
421
|
+
h('td', { style: { fontSize: 12, color: 'var(--text-muted)', whiteSpace: 'nowrap' } }, new Date(e.timestamp).toLocaleString()),
|
|
422
|
+
h('td', null, e.actor),
|
|
423
|
+
h('td', null, h('span', { className: 'audit-action' }, e.action)),
|
|
424
|
+
h('td', { style: { fontSize: 12 } }, e.resource),
|
|
425
|
+
h('td', { style: { fontSize: 12, color: 'var(--text-muted)' } }, e.ip || '-'),
|
|
426
|
+
)))
|
|
427
|
+
)
|
|
428
|
+
),
|
|
429
|
+
h('div', { style: { display: 'flex', gap: 8, justifyContent: 'center', marginTop: 16 } },
|
|
430
|
+
h('button', { className: 'btn btn-sm', disabled: page === 0, onClick: () => setPage(p => p - 1) }, '← Prev'),
|
|
431
|
+
h('span', { style: { padding: '4px 12px', fontSize: 12, color: 'var(--text-muted)' } }, `Page ${page + 1} of ${Math.ceil(total / pageSize) || 1}`),
|
|
432
|
+
h('button', { className: 'btn btn-sm', disabled: (page + 1) * pageSize >= total, onClick: () => setPage(p => p + 1) }, 'Next →'),
|
|
433
|
+
)
|
|
434
|
+
)
|
|
435
|
+
),
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ─── Settings Page ──────────────────────────────────
|
|
440
|
+
function SettingsPage({ showToast }) {
|
|
441
|
+
const [settings, setSettings] = useState(null);
|
|
442
|
+
const [form, setForm] = useState({});
|
|
443
|
+
const [retention, setRetention] = useState(null);
|
|
444
|
+
const [loading, setLoading] = useState(false);
|
|
445
|
+
|
|
446
|
+
useEffect(() => {
|
|
447
|
+
api('/settings').then(d => { setSettings(d); setForm({ name: d.name || '', domain: d.domain || '', primaryColor: d.primaryColor || '#6366f1', logoUrl: d.logoUrl || '' }); }).catch(() => {});
|
|
448
|
+
api('/retention').then(setRetention).catch(() => {});
|
|
449
|
+
}, []);
|
|
450
|
+
|
|
451
|
+
async function saveSettings(e) {
|
|
452
|
+
e.preventDefault(); setLoading(true);
|
|
453
|
+
try { await api('/settings', { method: 'PATCH', body: JSON.stringify(form) }); showToast('Settings saved', 'success'); }
|
|
454
|
+
catch (err) { showToast(err.message, 'error'); }
|
|
455
|
+
setLoading(false);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (!settings) return h('div', { className: 'page-desc' }, 'Loading...');
|
|
459
|
+
|
|
460
|
+
return h(Fragment, null,
|
|
461
|
+
h('h2', { className: 'page-title' }, 'Settings'),
|
|
462
|
+
h('p', { className: 'page-desc' }, 'Configure your organization'),
|
|
463
|
+
h('div', { className: 'card' },
|
|
464
|
+
h('div', { className: 'card-title' }, 'General'),
|
|
465
|
+
h('form', { onSubmit: saveSettings },
|
|
466
|
+
h('div', { style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 } },
|
|
467
|
+
h('div', { className: 'form-group' }, h('label', { className: 'form-label' }, 'Organization Name'), h('input', { className: 'input', value: form.name, onChange: e => setForm({ ...form, name: e.target.value }) })),
|
|
468
|
+
h('div', { className: 'form-group' }, h('label', { className: 'form-label' }, 'Domain'), h('input', { className: 'input', value: form.domain, onChange: e => setForm({ ...form, domain: e.target.value }), placeholder: 'agents.acme.com' })),
|
|
469
|
+
h('div', { className: 'form-group' }, h('label', { className: 'form-label' }, 'Primary Color'), h('input', { className: 'input', type: 'color', value: form.primaryColor, onChange: e => setForm({ ...form, primaryColor: e.target.value }), style: { height: 36, padding: 4 } })),
|
|
470
|
+
h('div', { className: 'form-group' }, h('label', { className: 'form-label' }, 'Logo URL'), h('input', { className: 'input', value: form.logoUrl, onChange: e => setForm({ ...form, logoUrl: e.target.value }), placeholder: 'https://...' })),
|
|
471
|
+
),
|
|
472
|
+
h('button', { className: 'btn btn-primary', type: 'submit', disabled: loading, style: { marginTop: 8 } }, loading ? 'Saving...' : 'Save'),
|
|
473
|
+
),
|
|
474
|
+
),
|
|
475
|
+
h('div', { className: 'card' },
|
|
476
|
+
h('div', { className: 'card-title' }, 'Plan'),
|
|
477
|
+
h('div', { style: { display: 'flex', alignItems: 'center', gap: 12 } },
|
|
478
|
+
h('span', { className: 'badge badge-active', style: { fontSize: 14, padding: '4px 12px' } }, (settings.plan || 'free').toUpperCase()),
|
|
479
|
+
h('span', { style: { fontSize: 13, color: 'var(--text-dim)' } }, `Subdomain: ${settings.subdomain || 'not set'}.agenticmail.cloud`),
|
|
480
|
+
),
|
|
481
|
+
),
|
|
482
|
+
retention && h('div', { className: 'card' },
|
|
483
|
+
h('div', { className: 'card-title' }, 'Data Retention'),
|
|
484
|
+
h('div', { style: { fontSize: 13 } },
|
|
485
|
+
h('div', null, 'Status: ', h('span', { style: { color: retention.enabled ? 'var(--success)' : 'var(--text-muted)' } }, retention.enabled ? 'Enabled' : 'Disabled')),
|
|
486
|
+
h('div', { style: { color: 'var(--text-dim)', marginTop: 4 } }, `Retain emails for ${retention.retainDays} days`, retention.archiveFirst ? ' (archive before delete)' : ''),
|
|
487
|
+
),
|
|
488
|
+
),
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ─── App ────────────────────────────────────────────
|
|
493
|
+
const PAGES = {
|
|
494
|
+
dashboard: { icon: '📊', label: 'Dashboard', component: DashboardPage },
|
|
495
|
+
agents: { icon: '🤖', label: 'Agents', component: AgentsPage },
|
|
496
|
+
users: { icon: '👥', label: 'Users', component: UsersPage },
|
|
497
|
+
'api-keys': { icon: '🔑', label: 'API Keys', component: ApiKeysPage },
|
|
498
|
+
audit: { icon: '📋', label: 'Audit Log', component: AuditPage },
|
|
499
|
+
settings: { icon: '⚙️', label: 'Settings', component: SettingsPage },
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
function App() {
|
|
503
|
+
const [user, setUser] = useState(null);
|
|
504
|
+
const [page, setPage] = useState('dashboard');
|
|
505
|
+
const [toast, setToast] = useState(null);
|
|
506
|
+
const [checkingAuth, setCheckingAuth] = useState(true);
|
|
507
|
+
|
|
508
|
+
const showToast = useCallback((message, type = 'success') => setToast({ message, type, key: Date.now() }), []);
|
|
509
|
+
|
|
510
|
+
useEffect(() => {
|
|
511
|
+
if (authToken) {
|
|
512
|
+
fetch('/auth/me', { headers: { Authorization: `Bearer ${authToken}` } })
|
|
513
|
+
.then(r => r.ok ? r.json() : Promise.reject())
|
|
514
|
+
.then(u => { setUser(u); setCheckingAuth(false); })
|
|
515
|
+
.catch(() => { localStorage.removeItem('am_token'); authToken = null; setCheckingAuth(false); });
|
|
516
|
+
} else { setCheckingAuth(false); }
|
|
517
|
+
}, []);
|
|
518
|
+
|
|
519
|
+
if (checkingAuth) return h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', color: 'var(--text-dim)' } }, 'Loading...');
|
|
520
|
+
if (!user) return h(LoginPage, { onLogin: setUser });
|
|
521
|
+
|
|
522
|
+
const PageComponent = PAGES[page]?.component || DashboardPage;
|
|
523
|
+
|
|
524
|
+
return h(Fragment, null,
|
|
525
|
+
h('div', { className: 'sidebar' },
|
|
526
|
+
h('div', { className: 'sidebar-logo' },
|
|
527
|
+
h('h1', null, '🏢 ', h('span', null, 'Agentic'), 'Mail'),
|
|
528
|
+
h('p', null, 'Enterprise'),
|
|
529
|
+
),
|
|
530
|
+
h('div', { className: 'nav-section' }, 'Overview'),
|
|
531
|
+
Object.entries(PAGES).slice(0, 1).map(([key, { icon, label }]) =>
|
|
532
|
+
h('button', { key, className: `nav-item ${page === key ? 'active' : ''}`, onClick: () => setPage(key) }, icon, ' ', h('span', null, label))
|
|
533
|
+
),
|
|
534
|
+
h('div', { className: 'nav-section' }, 'Manage'),
|
|
535
|
+
Object.entries(PAGES).slice(1, 4).map(([key, { icon, label }]) =>
|
|
536
|
+
h('button', { key, className: `nav-item ${page === key ? 'active' : ''}`, onClick: () => setPage(key) }, icon, ' ', h('span', null, label))
|
|
537
|
+
),
|
|
538
|
+
h('div', { className: 'nav-section' }, 'System'),
|
|
539
|
+
Object.entries(PAGES).slice(4).map(([key, { icon, label }]) =>
|
|
540
|
+
h('button', { key, className: `nav-item ${page === key ? 'active' : ''}`, onClick: () => setPage(key) }, icon, ' ', h('span', null, label))
|
|
541
|
+
),
|
|
542
|
+
h('div', { style: { marginTop: 'auto', padding: '16px 20px', borderTop: '1px solid var(--border)', fontSize: 12 } },
|
|
543
|
+
h('div', { style: { color: 'var(--text-dim)' } }, user.name),
|
|
544
|
+
h('div', { style: { color: 'var(--text-muted)', fontSize: 11 } }, user.email),
|
|
545
|
+
h('button', { style: { marginTop: 8, background: 'none', border: 'none', color: 'var(--text-muted)', cursor: 'pointer', fontSize: 11, padding: 0 },
|
|
546
|
+
onClick: () => { localStorage.removeItem('am_token'); authToken = null; setUser(null); }
|
|
547
|
+
}, 'Sign out'),
|
|
548
|
+
),
|
|
549
|
+
),
|
|
550
|
+
h('div', { className: 'main' }, h(PageComponent, { showToast })),
|
|
551
|
+
toast && h(Toast, { ...toast, onDone: () => setToast(null) }),
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
ReactDOM.createRoot(document.getElementById('root')).render(h(App));
|
|
556
|
+
</script>
|
|
557
|
+
</body>
|
|
558
|
+
</html>
|