@agenticmail/enterprise 0.5.291 → 0.5.293
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/agent-heartbeat-W2XLGPOG.js +510 -0
- package/dist/agent-tools-H7BYL7A6.js +13881 -0
- package/dist/chunk-64SXJMJI.js +3841 -0
- package/dist/chunk-67AD7SKP.js +4739 -0
- package/dist/chunk-I3AODI5Z.js +1519 -0
- package/dist/chunk-NPR5DIPX.js +48 -0
- package/dist/cli-agent-5JNS5J2U.js +1778 -0
- package/dist/cli-recover-2U3N37CE.js +487 -0
- package/dist/cli-serve-QM2D6TYP.js +143 -0
- package/dist/cli-verify-R32RJGVW.js +149 -0
- package/dist/cli.js +5 -5
- package/dist/dashboard/app.js +38 -4
- package/dist/dashboard/components/icons.js +2 -0
- package/dist/dashboard/pages/agent-detail/index.js +11 -1
- package/dist/dashboard/pages/users.js +282 -13
- package/dist/factory-KWNTMIVU.js +9 -0
- package/dist/index.js +17 -17
- package/dist/page-registry-OZYEX3Q3.js +178 -0
- package/dist/postgres-CRAQ7OOV.js +760 -0
- package/dist/routes-6DD25A5C.js +13695 -0
- package/dist/runtime-HKTQ22HR.js +45 -0
- package/dist/server-A37MVNDZ.js +15 -0
- package/dist/setup-XI4ZTR4B.js +20 -0
- package/dist/sqlite-RVBJTDQC.js +495 -0
- package/package.json +1 -1
- package/src/admin/page-registry.ts +204 -0
- package/src/admin/routes.ts +80 -0
- package/src/dashboard/app.js +38 -4
- package/src/dashboard/components/icons.js +2 -0
- package/src/dashboard/pages/agent-detail/index.js +11 -1
- package/src/dashboard/pages/users.js +282 -13
- package/src/db/adapter.ts +1 -0
- package/src/db/postgres.ts +2 -0
- package/src/db/sqlite.ts +4 -0
|
@@ -3,17 +3,232 @@ import { I } from '../components/icons.js';
|
|
|
3
3
|
import { Modal } from '../components/modal.js';
|
|
4
4
|
import { HelpButton } from '../components/help-button.js';
|
|
5
5
|
|
|
6
|
+
// ─── Permission Editor Component ───────────────────
|
|
7
|
+
|
|
8
|
+
function PermissionEditor({ userId, userName, currentPerms, pageRegistry, onSave, onClose }) {
|
|
9
|
+
// Deep clone perms
|
|
10
|
+
var [grants, setGrants] = useState(function() {
|
|
11
|
+
if (currentPerms === '*') {
|
|
12
|
+
// Start with all pages selected (all tabs)
|
|
13
|
+
var all = {};
|
|
14
|
+
Object.keys(pageRegistry).forEach(function(pid) { all[pid] = true; });
|
|
15
|
+
return all;
|
|
16
|
+
}
|
|
17
|
+
// Clone existing
|
|
18
|
+
var c = {};
|
|
19
|
+
Object.keys(currentPerms || {}).forEach(function(pid) {
|
|
20
|
+
var g = currentPerms[pid];
|
|
21
|
+
c[pid] = g === true ? true : (Array.isArray(g) ? g.slice() : true);
|
|
22
|
+
});
|
|
23
|
+
return c;
|
|
24
|
+
});
|
|
25
|
+
var [fullAccess, setFullAccess] = useState(currentPerms === '*');
|
|
26
|
+
var [saving, setSaving] = useState(false);
|
|
27
|
+
var [expandedPage, setExpandedPage] = useState(null);
|
|
28
|
+
|
|
29
|
+
var sections = { overview: [], management: [], administration: [] };
|
|
30
|
+
Object.keys(pageRegistry).forEach(function(pid) {
|
|
31
|
+
var page = pageRegistry[pid];
|
|
32
|
+
if (sections[page.section]) sections[page.section].push(pid);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
var togglePage = function(pid) {
|
|
36
|
+
setGrants(function(g) {
|
|
37
|
+
var next = Object.assign({}, g);
|
|
38
|
+
if (next[pid]) {
|
|
39
|
+
delete next[pid];
|
|
40
|
+
if (expandedPage === pid) setExpandedPage(null);
|
|
41
|
+
} else {
|
|
42
|
+
next[pid] = true;
|
|
43
|
+
}
|
|
44
|
+
return next;
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
var toggleTab = function(pid, tabId) {
|
|
49
|
+
setGrants(function(g) {
|
|
50
|
+
var next = Object.assign({}, g);
|
|
51
|
+
var current = next[pid];
|
|
52
|
+
var allTabs = Object.keys(pageRegistry[pid].tabs || {});
|
|
53
|
+
|
|
54
|
+
if (current === true) {
|
|
55
|
+
// Was all tabs — remove this one
|
|
56
|
+
var remaining = allTabs.filter(function(t) { return t !== tabId; });
|
|
57
|
+
next[pid] = remaining.length > 0 ? remaining : true;
|
|
58
|
+
} else if (Array.isArray(current)) {
|
|
59
|
+
var idx = current.indexOf(tabId);
|
|
60
|
+
if (idx >= 0) {
|
|
61
|
+
var arr = current.filter(function(t) { return t !== tabId; });
|
|
62
|
+
if (arr.length === 0) delete next[pid]; // no tabs = remove page
|
|
63
|
+
else next[pid] = arr;
|
|
64
|
+
} else {
|
|
65
|
+
var newArr = current.concat([tabId]);
|
|
66
|
+
if (newArr.length === allTabs.length) next[pid] = true; // all tabs = page access
|
|
67
|
+
else next[pid] = newArr;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return next;
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
var isPageChecked = function(pid) { return !!grants[pid]; };
|
|
75
|
+
var isTabChecked = function(pid, tabId) {
|
|
76
|
+
var g = grants[pid];
|
|
77
|
+
if (!g) return false;
|
|
78
|
+
if (g === true) return true;
|
|
79
|
+
return Array.isArray(g) && g.indexOf(tabId) >= 0;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
var toggleFullAccess = function() {
|
|
83
|
+
var newVal = !fullAccess;
|
|
84
|
+
setFullAccess(newVal);
|
|
85
|
+
if (newVal) {
|
|
86
|
+
var all = {};
|
|
87
|
+
Object.keys(pageRegistry).forEach(function(pid) { all[pid] = true; });
|
|
88
|
+
setGrants(all);
|
|
89
|
+
} else {
|
|
90
|
+
setGrants({});
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
var selectAll = function() {
|
|
95
|
+
var all = {};
|
|
96
|
+
Object.keys(pageRegistry).forEach(function(pid) { all[pid] = true; });
|
|
97
|
+
setGrants(all);
|
|
98
|
+
};
|
|
99
|
+
var selectNone = function() { setGrants({}); };
|
|
100
|
+
|
|
101
|
+
var doSave = async function() {
|
|
102
|
+
setSaving(true);
|
|
103
|
+
try {
|
|
104
|
+
var permsToSave = fullAccess ? '*' : grants;
|
|
105
|
+
await onSave(permsToSave);
|
|
106
|
+
} catch(e) { /* handled by parent */ }
|
|
107
|
+
setSaving(false);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
var sectionLabels = { overview: 'Overview', management: 'Management', administration: 'Administration' };
|
|
111
|
+
|
|
112
|
+
var _cs = { display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', borderRadius: 6, cursor: 'pointer', fontSize: 13, transition: 'background 0.15s' };
|
|
113
|
+
var _csHover = Object.assign({}, _cs, { background: 'var(--bg-tertiary)' });
|
|
114
|
+
var _tabRow = { display: 'flex', alignItems: 'center', gap: 8, padding: '4px 12px 4px 40px', fontSize: 12, color: 'var(--text-secondary)', cursor: 'pointer' };
|
|
115
|
+
var _checkbox = { width: 16, height: 16, accentColor: 'var(--primary)', cursor: 'pointer' };
|
|
116
|
+
var _sectionTitle = { fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)', padding: '16px 12px 4px', marginTop: 4 };
|
|
117
|
+
|
|
118
|
+
return h(Modal, {
|
|
119
|
+
title: 'Edit Permissions — ' + (userName || 'User'),
|
|
120
|
+
onClose: onClose,
|
|
121
|
+
width: 560,
|
|
122
|
+
footer: h(Fragment, null,
|
|
123
|
+
h('button', { className: 'btn btn-secondary', onClick: onClose }, 'Cancel'),
|
|
124
|
+
h('button', { className: 'btn btn-primary', onClick: doSave, disabled: saving }, saving ? 'Saving...' : 'Save Permissions')
|
|
125
|
+
)
|
|
126
|
+
},
|
|
127
|
+
// Full access toggle
|
|
128
|
+
h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', marginBottom: 12, background: fullAccess ? 'var(--success-soft, rgba(21,128,61,0.1))' : 'var(--bg-tertiary)', borderRadius: 8, border: '1px solid ' + (fullAccess ? 'var(--success, #15803d)' : 'var(--border)') } },
|
|
129
|
+
h('div', null,
|
|
130
|
+
h('strong', { style: { fontSize: 13 } }, 'Full Access'),
|
|
131
|
+
h('div', { style: { fontSize: 11, color: 'var(--text-muted)', marginTop: 2 } }, 'Owner and Admin roles always have full access')
|
|
132
|
+
),
|
|
133
|
+
h('input', { type: 'checkbox', checked: fullAccess, onChange: toggleFullAccess, style: Object.assign({}, _checkbox, { width: 20, height: 20 }) })
|
|
134
|
+
),
|
|
135
|
+
|
|
136
|
+
// Quick actions
|
|
137
|
+
!fullAccess && h('div', { style: { display: 'flex', gap: 8, marginBottom: 12 } },
|
|
138
|
+
h('button', { className: 'btn btn-ghost btn-sm', onClick: selectAll, style: { fontSize: 11 } }, 'Select All'),
|
|
139
|
+
h('button', { className: 'btn btn-ghost btn-sm', onClick: selectNone, style: { fontSize: 11 } }, 'Select None')
|
|
140
|
+
),
|
|
141
|
+
|
|
142
|
+
// Page/tab list grouped by section
|
|
143
|
+
!fullAccess && h('div', { style: { maxHeight: 400, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 8 } },
|
|
144
|
+
Object.keys(sections).map(function(sectionKey) {
|
|
145
|
+
var pageIds = sections[sectionKey];
|
|
146
|
+
if (pageIds.length === 0) return null;
|
|
147
|
+
return h(Fragment, { key: sectionKey },
|
|
148
|
+
h('div', { style: _sectionTitle }, sectionLabels[sectionKey]),
|
|
149
|
+
pageIds.map(function(pid) {
|
|
150
|
+
var page = pageRegistry[pid];
|
|
151
|
+
var hasTabs = page.tabs && Object.keys(page.tabs).length > 0;
|
|
152
|
+
var isExpanded = expandedPage === pid;
|
|
153
|
+
var checked = isPageChecked(pid);
|
|
154
|
+
var tabCount = hasTabs ? Object.keys(page.tabs).length : 0;
|
|
155
|
+
var checkedTabCount = 0;
|
|
156
|
+
if (hasTabs && checked) {
|
|
157
|
+
if (grants[pid] === true) checkedTabCount = tabCount;
|
|
158
|
+
else if (Array.isArray(grants[pid])) checkedTabCount = grants[pid].length;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return h(Fragment, { key: pid },
|
|
162
|
+
h('div', {
|
|
163
|
+
style: Object.assign({}, _cs, checked ? { background: 'var(--bg-tertiary)' } : {}),
|
|
164
|
+
onClick: function(e) {
|
|
165
|
+
// Don't toggle page when clicking the expand arrow
|
|
166
|
+
if (e.target.tagName === 'svg' || e.target.tagName === 'path' || e.target.closest?.('[data-expand]')) return;
|
|
167
|
+
togglePage(pid);
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
h('input', { type: 'checkbox', checked: checked, readOnly: true, style: _checkbox }),
|
|
171
|
+
h('div', { style: { flex: 1 } },
|
|
172
|
+
h('div', { style: { fontWeight: 500 } }, page.label),
|
|
173
|
+
page.description && h('div', { style: { fontSize: 11, color: 'var(--text-muted)', marginTop: 1 } }, page.description)
|
|
174
|
+
),
|
|
175
|
+
hasTabs && h('div', { style: { display: 'flex', alignItems: 'center', gap: 6 } },
|
|
176
|
+
checked && h('span', { style: { fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap' } },
|
|
177
|
+
checkedTabCount + '/' + tabCount + ' tabs'
|
|
178
|
+
),
|
|
179
|
+
h('button', {
|
|
180
|
+
'data-expand': true,
|
|
181
|
+
className: 'btn btn-ghost btn-sm',
|
|
182
|
+
style: { padding: '2px 4px', minWidth: 0 },
|
|
183
|
+
onClick: function(e) { e.stopPropagation(); setExpandedPage(isExpanded ? null : pid); }
|
|
184
|
+
}, isExpanded ? I.chevronDown() : I.chevronRight())
|
|
185
|
+
)
|
|
186
|
+
),
|
|
187
|
+
// Expanded tabs
|
|
188
|
+
hasTabs && isExpanded && checked && Object.keys(page.tabs).map(function(tabId) {
|
|
189
|
+
return h('div', {
|
|
190
|
+
key: tabId,
|
|
191
|
+
style: _tabRow,
|
|
192
|
+
onClick: function() { toggleTab(pid, tabId); }
|
|
193
|
+
},
|
|
194
|
+
h('input', { type: 'checkbox', checked: isTabChecked(pid, tabId), readOnly: true, style: _checkbox }),
|
|
195
|
+
h('span', null, page.tabs[tabId])
|
|
196
|
+
);
|
|
197
|
+
})
|
|
198
|
+
);
|
|
199
|
+
})
|
|
200
|
+
);
|
|
201
|
+
})
|
|
202
|
+
),
|
|
203
|
+
|
|
204
|
+
// Summary
|
|
205
|
+
h('div', { style: { marginTop: 12, padding: 8, background: 'var(--bg-tertiary)', borderRadius: 6, fontSize: 11, color: 'var(--text-muted)' } },
|
|
206
|
+
fullAccess
|
|
207
|
+
? 'This user has full access to all pages and tabs.'
|
|
208
|
+
: 'Access to ' + Object.keys(grants).length + ' of ' + Object.keys(pageRegistry).length + ' pages.'
|
|
209
|
+
)
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ─── Users Page ────────────────────────────────────
|
|
214
|
+
|
|
6
215
|
export function UsersPage() {
|
|
7
216
|
var { toast } = useApp();
|
|
8
217
|
var [users, setUsers] = useState([]);
|
|
9
218
|
var [creating, setCreating] = useState(false);
|
|
10
219
|
var [form, setForm] = useState({ email: '', password: '', name: '', role: 'viewer' });
|
|
11
|
-
var [resetTarget, setResetTarget] = useState(null);
|
|
220
|
+
var [resetTarget, setResetTarget] = useState(null);
|
|
12
221
|
var [newPassword, setNewPassword] = useState('');
|
|
13
222
|
var [resetting, setResetting] = useState(false);
|
|
223
|
+
var [permTarget, setPermTarget] = useState(null); // user object for permission editing
|
|
224
|
+
var [permGrants, setPermGrants] = useState('*'); // current permissions for target
|
|
225
|
+
var [pageRegistry, setPageRegistry] = useState(null); // page/tab registry from backend
|
|
14
226
|
|
|
15
227
|
var load = function() { apiCall('/users').then(function(d) { setUsers(d.users || d || []); }).catch(function() {}); };
|
|
16
|
-
useEffect(function() {
|
|
228
|
+
useEffect(function() {
|
|
229
|
+
load();
|
|
230
|
+
apiCall('/page-registry').then(function(d) { setPageRegistry(d); }).catch(function() {});
|
|
231
|
+
}, []);
|
|
17
232
|
|
|
18
233
|
var create = async function() {
|
|
19
234
|
try {
|
|
@@ -50,6 +265,23 @@ export function UsersPage() {
|
|
|
50
265
|
} catch (e) { toast(e.message, 'error'); }
|
|
51
266
|
};
|
|
52
267
|
|
|
268
|
+
var openPermissions = async function(user) {
|
|
269
|
+
try {
|
|
270
|
+
var d = await apiCall('/users/' + user.id + '/permissions');
|
|
271
|
+
setPermGrants(d.permissions || '*');
|
|
272
|
+
setPermTarget(user);
|
|
273
|
+
} catch (e) { toast('Failed to load permissions', 'error'); }
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
var savePermissions = async function(newPerms) {
|
|
277
|
+
try {
|
|
278
|
+
await apiCall('/users/' + permTarget.id + '/permissions', { method: 'PUT', body: JSON.stringify({ permissions: newPerms }) });
|
|
279
|
+
toast('Permissions updated for ' + (permTarget.name || permTarget.email), 'success');
|
|
280
|
+
setPermTarget(null);
|
|
281
|
+
load();
|
|
282
|
+
} catch (e) { toast(e.message, 'error'); throw e; }
|
|
283
|
+
};
|
|
284
|
+
|
|
53
285
|
var generatePassword = function() {
|
|
54
286
|
var chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%';
|
|
55
287
|
var pw = '';
|
|
@@ -57,20 +289,36 @@ export function UsersPage() {
|
|
|
57
289
|
setNewPassword(pw);
|
|
58
290
|
};
|
|
59
291
|
|
|
292
|
+
// Permission badge for display
|
|
293
|
+
var permBadge = function(user) {
|
|
294
|
+
if (user.role === 'owner' || user.role === 'admin') {
|
|
295
|
+
return h('span', { className: 'badge badge-success', style: { fontSize: 10 } }, 'Full');
|
|
296
|
+
}
|
|
297
|
+
var p = user.permissions;
|
|
298
|
+
if (!p || p === '*' || p === '"*"') return h('span', { className: 'badge badge-success', style: { fontSize: 10 } }, 'Full');
|
|
299
|
+
try {
|
|
300
|
+
var parsed = typeof p === 'string' ? JSON.parse(p) : p;
|
|
301
|
+
if (parsed === '*') return h('span', { className: 'badge badge-success', style: { fontSize: 10 } }, 'Full');
|
|
302
|
+
var count = Object.keys(parsed).length;
|
|
303
|
+
var total = pageRegistry ? Object.keys(pageRegistry).length : '?';
|
|
304
|
+
return h('span', { className: 'badge badge-warning', style: { fontSize: 10 } }, count + '/' + total + ' pages');
|
|
305
|
+
} catch { return h('span', { className: 'badge badge-neutral', style: { fontSize: 10 } }, 'Custom'); }
|
|
306
|
+
};
|
|
307
|
+
|
|
60
308
|
return h(Fragment, null,
|
|
61
309
|
h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 } },
|
|
62
310
|
h('div', null, h('h1', { style: { fontSize: 20, fontWeight: 700, display: 'flex', alignItems: 'center' } }, 'Users', h(HelpButton, { label: 'Users' },
|
|
63
311
|
h('p', null, 'Manage dashboard users who can access and administer the AgenticMail Enterprise console. Each user has a role that controls what they can see and do.'),
|
|
64
312
|
h('h4', { style: { marginTop: 16, marginBottom: 8, fontSize: 14 } }, 'Roles'),
|
|
65
313
|
h('ul', { style: { paddingLeft: 20, margin: '4px 0 8px' } },
|
|
66
|
-
h('li', null, h('strong', null, 'Owner'), ' — Full access
|
|
67
|
-
h('li', null, h('strong', null, 'Admin'), ' —
|
|
68
|
-
h('li', null, h('strong', null, 'Member'), ' —
|
|
69
|
-
h('li', null, h('strong', null, 'Viewer'), ' — Read-only
|
|
314
|
+
h('li', null, h('strong', null, 'Owner'), ' — Full access to everything. Cannot be restricted.'),
|
|
315
|
+
h('li', null, h('strong', null, 'Admin'), ' — Full access to everything. Cannot be restricted.'),
|
|
316
|
+
h('li', null, h('strong', null, 'Member'), ' — Access based on page permissions. Set via the shield icon.'),
|
|
317
|
+
h('li', null, h('strong', null, 'Viewer'), ' — Read-only. Access based on page permissions.')
|
|
70
318
|
),
|
|
71
|
-
h('h4', { style: { marginTop: 16, marginBottom: 8, fontSize: 14 } }, '
|
|
72
|
-
h('p', null, '
|
|
73
|
-
h('div', { style: { marginTop: 12, padding: 12, background: 'var(--bg-secondary, #1e293b)', borderRadius: 'var(--radius, 8px)', fontSize: 13 } }, h('strong', null, 'Tip: '), '
|
|
319
|
+
h('h4', { style: { marginTop: 16, marginBottom: 8, fontSize: 14 } }, 'Page Permissions'),
|
|
320
|
+
h('p', null, 'Click the shield icon on a Member or Viewer to control which pages and tabs they can see. Pages with tabs (like Agents) allow tab-level control.'),
|
|
321
|
+
h('div', { style: { marginTop: 12, padding: 12, background: 'var(--bg-secondary, #1e293b)', borderRadius: 'var(--radius, 8px)', fontSize: 13 } }, h('strong', null, 'Tip: '), 'Owner and Admin users always have full access — permissions only apply to Member and Viewer roles.')
|
|
74
322
|
)), h('p', { style: { color: 'var(--text-muted)', fontSize: 13 } }, 'Manage team members and their access')),
|
|
75
323
|
h('button', { className: 'btn btn-primary', onClick: function() { setCreating(true); } }, I.plus(), ' Add User')
|
|
76
324
|
),
|
|
@@ -80,7 +328,10 @@ export function UsersPage() {
|
|
|
80
328
|
h('div', { className: 'form-group' }, h('label', { className: 'form-label' }, 'Name'), h('input', { className: 'input', value: form.name, onChange: function(e) { setForm(function(f) { return Object.assign({}, f, { name: e.target.value }); }); } })),
|
|
81
329
|
h('div', { className: 'form-group' }, h('label', { className: 'form-label' }, 'Email *'), h('input', { className: 'input', type: 'email', value: form.email, onChange: function(e) { setForm(function(f) { return Object.assign({}, f, { email: e.target.value }); }); } })),
|
|
82
330
|
h('div', { className: 'form-group' }, h('label', { className: 'form-label' }, 'Password *'), h('input', { className: 'input', type: 'password', value: form.password, onChange: function(e) { setForm(function(f) { return Object.assign({}, f, { password: e.target.value }); }); } })),
|
|
83
|
-
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')))
|
|
331
|
+
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'))),
|
|
332
|
+
(form.role === 'member' || form.role === 'viewer') && h('div', { style: { marginTop: 8, padding: 10, background: 'var(--info-soft)', borderRadius: 'var(--radius)', fontSize: 12, color: 'var(--info)' } },
|
|
333
|
+
'After creating this user, click the shield icon to set their page permissions. By default, new Member/Viewer users have full access.'
|
|
334
|
+
)
|
|
84
335
|
),
|
|
85
336
|
|
|
86
337
|
// Reset password modal
|
|
@@ -93,7 +344,7 @@ export function UsersPage() {
|
|
|
93
344
|
)
|
|
94
345
|
},
|
|
95
346
|
h('div', { style: { marginBottom: 16 } },
|
|
96
|
-
h('p', { style: { fontSize: 13, color: 'var(--text-secondary)' } }, 'Set a new password for ', h('strong', null, resetTarget.name || resetTarget.email), '.
|
|
347
|
+
h('p', { style: { fontSize: 13, color: 'var(--text-secondary)' } }, 'Set a new password for ', h('strong', null, resetTarget.name || resetTarget.email), '.'),
|
|
97
348
|
resetTarget.totpEnabled && h('div', { style: { marginTop: 8, padding: 8, background: 'var(--info-soft)', borderRadius: 'var(--radius)', fontSize: 12, color: 'var(--info)' } }, 'This user has 2FA enabled. Password reset will not affect their 2FA setup.')
|
|
98
349
|
),
|
|
99
350
|
h('div', { className: 'form-group' },
|
|
@@ -104,25 +355,43 @@ export function UsersPage() {
|
|
|
104
355
|
)
|
|
105
356
|
),
|
|
106
357
|
newPassword && h('div', { style: { marginTop: 8, padding: 8, background: 'var(--bg-tertiary)', borderRadius: 'var(--radius)', fontSize: 12, color: 'var(--text-muted)' } },
|
|
107
|
-
'Make sure to share this password securely with the user.
|
|
358
|
+
'Make sure to share this password securely with the user.'
|
|
108
359
|
)
|
|
109
360
|
),
|
|
110
361
|
|
|
362
|
+
// Permission editor modal
|
|
363
|
+
permTarget && pageRegistry && h(PermissionEditor, {
|
|
364
|
+
userId: permTarget.id,
|
|
365
|
+
userName: permTarget.name || permTarget.email,
|
|
366
|
+
currentPerms: permGrants,
|
|
367
|
+
pageRegistry: pageRegistry,
|
|
368
|
+
onSave: savePermissions,
|
|
369
|
+
onClose: function() { setPermTarget(null); }
|
|
370
|
+
}),
|
|
371
|
+
|
|
111
372
|
// Users table
|
|
112
373
|
h('div', { className: 'card' },
|
|
113
374
|
h('div', { className: 'card-body-flush' },
|
|
114
375
|
users.length === 0 ? h('div', { style: { padding: 24, textAlign: 'center', color: 'var(--text-muted)' } }, 'No users')
|
|
115
376
|
: h('table', null,
|
|
116
|
-
h('thead', null, h('tr', null, h('th', null, 'Name'), h('th', null, 'Email'), h('th', null, 'Role'), h('th', null, '2FA'), h('th', null, 'Created'), h('th', { style: { width:
|
|
377
|
+
h('thead', null, h('tr', null, h('th', null, 'Name'), h('th', null, 'Email'), h('th', null, 'Role'), h('th', null, 'Access'), h('th', null, '2FA'), h('th', null, 'Created'), h('th', { style: { width: 180 } }, 'Actions'))),
|
|
117
378
|
h('tbody', null, users.map(function(u) {
|
|
379
|
+
var isRestricted = u.role === 'member' || u.role === 'viewer';
|
|
118
380
|
return h('tr', { key: u.id },
|
|
119
381
|
h('td', null, h('strong', null, u.name || '-')),
|
|
120
382
|
h('td', null, h('span', { style: { fontFamily: 'var(--font-mono)', fontSize: 12 } }, u.email)),
|
|
121
383
|
h('td', null, h('span', { className: 'badge badge-' + (u.role === 'owner' ? 'warning' : u.role === 'admin' ? 'primary' : 'neutral') }, u.role)),
|
|
384
|
+
h('td', null, permBadge(u)),
|
|
122
385
|
h('td', null, u.totpEnabled ? h('span', { className: 'badge badge-success' }, 'On') : h('span', { className: 'badge badge-neutral' }, 'Off')),
|
|
123
386
|
h('td', { style: { fontSize: 12, color: 'var(--text-muted)' } }, u.createdAt ? new Date(u.createdAt).toLocaleDateString() : '-'),
|
|
124
387
|
h('td', null,
|
|
125
388
|
h('div', { style: { display: 'flex', gap: 4 } },
|
|
389
|
+
h('button', {
|
|
390
|
+
className: 'btn btn-ghost btn-sm',
|
|
391
|
+
title: isRestricted ? 'Edit Permissions' : 'Permissions (Owner/Admin have full access)',
|
|
392
|
+
onClick: function() { openPermissions(u); },
|
|
393
|
+
style: !isRestricted ? { opacity: 0.4 } : {}
|
|
394
|
+
}, I.shield()),
|
|
126
395
|
h('button', { className: 'btn btn-ghost btn-sm', title: 'Reset Password', onClick: function() { setResetTarget(u); setNewPassword(''); } }, I.lock()),
|
|
127
396
|
h('button', { className: 'btn btn-ghost btn-sm', title: 'Delete User', onClick: function() { deleteUser(u); }, style: { color: 'var(--danger)' } }, I.trash())
|
|
128
397
|
)
|
package/src/db/adapter.ts
CHANGED
|
@@ -65,6 +65,7 @@ export interface User {
|
|
|
65
65
|
totpSecret?: string; // Base32-encoded TOTP secret (encrypted)
|
|
66
66
|
totpEnabled?: boolean; // Whether 2FA is active
|
|
67
67
|
totpBackupCodes?: string; // JSON array of hashed backup codes
|
|
68
|
+
permissions?: any; // '*' or { pageId: true | string[] }
|
|
68
69
|
createdAt: Date;
|
|
69
70
|
updatedAt: Date;
|
|
70
71
|
lastLoginAt?: Date;
|
package/src/db/postgres.ts
CHANGED
|
@@ -189,6 +189,7 @@ export class PostgresAdapter extends DatabaseAdapter {
|
|
|
189
189
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret TEXT;
|
|
190
190
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled BOOLEAN DEFAULT FALSE;
|
|
191
191
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_backup_codes TEXT;
|
|
192
|
+
ALTER TABLE users ADD COLUMN IF NOT EXISTS permissions JSONB DEFAULT '"*"';
|
|
192
193
|
`).catch(() => {});
|
|
193
194
|
await client.query('COMMIT');
|
|
194
195
|
} catch (err) {
|
|
@@ -705,6 +706,7 @@ export class PostgresAdapter extends DatabaseAdapter {
|
|
|
705
706
|
id: r.id, email: r.email, name: r.name, role: r.role,
|
|
706
707
|
passwordHash: r.password_hash, ssoProvider: r.sso_provider, ssoSubject: r.sso_subject,
|
|
707
708
|
totpSecret: r.totp_secret, totpEnabled: !!r.totp_enabled, totpBackupCodes: r.totp_backup_codes,
|
|
709
|
+
permissions: r.permissions != null ? (typeof r.permissions === 'string' ? JSON.parse(r.permissions) : r.permissions) : '*',
|
|
708
710
|
createdAt: new Date(r.created_at), updatedAt: new Date(r.updated_at),
|
|
709
711
|
lastLoginAt: r.last_login_at ? new Date(r.last_login_at) : undefined,
|
|
710
712
|
};
|
package/src/db/sqlite.ts
CHANGED
|
@@ -59,6 +59,10 @@ export class SqliteAdapter extends DatabaseAdapter {
|
|
|
59
59
|
this.db.prepare(
|
|
60
60
|
`INSERT OR IGNORE INTO company_settings (id, name, subdomain) VALUES ('default', 'My Company', 'my-company')`
|
|
61
61
|
).run();
|
|
62
|
+
// Add permissions column if missing
|
|
63
|
+
try {
|
|
64
|
+
this.db.exec(`ALTER TABLE users ADD COLUMN permissions TEXT DEFAULT '"*"'`);
|
|
65
|
+
} catch { /* column already exists */ }
|
|
62
66
|
});
|
|
63
67
|
tx();
|
|
64
68
|
}
|