@agenticmail/enterprise 0.5.302 → 0.5.304

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.
@@ -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) => {
@@ -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
- return h(AppContext.Provider, { value: { toast, toasts, user, theme, setPage, permissions } },
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
- * Props:
8
- * onOrgChange(orgId, org) called when org selection changes
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
- setOrgs(d.organizations || []);
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 exist
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 selectedOrg = orgs.find(function(o) { return o.id === selectedOrgId; });
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
- h('select', {
49
- value: selectedOrgId,
50
- onChange: function(e) {
51
- var id = e.target.value;
52
- var org = orgs.find(function(o) { return o.id === id; });
53
- onOrgChange(id, org || null);
54
- },
55
- style: {
56
- padding: '6px 10px', borderRadius: 6, border: '1px solid var(--border)',
57
- background: 'var(--bg-card)', color: 'var(--text)', fontSize: 13,
58
- cursor: 'pointer', fontWeight: 600, flex: 1, maxWidth: 300
59
- }
60
- },
61
- h('option', { value: '' }, 'My Organization'),
62
- orgs.filter(function(o) { return o.is_active !== false; }).map(function(o) {
63
- 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)' : ''));
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
- * Returns [selectedOrgId, selectedOrg, onOrgChange, OrgSwitcher component]
88
+ * Auto-selects the user's client org if they are org-bound.
76
89
  */
77
90
  export function useOrgContext() {
78
- var _sel = useState('');
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: '), (detailOrg.currency || 'USD') + ' ' + (parseFloat(detailOrg.billing_rate_per_agent) * detailAgents.length).toFixed(2)),
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);
@@ -130,6 +135,8 @@ function PermissionEditor({ userId, userName, currentPerms, pageRegistry, onSave
130
135
  }
131
136
  }
132
137
  await onSave(permsToSave);
138
+ // Save org assignment
139
+ await apiCall('/users/' + userId, { method: 'PATCH', body: JSON.stringify({ clientOrgId: userOrgId || null }) }).catch(function() {});
133
140
  } catch(e) { /* handled by parent */ }
134
141
  setSaving(false);
135
142
  };
@@ -228,6 +235,30 @@ function PermissionEditor({ userId, userName, currentPerms, pageRegistry, onSave
228
235
  })
229
236
  ),
230
237
 
238
+ // ─── Organization Assignment ──────────────────────
239
+ permOrgs.length > 0 && h('div', { style: { marginTop: 16, padding: '12px 14px', background: 'var(--bg-tertiary)', borderRadius: 8 } },
240
+ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 } },
241
+ h('div', null,
242
+ h('strong', { style: { fontSize: 13 } }, 'Client Organization'),
243
+ h('div', { style: { fontSize: 11, color: 'var(--text-muted)', marginTop: 1 } }, 'Bind this user to a client org — they will only see that org\'s agents and data')
244
+ )
245
+ ),
246
+ h('select', {
247
+ className: 'input',
248
+ value: userOrgId,
249
+ onChange: function(e) { setUserOrgId(e.target.value); },
250
+ style: { fontSize: 13 }
251
+ },
252
+ h('option', { value: '' }, 'None (Internal User)'),
253
+ permOrgs.filter(function(o) { return o.is_active !== false; }).map(function(o) {
254
+ return h('option', { key: o.id, value: o.id }, o.name);
255
+ })
256
+ ),
257
+ userOrgId && h('div', { style: { fontSize: 11, color: 'var(--warning, #f59e0b)', marginTop: 6 } },
258
+ 'This user will only see agents and data belonging to this organization. The org switcher will be locked for them.'
259
+ )
260
+ ),
261
+
231
262
  // ─── Agent Access ──────────────────────────
232
263
  !fullAccess && h('div', { style: { marginTop: 16 } },
233
264
  h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 } },
@@ -415,8 +446,9 @@ export function UsersPage() {
415
446
  var toast = app.toast;
416
447
  var [users, setUsers] = useState([]);
417
448
  var [creating, setCreating] = useState(false);
418
- var [form, setForm] = useState({ email: '', password: '', name: '', role: 'viewer', permissions: '*' });
449
+ var [form, setForm] = useState({ email: '', password: '', name: '', role: 'viewer', permissions: '*', clientOrgId: '' });
419
450
  var [resetTarget, setResetTarget] = useState(null);
451
+ var [clientOrgs, setClientOrgs] = useState([]);
420
452
  var [newPassword, setNewPassword] = useState('');
421
453
  var [resetting, setResetting] = useState(false);
422
454
  var [permTarget, setPermTarget] = useState(null); // user object for permission editing
@@ -427,6 +459,7 @@ export function UsersPage() {
427
459
  useEffect(function() {
428
460
  load();
429
461
  apiCall('/page-registry').then(function(d) { setPageRegistry(d); }).catch(function() {});
462
+ apiCall('/organizations').then(function(d) { setClientOrgs(d.organizations || []); }).catch(function() {});
430
463
  }, []);
431
464
 
432
465
  var generateCreatePassword = function() {
@@ -442,9 +475,10 @@ export function UsersPage() {
442
475
  try {
443
476
  var body = { email: form.email, password: form.password, name: form.name, role: form.role };
444
477
  if (form.permissions !== '*') body.permissions = form.permissions;
478
+ if (form.clientOrgId) body.clientOrgId = form.clientOrgId;
445
479
  await apiCall('/users', { method: 'POST', body: JSON.stringify(body) });
446
480
  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();
481
+ setCreating(false); setForm({ email: '', password: '', name: '', role: 'viewer', permissions: '*', clientOrgId: '' }); setShowCreatePerms(false); load();
448
482
  } catch (e) { toast(e.message, 'error'); }
449
483
  };
450
484
 
@@ -566,6 +600,17 @@ export function UsersPage() {
566
600
  )
567
601
  ),
568
602
  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'))),
603
+ // Client organization assignment
604
+ clientOrgs.length > 0 && h('div', { className: 'form-group' },
605
+ h('label', { className: 'form-label' }, 'Client Organization'),
606
+ h('select', { className: 'input', value: form.clientOrgId, onChange: function(e) { setForm(function(f) { return Object.assign({}, f, { clientOrgId: e.target.value }); }); } },
607
+ h('option', { value: '' }, 'None (internal user)'),
608
+ clientOrgs.filter(function(o) { return o.is_active !== false; }).map(function(o) {
609
+ return h('option', { key: o.id, value: o.id }, o.name);
610
+ })
611
+ ),
612
+ 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.')
613
+ ),
569
614
  // Inline permissions for member/viewer
570
615
  (form.role === 'member' || form.role === 'viewer') && h('div', { style: { marginTop: 4 } },
571
616
  h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' } },
@@ -700,7 +745,7 @@ export function UsersPage() {
700
745
  h('div', { className: 'card-body-flush' },
701
746
  users.length === 0 ? h('div', { style: { padding: 24, textAlign: 'center', color: 'var(--text-muted)' } }, 'No users')
702
747
  : 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: 200 } }, 'Actions'))),
748
+ 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
749
  h('tbody', null, users.map(function(u) {
705
750
  var isRestricted = u.role === 'member' || u.role === 'viewer';
706
751
  var isDeactivated = u.isActive === false;
@@ -709,6 +754,10 @@ export function UsersPage() {
709
754
  h('td', null, h('strong', null, u.name || '-')),
710
755
  h('td', null, h('span', { style: { fontFamily: 'var(--font-mono)', fontSize: 12 } }, u.email)),
711
756
  h('td', null, h('span', { className: 'badge badge-' + (u.role === 'owner' ? 'warning' : u.role === 'admin' ? 'primary' : 'neutral') }, u.role)),
757
+ h('td', null, (function() {
758
+ var org = u.clientOrgId && clientOrgs.find(function(o) { return o.id === u.clientOrgId; });
759
+ return org ? h('span', { className: 'badge badge-info', style: { fontSize: 10 } }, org.name) : h('span', { style: { color: 'var(--text-muted)', fontSize: 11 } }, 'Internal');
760
+ })()),
712
761
  h('td', null, isDeactivated
713
762
  ? h('span', { className: 'badge badge-danger', style: { fontSize: 10 } }, 'Deactivated')
714
763
  : h('span', { className: 'badge badge-success', style: { fontSize: 10 } }, 'Active')
@@ -725,6 +774,13 @@ export function UsersPage() {
725
774
  style: !isRestricted ? { opacity: 0.4 } : {}
726
775
  }, I.shield()),
727
776
  h('button', { className: 'btn btn-ghost btn-sm', title: 'Reset Password', onClick: function() { setResetTarget(u); setNewPassword(''); } }, I.lock()),
777
+ // Impersonate (owner-only, not self)
778
+ !isSelf && app.user && app.user.role === 'owner' && !isDeactivated && h('button', {
779
+ className: 'btn btn-ghost btn-sm',
780
+ title: 'View as ' + (u.name || u.email),
781
+ onClick: function() { if (app.startImpersonation) app.startImpersonation(u.id); },
782
+ style: { color: 'var(--primary)' }
783
+ }, I.agents()),
728
784
  // Deactivate / Reactivate
729
785
  !isSelf && h('button', {
730
786
  className: 'btn btn-ghost btn-sm',
package/src/db/adapter.ts CHANGED
@@ -68,6 +68,7 @@ export interface User {
68
68
  permissions?: any; // '*' or { pageId: true | string[] }
69
69
  mustResetPassword?: boolean;
70
70
  isActive?: boolean;
71
+ clientOrgId?: string | null;
71
72
  createdAt: Date;
72
73
  updatedAt: Date;
73
74
  lastLoginAt?: Date;
@@ -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
  };