@agenticmail/enterprise 0.5.302 → 0.5.303
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/dist/chunk-4VGAZULN.js +1519 -0
- package/dist/chunk-CRXYUYVJ.js +4395 -0
- package/dist/chunk-QCGSFWKU.js +48 -0
- package/dist/cli-agent-YSQVDBOZ.js +1778 -0
- package/dist/cli-recover-2LWWVD4Q.js +487 -0
- package/dist/cli-serve-JZEXPYXY.js +143 -0
- package/dist/cli-verify-TZMX3GWV.js +149 -0
- package/dist/cli.js +5 -5
- package/dist/dashboard/app.js +57 -1
- package/dist/dashboard/components/org-switcher.js +57 -35
- package/dist/dashboard/pages/organizations.js +39 -3
- package/dist/dashboard/pages/users.js +34 -4
- package/dist/factory-QYGGXVYW.js +9 -0
- package/dist/index.js +3 -3
- package/dist/postgres-ALNOGUUM.js +819 -0
- package/dist/server-3R5KZPLA.js +15 -0
- package/dist/setup-ZZAOBEY4.js +20 -0
- package/dist/sqlite-2QPVZQ27.js +566 -0
- package/package.json +1 -1
- package/src/admin/routes.ts +32 -1
- package/src/auth/routes.ts +36 -2
- package/src/dashboard/app.js +57 -1
- package/src/dashboard/components/org-switcher.js +57 -35
- package/src/dashboard/pages/organizations.js +39 -3
- package/src/dashboard/pages/users.js +34 -4
- package/src/db/adapter.ts +1 -0
- package/src/db/postgres.ts +3 -0
- package/src/db/sqlite.ts +7 -0
package/src/auth/routes.ts
CHANGED
|
@@ -303,7 +303,7 @@ export function createAuthRoutes(
|
|
|
303
303
|
token,
|
|
304
304
|
refreshToken,
|
|
305
305
|
csrf,
|
|
306
|
-
user: { id: user.id, email: user.email, name: user.name, role: user.role, totpEnabled: !!user.totpEnabled },
|
|
306
|
+
user: { id: user.id, email: user.email, name: user.name, role: user.role, totpEnabled: !!user.totpEnabled, clientOrgId: user.clientOrgId || null },
|
|
307
307
|
mustResetPassword: !!user.mustResetPassword,
|
|
308
308
|
});
|
|
309
309
|
});
|
|
@@ -362,7 +362,7 @@ export function createAuthRoutes(
|
|
|
362
362
|
token,
|
|
363
363
|
refreshToken,
|
|
364
364
|
csrf,
|
|
365
|
-
user: { id: user.id, email: user.email, name: user.name, role: user.role, totpEnabled: true },
|
|
365
|
+
user: { id: user.id, email: user.email, name: user.name, role: user.role, totpEnabled: true, clientOrgId: user.clientOrgId || null },
|
|
366
366
|
mustResetPassword: !!user.mustResetPassword,
|
|
367
367
|
...(backupUsed ? { warning: 'Backup code used. You have fewer backup codes remaining.' } : {}),
|
|
368
368
|
});
|
|
@@ -672,6 +672,40 @@ export function createAuthRoutes(
|
|
|
672
672
|
}
|
|
673
673
|
});
|
|
674
674
|
|
|
675
|
+
// ─── Impersonation (owner-only) ──────────────────────────
|
|
676
|
+
|
|
677
|
+
auth.post('/impersonate/:userId', async (c) => {
|
|
678
|
+
// Only owners can impersonate
|
|
679
|
+
const token = await extractToken(c);
|
|
680
|
+
if (!token) return c.json({ error: 'Authentication required' }, 401);
|
|
681
|
+
try {
|
|
682
|
+
const { jwtVerify, SignJWT } = await import('jose');
|
|
683
|
+
const secret = new TextEncoder().encode(jwtSecret);
|
|
684
|
+
const { payload } = await jwtVerify(token, secret);
|
|
685
|
+
const caller = await db.getUser(payload.sub as string);
|
|
686
|
+
if (!caller || caller.role !== 'owner') return c.json({ error: 'Only owners can impersonate users' }, 403);
|
|
687
|
+
|
|
688
|
+
const targetId = c.req.param('userId');
|
|
689
|
+
const target = await db.getUser(targetId);
|
|
690
|
+
if (!target) return c.json({ error: 'User not found' }, 404);
|
|
691
|
+
|
|
692
|
+
// Generate a short-lived token (1 hour) for the target user with impersonation flag
|
|
693
|
+
const impersonateToken = await new SignJWT({ sub: target.id, role: target.role, impersonatedBy: caller.id })
|
|
694
|
+
.setProtectedHeader({ alg: 'HS256' })
|
|
695
|
+
.setIssuedAt()
|
|
696
|
+
.setExpirationTime('1h')
|
|
697
|
+
.sign(secret);
|
|
698
|
+
|
|
699
|
+
return c.json({
|
|
700
|
+
token: impersonateToken,
|
|
701
|
+
user: { id: target.id, email: target.email, name: target.name, role: target.role, totpEnabled: !!target.totpEnabled, clientOrgId: target.clientOrgId || null, permissions: target.permissions },
|
|
702
|
+
impersonatedBy: { id: caller.id, name: caller.name, email: caller.email },
|
|
703
|
+
});
|
|
704
|
+
} catch (e: any) {
|
|
705
|
+
return c.json({ error: e.message || 'Impersonation failed' }, 500);
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
|
|
675
709
|
// ─── Logout ─────────────────────────────────────────────
|
|
676
710
|
|
|
677
711
|
auth.post('/logout', (c) => {
|
package/src/dashboard/app.js
CHANGED
|
@@ -97,6 +97,7 @@ function App() {
|
|
|
97
97
|
const [permissions, setPermissions] = useState('*'); // '*' = full access, or { pageId: true | ['tab1','tab2'] }
|
|
98
98
|
const [mustResetPassword, setMustResetPassword] = useState(false);
|
|
99
99
|
const [show2faReminder, setShow2faReminder] = useState(false);
|
|
100
|
+
const [impersonating, setImpersonating] = useState(null); // { user, impersonatedBy }
|
|
100
101
|
const [forceResetPw, setForceResetPw] = useState('');
|
|
101
102
|
const [forceResetPw2, setForceResetPw2] = useState('');
|
|
102
103
|
const [forceResetLoading, setForceResetLoading] = useState(false);
|
|
@@ -146,6 +147,12 @@ function App() {
|
|
|
146
147
|
apiCall('/settings').then(d => { const s = d.settings || d || {}; if (s.primaryColor) applyBrandColor(s.primaryColor); if (s.orgId) setOrgId(s.orgId); }).catch(() => {});
|
|
147
148
|
apiCall('/me/permissions').then(d => {
|
|
148
149
|
if (d && d.permissions) setPermissions(d.permissions);
|
|
150
|
+
// If user is assigned to a client org, auto-set org context
|
|
151
|
+
if (d && d.clientOrgId) {
|
|
152
|
+
localStorage.setItem('em_client_org_id', d.clientOrgId);
|
|
153
|
+
} else {
|
|
154
|
+
localStorage.removeItem('em_client_org_id');
|
|
155
|
+
}
|
|
149
156
|
}).catch(() => {});
|
|
150
157
|
}, [authed]);
|
|
151
158
|
|
|
@@ -281,7 +288,44 @@ function App() {
|
|
|
281
288
|
const PageComponent = canAccessPage ? (pages[page] || DashboardPage) : null;
|
|
282
289
|
const sidebarClass = 'sidebar' + (sidebarPinned ? ' expanded' : sidebarHovered ? ' hover-expanded' : '') + (mobileMenuOpen ? ' mobile-open' : '');
|
|
283
290
|
|
|
284
|
-
|
|
291
|
+
// Impersonation functions
|
|
292
|
+
const startImpersonation = useCallback(async (userId) => {
|
|
293
|
+
try {
|
|
294
|
+
const d = await authCall('/impersonate/' + userId, { method: 'POST' });
|
|
295
|
+
if (d.token && d.user) {
|
|
296
|
+
// Store real user info
|
|
297
|
+
setImpersonating({ user: d.user, impersonatedBy: d.impersonatedBy, originalToken: localStorage.getItem('em_token') });
|
|
298
|
+
// Set impersonated user's token
|
|
299
|
+
localStorage.setItem('em_token', d.token);
|
|
300
|
+
setUser(d.user);
|
|
301
|
+
if (d.user.permissions) setPermissions(d.user.permissions);
|
|
302
|
+
if (d.user.clientOrgId) {
|
|
303
|
+
localStorage.setItem('em_client_org_id', d.user.clientOrgId);
|
|
304
|
+
// Fetch org name for display
|
|
305
|
+
apiCall('/organizations/' + d.user.clientOrgId).then(function(o) {
|
|
306
|
+
if (o && o.name) setImpersonating(function(prev) { return prev ? Object.assign({}, prev, { user: Object.assign({}, prev.user, { clientOrgName: o.name }) }) : prev; });
|
|
307
|
+
}).catch(function() {});
|
|
308
|
+
} else localStorage.removeItem('em_client_org_id');
|
|
309
|
+
toast('Now viewing as ' + d.user.name, 'info');
|
|
310
|
+
setPage('dashboard');
|
|
311
|
+
}
|
|
312
|
+
} catch (e) { toast(e.message || 'Impersonation failed', 'error'); }
|
|
313
|
+
}, []);
|
|
314
|
+
|
|
315
|
+
const stopImpersonation = useCallback(() => {
|
|
316
|
+
if (impersonating && impersonating.originalToken) {
|
|
317
|
+
localStorage.setItem('em_token', impersonating.originalToken);
|
|
318
|
+
}
|
|
319
|
+
setImpersonating(null);
|
|
320
|
+
localStorage.removeItem('em_client_org_id');
|
|
321
|
+
// Reload real user
|
|
322
|
+
authCall('/me').then(d => { setUser(d.user || d); }).catch(() => {});
|
|
323
|
+
apiCall('/me/permissions').then(d => { if (d && d.permissions) setPermissions(d.permissions); }).catch(() => {});
|
|
324
|
+
toast('Stopped impersonation', 'success');
|
|
325
|
+
setPage('users');
|
|
326
|
+
}, [impersonating]);
|
|
327
|
+
|
|
328
|
+
return h(AppContext.Provider, { value: { toast, toasts, user, theme, setPage, permissions, impersonating, startImpersonation, stopImpersonation } },
|
|
285
329
|
h('div', { className: 'app-layout' },
|
|
286
330
|
// Mobile hamburger
|
|
287
331
|
h('button', { className: 'mobile-hamburger', onClick: () => setMobileMenuOpen(true) },
|
|
@@ -337,6 +381,18 @@ function App() {
|
|
|
337
381
|
)
|
|
338
382
|
),
|
|
339
383
|
h('div', { className: 'page-content' },
|
|
384
|
+
// Impersonation banner
|
|
385
|
+
impersonating && h('div', { style: { display: 'flex', alignItems: 'center', gap: 12, padding: '10px 16px', margin: '0 0 16px', background: 'rgba(99,102,241,0.12)', border: '2px solid var(--primary, #6366f1)', borderRadius: 8, fontSize: 13 } },
|
|
386
|
+
I.agents(),
|
|
387
|
+
h('div', { style: { flex: 1 } },
|
|
388
|
+
h('strong', null, 'Viewing as: '),
|
|
389
|
+
impersonating.user.name + ' (' + impersonating.user.email + ')',
|
|
390
|
+
impersonating.user.role && h('span', { className: 'badge badge-neutral', style: { marginLeft: 8, fontSize: 10 } }, impersonating.user.role),
|
|
391
|
+
impersonating.user.clientOrgName && h('span', { className: 'badge badge-info', style: { marginLeft: 8, fontSize: 10 } }, 'Org: ' + impersonating.user.clientOrgName),
|
|
392
|
+
impersonating.user.clientOrgId && !impersonating.user.clientOrgName && h('span', { className: 'badge badge-info', style: { marginLeft: 8, fontSize: 10 } }, 'Client Org')
|
|
393
|
+
),
|
|
394
|
+
h('button', { className: 'btn btn-primary btn-sm', onClick: stopImpersonation }, 'Stop Impersonating')
|
|
395
|
+
),
|
|
340
396
|
// 2FA recommendation banner
|
|
341
397
|
show2faReminder && h('div', { style: { display: 'flex', alignItems: 'center', gap: 12, padding: '10px 16px', margin: '0 0 16px', background: 'var(--warning-soft, rgba(245,158,11,0.1))', border: '1px solid var(--warning, #f59e0b)', borderRadius: 8, fontSize: 13 } },
|
|
342
398
|
I.shield(),
|
|
@@ -1,17 +1,11 @@
|
|
|
1
|
-
import { h, useState, useEffect, Fragment, apiCall } from './utils.js';
|
|
1
|
+
import { h, useState, useEffect, Fragment, apiCall, useApp } from './utils.js';
|
|
2
2
|
import { I } from './icons.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* OrgContextSwitcher — Global org context picker for multi-tenant pages.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* selectedOrgId — currently selected org ID ('' = my org)
|
|
10
|
-
* style — optional container style override
|
|
11
|
-
* showLabel — show "Viewing:" label (default true)
|
|
12
|
-
*
|
|
13
|
-
* The component loads client_organizations from the API and renders a
|
|
14
|
-
* compact dropdown that switches between "My Organization" and client orgs.
|
|
7
|
+
* If the current user has a clientOrgId, the switcher is LOCKED to that org
|
|
8
|
+
* (they can only see their org's data). Owners/admins can switch freely.
|
|
15
9
|
*/
|
|
16
10
|
export function OrgContextSwitcher(props) {
|
|
17
11
|
var onOrgChange = props.onOrgChange;
|
|
@@ -19,6 +13,11 @@ export function OrgContextSwitcher(props) {
|
|
|
19
13
|
var showLabel = props.showLabel !== false;
|
|
20
14
|
var style = props.style || {};
|
|
21
15
|
|
|
16
|
+
var app = useApp();
|
|
17
|
+
var user = app.user || {};
|
|
18
|
+
var userOrgId = user.clientOrgId || null;
|
|
19
|
+
var isLocked = !!userOrgId && user.role !== 'owner' && user.role !== 'admin';
|
|
20
|
+
|
|
22
21
|
var _orgs = useState([]);
|
|
23
22
|
var orgs = _orgs[0]; var setOrgs = _orgs[1];
|
|
24
23
|
var _loaded = useState(false);
|
|
@@ -26,16 +25,23 @@ export function OrgContextSwitcher(props) {
|
|
|
26
25
|
|
|
27
26
|
useEffect(function() {
|
|
28
27
|
apiCall('/organizations').then(function(d) {
|
|
29
|
-
|
|
28
|
+
var list = d.organizations || [];
|
|
29
|
+
setOrgs(list);
|
|
30
30
|
setLoaded(true);
|
|
31
|
+
// Auto-select user's org on first load if org-bound
|
|
32
|
+
if (userOrgId && !selectedOrgId) {
|
|
33
|
+
var org = list.find(function(o) { return o.id === userOrgId; });
|
|
34
|
+
if (org) onOrgChange(userOrgId, org);
|
|
35
|
+
}
|
|
31
36
|
}).catch(function() { setLoaded(true); });
|
|
32
|
-
}, []);
|
|
37
|
+
}, [userOrgId]);
|
|
33
38
|
|
|
34
|
-
// Don't render if no client orgs
|
|
35
|
-
if (loaded && orgs.length === 0) return null;
|
|
39
|
+
// Don't render if no client orgs and user isn't org-bound
|
|
40
|
+
if (loaded && orgs.length === 0 && !userOrgId) return null;
|
|
36
41
|
if (!loaded) return null;
|
|
37
42
|
|
|
38
|
-
var
|
|
43
|
+
var effectiveId = isLocked ? userOrgId : selectedOrgId;
|
|
44
|
+
var selectedOrg = orgs.find(function(o) { return o.id === effectiveId; });
|
|
39
45
|
|
|
40
46
|
return h('div', {
|
|
41
47
|
style: Object.assign({
|
|
@@ -45,41 +51,57 @@ export function OrgContextSwitcher(props) {
|
|
|
45
51
|
}, style)
|
|
46
52
|
},
|
|
47
53
|
showLabel && h('span', { style: { color: 'var(--text-muted)', fontWeight: 600, whiteSpace: 'nowrap' } }, I.building(), ' Viewing:'),
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
54
|
+
isLocked
|
|
55
|
+
? h('div', { style: { fontWeight: 600, fontSize: 13, color: 'var(--text)', display: 'flex', alignItems: 'center', gap: 6 } },
|
|
56
|
+
selectedOrg ? selectedOrg.name : 'Your Organization',
|
|
57
|
+
h('span', { className: 'badge badge-neutral', style: { fontSize: 10 } }, 'Locked')
|
|
58
|
+
)
|
|
59
|
+
: h('select', {
|
|
60
|
+
value: selectedOrgId,
|
|
61
|
+
onChange: function(e) {
|
|
62
|
+
var id = e.target.value;
|
|
63
|
+
var org = orgs.find(function(o) { return o.id === id; });
|
|
64
|
+
onOrgChange(id, org || null);
|
|
65
|
+
},
|
|
66
|
+
style: {
|
|
67
|
+
padding: '6px 10px', borderRadius: 6, border: '1px solid var(--border)',
|
|
68
|
+
background: 'var(--bg-card)', color: 'var(--text)', fontSize: 13,
|
|
69
|
+
cursor: 'pointer', fontWeight: 600, flex: 1, maxWidth: 300
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
h('option', { value: '' }, 'My Organization'),
|
|
73
|
+
orgs.filter(function(o) { return o.is_active !== false; }).map(function(o) {
|
|
74
|
+
return h('option', { key: o.id, value: o.id }, o.name + (o.billing_rate_per_agent > 0 ? ' (' + (o.currency || 'USD') + ' ' + parseFloat(o.billing_rate_per_agent).toFixed(0) + '/agent)' : ''));
|
|
75
|
+
})
|
|
76
|
+
),
|
|
66
77
|
selectedOrg && h('span', { style: { fontSize: 11, color: 'var(--text-muted)' } },
|
|
67
78
|
selectedOrg.contact_name ? selectedOrg.contact_name : '',
|
|
68
79
|
selectedOrg.contact_email ? ' \u2022 ' + selectedOrg.contact_email : ''
|
|
69
|
-
)
|
|
80
|
+
),
|
|
81
|
+
// Impersonation banner
|
|
82
|
+
app.impersonating && h('span', { className: 'badge badge-warning', style: { fontSize: 10, marginLeft: 'auto' } }, 'Impersonating: ' + (user.name || user.email))
|
|
70
83
|
);
|
|
71
84
|
}
|
|
72
85
|
|
|
73
86
|
/**
|
|
74
87
|
* useOrgContext — Hook that provides org switching state.
|
|
75
|
-
*
|
|
88
|
+
* Auto-selects the user's client org if they are org-bound.
|
|
76
89
|
*/
|
|
77
90
|
export function useOrgContext() {
|
|
78
|
-
var
|
|
91
|
+
var app = useApp();
|
|
92
|
+
var user = app.user || {};
|
|
93
|
+
var userOrgId = user.clientOrgId || '';
|
|
94
|
+
|
|
95
|
+
var _sel = useState(userOrgId);
|
|
79
96
|
var selectedOrgId = _sel[0]; var setSelectedOrgId = _sel[1];
|
|
80
97
|
var _org = useState(null);
|
|
81
98
|
var selectedOrg = _org[0]; var setSelectedOrg = _org[1];
|
|
82
99
|
|
|
100
|
+
// If user changes (e.g. impersonation), update default
|
|
101
|
+
useEffect(function() {
|
|
102
|
+
if (userOrgId && !selectedOrgId) setSelectedOrgId(userOrgId);
|
|
103
|
+
}, [userOrgId]);
|
|
104
|
+
|
|
83
105
|
var onOrgChange = function(id, org) {
|
|
84
106
|
setSelectedOrgId(id);
|
|
85
107
|
setSelectedOrg(org);
|
|
@@ -342,9 +342,15 @@ export function OrganizationsPage() {
|
|
|
342
342
|
)
|
|
343
343
|
),
|
|
344
344
|
// Billing rate in header
|
|
345
|
-
detailOrg.billing_rate_per_agent > 0 && h('div', { style: { display: 'flex', alignItems: 'center', gap: 16, padding: '10px 14px', background: 'var(--success-soft, rgba(21,128,61,0.06))', borderRadius: 8, marginBottom: 16, fontSize: 13 } },
|
|
346
|
-
h('div', null, h('strong', null, 'Rate: '), (detailOrg.currency || 'USD') + ' ' + parseFloat(detailOrg.billing_rate_per_agent).toFixed(2) + '/agent/month'),
|
|
347
|
-
h('div', null, h('strong', null, 'Monthly Revenue: '), (
|
|
345
|
+
(detailOrg.billing_rate_per_agent > 0 || detailAgents.some(function(a) { return a.billing_rate > 0; })) && h('div', { style: { display: 'flex', alignItems: 'center', gap: 16, padding: '10px 14px', background: 'var(--success-soft, rgba(21,128,61,0.06))', borderRadius: 8, marginBottom: 16, fontSize: 13, flexWrap: 'wrap' } },
|
|
346
|
+
h('div', null, h('strong', null, 'Default Rate: '), (detailOrg.currency || 'USD') + ' ' + parseFloat(detailOrg.billing_rate_per_agent || 0).toFixed(2) + '/agent/month'),
|
|
347
|
+
h('div', null, h('strong', null, 'Monthly Revenue: '), (function() {
|
|
348
|
+
var total = detailAgents.reduce(function(sum, a) {
|
|
349
|
+
var rate = a.billing_rate > 0 ? parseFloat(a.billing_rate) : parseFloat(detailOrg.billing_rate_per_agent || 0);
|
|
350
|
+
return sum + rate;
|
|
351
|
+
}, 0);
|
|
352
|
+
return (detailOrg.currency || 'USD') + ' ' + total.toFixed(2);
|
|
353
|
+
})()),
|
|
348
354
|
h('div', null, h('strong', null, 'Agents: '), detailAgents.length)
|
|
349
355
|
),
|
|
350
356
|
|
|
@@ -438,6 +444,36 @@ export function OrganizationsPage() {
|
|
|
438
444
|
'No billing data yet. Billing records are created as agents process tasks and accumulate token costs.'
|
|
439
445
|
),
|
|
440
446
|
|
|
447
|
+
// Per-agent billing rates
|
|
448
|
+
detailAgents.length > 0 && h('div', { style: { marginBottom: 20 } },
|
|
449
|
+
h('div', { style: { fontSize: 14, fontWeight: 700, marginBottom: 8 } }, 'Per-Agent Billing Rates'),
|
|
450
|
+
h('div', { style: { fontSize: 11, color: 'var(--text-muted)', marginBottom: 10 } }, 'Set custom billing rates per agent. Leave blank to use the default org rate (' + (detailOrg.currency || 'USD') + ' ' + parseFloat(detailOrg.billing_rate_per_agent || 0).toFixed(2) + '/agent/month).'),
|
|
451
|
+
h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 10 } },
|
|
452
|
+
detailAgents.map(function(a) {
|
|
453
|
+
var agentRate = a.billing_rate > 0 ? parseFloat(a.billing_rate) : 0;
|
|
454
|
+
var effectiveRate = agentRate > 0 ? agentRate : parseFloat(detailOrg.billing_rate_per_agent || 0);
|
|
455
|
+
return h('div', { key: a.id, style: { padding: 10, background: 'var(--bg-tertiary)', borderRadius: 8 } },
|
|
456
|
+
h('div', { style: { fontWeight: 600, fontSize: 13, marginBottom: 6 } }, a.name || a.id),
|
|
457
|
+
h('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
|
|
458
|
+
h('span', { style: { fontSize: 12, color: 'var(--text-muted)' } }, detailOrg.currency || 'USD'),
|
|
459
|
+
h('input', { className: 'input', type: 'number', step: '0.01', min: '0', value: agentRate > 0 ? agentRate : '',
|
|
460
|
+
placeholder: effectiveRate.toFixed(2),
|
|
461
|
+
onChange: function(e) {
|
|
462
|
+
var val = parseFloat(e.target.value) || 0;
|
|
463
|
+
apiCall('/agents/' + a.id, { method: 'PATCH', body: JSON.stringify({ billingRate: val }) })
|
|
464
|
+
.then(function() { toast('Rate updated for ' + (a.name || a.id), 'success'); })
|
|
465
|
+
.catch(function(err) { toast(err.message, 'error'); });
|
|
466
|
+
},
|
|
467
|
+
style: { width: 90, fontSize: 12, padding: '4px 6px' }
|
|
468
|
+
}),
|
|
469
|
+
h('span', { style: { fontSize: 10, color: 'var(--text-muted)' } }, '/mo')
|
|
470
|
+
),
|
|
471
|
+
agentRate > 0 && h('div', { style: { fontSize: 10, color: 'var(--success, #15803d)', marginTop: 4 } }, 'Custom rate')
|
|
472
|
+
);
|
|
473
|
+
})
|
|
474
|
+
)
|
|
475
|
+
),
|
|
476
|
+
|
|
441
477
|
// Stats summary
|
|
442
478
|
(function() {
|
|
443
479
|
var totRev = billingSummary.reduce(function(a, m) { return a + (parseFloat(m.total_revenue) || 0); }, 0);
|
|
@@ -5,7 +5,7 @@ import { HelpButton } from '../components/help-button.js';
|
|
|
5
5
|
|
|
6
6
|
// ─── Permission Editor Component ───────────────────
|
|
7
7
|
|
|
8
|
-
function PermissionEditor({ userId, userName, currentPerms, pageRegistry, onSave, onClose }) {
|
|
8
|
+
function PermissionEditor({ userId, userName, currentPerms, pageRegistry, onSave, onClose, userObj }) {
|
|
9
9
|
// Deep clone perms (skip _allowedAgents from page grants)
|
|
10
10
|
var [grants, setGrants] = useState(function() {
|
|
11
11
|
if (currentPerms === '*') {
|
|
@@ -34,8 +34,13 @@ function PermissionEditor({ userId, userName, currentPerms, pageRegistry, onSave
|
|
|
34
34
|
return (currentPerms || {})._allowedAgents === '*' || !(currentPerms || {})._allowedAgents;
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
+
// Client org assignment
|
|
38
|
+
var [permOrgs, setPermOrgs] = useState([]);
|
|
39
|
+
var [userOrgId, setUserOrgId] = useState((userObj && userObj.clientOrgId) || '');
|
|
40
|
+
|
|
37
41
|
useEffect(function() {
|
|
38
42
|
apiCall('/agents').then(function(d) { setAgents(d.agents || d || []); }).catch(function() {});
|
|
43
|
+
apiCall('/organizations').then(function(d) { setPermOrgs(d.organizations || []); }).catch(function() {});
|
|
39
44
|
}, []);
|
|
40
45
|
var [saving, setSaving] = useState(false);
|
|
41
46
|
var [expandedPage, setExpandedPage] = useState(null);
|
|
@@ -415,8 +420,9 @@ export function UsersPage() {
|
|
|
415
420
|
var toast = app.toast;
|
|
416
421
|
var [users, setUsers] = useState([]);
|
|
417
422
|
var [creating, setCreating] = useState(false);
|
|
418
|
-
var [form, setForm] = useState({ email: '', password: '', name: '', role: 'viewer', permissions: '*' });
|
|
423
|
+
var [form, setForm] = useState({ email: '', password: '', name: '', role: 'viewer', permissions: '*', clientOrgId: '' });
|
|
419
424
|
var [resetTarget, setResetTarget] = useState(null);
|
|
425
|
+
var [clientOrgs, setClientOrgs] = useState([]);
|
|
420
426
|
var [newPassword, setNewPassword] = useState('');
|
|
421
427
|
var [resetting, setResetting] = useState(false);
|
|
422
428
|
var [permTarget, setPermTarget] = useState(null); // user object for permission editing
|
|
@@ -427,6 +433,7 @@ export function UsersPage() {
|
|
|
427
433
|
useEffect(function() {
|
|
428
434
|
load();
|
|
429
435
|
apiCall('/page-registry').then(function(d) { setPageRegistry(d); }).catch(function() {});
|
|
436
|
+
apiCall('/organizations').then(function(d) { setClientOrgs(d.organizations || []); }).catch(function() {});
|
|
430
437
|
}, []);
|
|
431
438
|
|
|
432
439
|
var generateCreatePassword = function() {
|
|
@@ -442,9 +449,10 @@ export function UsersPage() {
|
|
|
442
449
|
try {
|
|
443
450
|
var body = { email: form.email, password: form.password, name: form.name, role: form.role };
|
|
444
451
|
if (form.permissions !== '*') body.permissions = form.permissions;
|
|
452
|
+
if (form.clientOrgId) body.clientOrgId = form.clientOrgId;
|
|
445
453
|
await apiCall('/users', { method: 'POST', body: JSON.stringify(body) });
|
|
446
454
|
toast('User created. They will be prompted to set a new password on first login.', 'success');
|
|
447
|
-
setCreating(false); setForm({ email: '', password: '', name: '', role: 'viewer', permissions: '*' }); setShowCreatePerms(false); load();
|
|
455
|
+
setCreating(false); setForm({ email: '', password: '', name: '', role: 'viewer', permissions: '*', clientOrgId: '' }); setShowCreatePerms(false); load();
|
|
448
456
|
} catch (e) { toast(e.message, 'error'); }
|
|
449
457
|
};
|
|
450
458
|
|
|
@@ -566,6 +574,17 @@ export function UsersPage() {
|
|
|
566
574
|
)
|
|
567
575
|
),
|
|
568
576
|
h('div', { className: 'form-group' }, h('label', { className: 'form-label' }, 'Role'), h('select', { className: 'input', value: form.role, onChange: function(e) { setForm(function(f) { return Object.assign({}, f, { role: e.target.value }); }); } }, h('option', { value: 'viewer' }, 'Viewer'), h('option', { value: 'member' }, 'Member'), h('option', { value: 'admin' }, 'Admin'), h('option', { value: 'owner' }, 'Owner'))),
|
|
577
|
+
// Client organization assignment
|
|
578
|
+
clientOrgs.length > 0 && h('div', { className: 'form-group' },
|
|
579
|
+
h('label', { className: 'form-label' }, 'Client Organization'),
|
|
580
|
+
h('select', { className: 'input', value: form.clientOrgId, onChange: function(e) { setForm(function(f) { return Object.assign({}, f, { clientOrgId: e.target.value }); }); } },
|
|
581
|
+
h('option', { value: '' }, 'None (internal user)'),
|
|
582
|
+
clientOrgs.filter(function(o) { return o.is_active !== false; }).map(function(o) {
|
|
583
|
+
return h('option', { key: o.id, value: o.id }, o.name);
|
|
584
|
+
})
|
|
585
|
+
),
|
|
586
|
+
h('div', { style: { fontSize: 11, color: 'var(--text-muted)', marginTop: 4 } }, 'Assigning to a client org restricts this user to only see that organization\'s agents and data.')
|
|
587
|
+
),
|
|
569
588
|
// Inline permissions for member/viewer
|
|
570
589
|
(form.role === 'member' || form.role === 'viewer') && h('div', { style: { marginTop: 4 } },
|
|
571
590
|
h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' } },
|
|
@@ -700,7 +719,7 @@ export function UsersPage() {
|
|
|
700
719
|
h('div', { className: 'card-body-flush' },
|
|
701
720
|
users.length === 0 ? h('div', { style: { padding: 24, textAlign: 'center', color: 'var(--text-muted)' } }, 'No users')
|
|
702
721
|
: h('table', null,
|
|
703
|
-
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, 'Access'), h('th', null, '2FA'), h('th', null, 'Created'), h('th', { style: { width:
|
|
722
|
+
h('thead', null, h('tr', null, h('th', null, 'Name'), h('th', null, 'Email'), h('th', null, 'Role'), h('th', null, 'Organization'), h('th', null, 'Status'), h('th', null, 'Access'), h('th', null, '2FA'), h('th', null, 'Created'), h('th', { style: { width: 240 } }, 'Actions'))),
|
|
704
723
|
h('tbody', null, users.map(function(u) {
|
|
705
724
|
var isRestricted = u.role === 'member' || u.role === 'viewer';
|
|
706
725
|
var isDeactivated = u.isActive === false;
|
|
@@ -709,6 +728,10 @@ export function UsersPage() {
|
|
|
709
728
|
h('td', null, h('strong', null, u.name || '-')),
|
|
710
729
|
h('td', null, h('span', { style: { fontFamily: 'var(--font-mono)', fontSize: 12 } }, u.email)),
|
|
711
730
|
h('td', null, h('span', { className: 'badge badge-' + (u.role === 'owner' ? 'warning' : u.role === 'admin' ? 'primary' : 'neutral') }, u.role)),
|
|
731
|
+
h('td', null, (function() {
|
|
732
|
+
var org = u.clientOrgId && clientOrgs.find(function(o) { return o.id === u.clientOrgId; });
|
|
733
|
+
return org ? h('span', { className: 'badge badge-info', style: { fontSize: 10 } }, org.name) : h('span', { style: { color: 'var(--text-muted)', fontSize: 11 } }, 'Internal');
|
|
734
|
+
})()),
|
|
712
735
|
h('td', null, isDeactivated
|
|
713
736
|
? h('span', { className: 'badge badge-danger', style: { fontSize: 10 } }, 'Deactivated')
|
|
714
737
|
: h('span', { className: 'badge badge-success', style: { fontSize: 10 } }, 'Active')
|
|
@@ -725,6 +748,13 @@ export function UsersPage() {
|
|
|
725
748
|
style: !isRestricted ? { opacity: 0.4 } : {}
|
|
726
749
|
}, I.shield()),
|
|
727
750
|
h('button', { className: 'btn btn-ghost btn-sm', title: 'Reset Password', onClick: function() { setResetTarget(u); setNewPassword(''); } }, I.lock()),
|
|
751
|
+
// Impersonate (owner-only, not self)
|
|
752
|
+
!isSelf && app.user && app.user.role === 'owner' && !isDeactivated && h('button', {
|
|
753
|
+
className: 'btn btn-ghost btn-sm',
|
|
754
|
+
title: 'View as ' + (u.name || u.email),
|
|
755
|
+
onClick: function() { if (app.startImpersonation) app.startImpersonation(u.id); },
|
|
756
|
+
style: { color: 'var(--primary)' }
|
|
757
|
+
}, I.agents()),
|
|
728
758
|
// Deactivate / Reactivate
|
|
729
759
|
!isSelf && h('button', {
|
|
730
760
|
className: 'btn btn-ghost btn-sm',
|
package/src/db/adapter.ts
CHANGED
package/src/db/postgres.ts
CHANGED
|
@@ -192,6 +192,8 @@ export class PostgresAdapter extends DatabaseAdapter {
|
|
|
192
192
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS permissions JSONB DEFAULT '"*"';
|
|
193
193
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS must_reset_password BOOLEAN DEFAULT FALSE;
|
|
194
194
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT TRUE;
|
|
195
|
+
ALTER TABLE users ADD COLUMN IF NOT EXISTS client_org_id TEXT;
|
|
196
|
+
ALTER TABLE agents ADD COLUMN IF NOT EXISTS billing_rate NUMERIC(10,2) DEFAULT 0;
|
|
195
197
|
`).catch(() => {});
|
|
196
198
|
// ─── Client Organizations ────────────────────────────
|
|
197
199
|
await client.query(`
|
|
@@ -757,6 +759,7 @@ export class PostgresAdapter extends DatabaseAdapter {
|
|
|
757
759
|
permissions: r.permissions != null ? (typeof r.permissions === 'string' ? (() => { try { return JSON.parse(r.permissions); } catch { return '*'; } })() : r.permissions) : '*',
|
|
758
760
|
mustResetPassword: !!r.must_reset_password,
|
|
759
761
|
isActive: r.is_active !== false && r.is_active !== 0, // default true
|
|
762
|
+
clientOrgId: r.client_org_id || null,
|
|
760
763
|
createdAt: new Date(r.created_at), updatedAt: new Date(r.updated_at),
|
|
761
764
|
lastLoginAt: r.last_login_at ? new Date(r.last_login_at) : undefined,
|
|
762
765
|
};
|
package/src/db/sqlite.ts
CHANGED
|
@@ -63,6 +63,8 @@ export class SqliteAdapter extends DatabaseAdapter {
|
|
|
63
63
|
try { this.db.exec(`ALTER TABLE users ADD COLUMN permissions TEXT DEFAULT '"*"'`); } catch { /* exists */ }
|
|
64
64
|
try { this.db.exec(`ALTER TABLE users ADD COLUMN must_reset_password INTEGER DEFAULT 0`); } catch { /* exists */ }
|
|
65
65
|
try { this.db.exec(`ALTER TABLE users ADD COLUMN is_active INTEGER DEFAULT 1`); } catch { /* exists */ }
|
|
66
|
+
try { this.db.exec(`ALTER TABLE users ADD COLUMN client_org_id TEXT`); } catch { /* exists */ }
|
|
67
|
+
try { this.db.exec(`ALTER TABLE agents ADD COLUMN billing_rate REAL DEFAULT 0`); } catch { /* exists */ }
|
|
66
68
|
// ─── Client Organizations ────────────────────────────
|
|
67
69
|
this.db.exec(`
|
|
68
70
|
CREATE TABLE IF NOT EXISTS client_organizations (
|
|
@@ -433,6 +435,11 @@ export class SqliteAdapter extends DatabaseAdapter {
|
|
|
433
435
|
return {
|
|
434
436
|
id: r.id, email: r.email, name: r.name, role: r.role,
|
|
435
437
|
passwordHash: r.password_hash, ssoProvider: r.sso_provider, ssoSubject: r.sso_subject,
|
|
438
|
+
totpSecret: r.totp_secret, totpEnabled: !!r.totp_enabled, totpBackupCodes: r.totp_backup_codes,
|
|
439
|
+
permissions: r.permissions != null ? (typeof r.permissions === 'string' ? (() => { try { return JSON.parse(r.permissions); } catch { return '*'; } })() : r.permissions) : '*',
|
|
440
|
+
mustResetPassword: !!r.must_reset_password,
|
|
441
|
+
isActive: r.is_active !== 0 && r.is_active !== false,
|
|
442
|
+
clientOrgId: r.client_org_id || null,
|
|
436
443
|
createdAt: new Date(r.created_at), updatedAt: new Date(r.updated_at),
|
|
437
444
|
lastLoginAt: r.last_login_at ? new Date(r.last_login_at) : undefined,
|
|
438
445
|
};
|