@xeplr/ui-account 1.0.9 → 1.0.10
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/README.md +25 -11
- package/package.json +1 -1
- package/src/accessStates.js +95 -0
- package/src/adminApi.js +23 -0
- package/src/adminPaths.js +10 -0
- package/src/authRoutes.jsx +11 -4
- package/src/designs/AccessMatrixSample.jsx +85 -24
- package/src/designs/AdminTabs.jsx +22 -0
- package/src/designs/MasterSettingsSample.jsx +33 -4
- package/src/designs/UserRolesMatrixSample.jsx +19 -1
- package/src/designs/admin.css +112 -0
- package/src/designs/index.js +1 -0
- package/src/index.js +1 -0
- package/src/masterApi.js +12 -0
- package/src/useAccessMatrixController.js +167 -41
- package/src/useMasterSettingsController.js +32 -1
- package/src/useUserRolesController.js +26 -0
- package/src/validateDesign.js +4 -2
package/README.md
CHANGED
|
@@ -54,7 +54,7 @@ createRoot(document.getElementById('root')).render(
|
|
|
54
54
|
|
|
55
55
|
```css
|
|
56
56
|
.app { min-height: 100vh; background: var(--xeplr-bg-primary); color: var(--xeplr-text-primary); }
|
|
57
|
-
.app-main { padding-left:
|
|
57
|
+
.app-main { padding-left: 48px; } /* room for the collapsed rail */
|
|
58
58
|
```
|
|
59
59
|
|
|
60
60
|
Link to auth pages with `authPath(key)` rather than a literal URL: `<Link to={authPath('profile')}>`.
|
|
@@ -63,7 +63,7 @@ Link to auth pages with `authPath(key)` rather than a literal URL: `<Link to={au
|
|
|
63
63
|
|
|
64
64
|
- **`<ThemeProvider>` is required.** `theme.css` defines every `--xeplr-*` variable only inside the `.xeplr-theme-dark`, `-light`, `-medium` and `-bright` classes — there is no `:root` fallback. Without a theme class above them, every screen renders unstyled and nothing reports an error. `ThemeProvider` renders that class on a wrapper `<div>` (you may instead put the class on a container yourself).
|
|
65
65
|
- **Set the page background on a container inside the theme wrapper, not on `body`.** `body` is outside the wrapper `<div>`, so `var(--xeplr-bg-primary)` there resolves to nothing.
|
|
66
|
-
- **Reserve room for the rail.** The drawer is `position: fixed` at the top-left, full height, `z-index: 500`,
|
|
66
|
+
- **Reserve room for the rail.** The drawer is `position: fixed` at the top-left, full height, `z-index: 500`, 48px wide when collapsed (compact, like VS Code's activity bar). It never pushes content, so the app must leave that space (e.g. `padding-left: 48px`). Expanded, it overlays the page. A full-screen modal must stack above 500 or the rail shows through it.
|
|
67
67
|
- **The rail only renders when the person may see at least one drawer item.** If none of `drawerItems` survives the access filter, `NavPage` renders the top bar instead (in normal flow, not fixed). If that can happen in your app, decide the padding with `labelMenuItems(drawerItems, access).length > 0`.
|
|
68
68
|
|
|
69
69
|
### Themes
|
|
@@ -103,7 +103,8 @@ authRoutes({
|
|
|
103
103
|
register: false, // drop the route
|
|
104
104
|
profile: <MyProfile />, // full replace (same as { element: <MyProfile /> })
|
|
105
105
|
userRoles: { element: <ProtectedRoute roles={['Super Admin']}><UserRolesPage /></ProtectedRoute> },
|
|
106
|
-
forgotPassword: { path: '/forgot', design: MyForgot } // mount at another path
|
|
106
|
+
forgotPassword: { path: '/forgot', design: MyForgot }, // mount at another path
|
|
107
|
+
accessMatrix: { props: { loadWorkspaces } } // props for the page's controller
|
|
107
108
|
}, { layout: <Shell />, loginPath: '/auth/login', raw: true })
|
|
108
109
|
```
|
|
109
110
|
|
|
@@ -201,8 +202,8 @@ Every reply must be JSON: an empty body (a `204`, say) throws too. Each failure
|
|
|
201
202
|
```jsx
|
|
202
203
|
const navigate = useNavigate()
|
|
203
204
|
const drawerItems = [
|
|
204
|
-
{ key: 'Tasks', icon: <TaskIcon />, clickHandler: () => navigate('/tasks') },
|
|
205
|
-
{ key: 'Reports', icon: <ReportIcon />, clickHandler: () => navigate('/reports'), group: 'Insights', badge: 3 },
|
|
205
|
+
{ key: 'Tasks', icon: <TaskIcon />, path: '/tasks', clickHandler: () => navigate('/tasks') },
|
|
206
|
+
{ key: 'Reports', icon: <ReportIcon />, path: '/reports', clickHandler: () => navigate('/reports'), group: 'Insights', badge: 3 },
|
|
206
207
|
]
|
|
207
208
|
const settingsOverrides = [{ key: 'Admin', path: '/admin' }]
|
|
208
209
|
```
|
|
@@ -210,6 +211,8 @@ const settingsOverrides = [{ key: 'Admin', path: '/admin' }]
|
|
|
210
211
|
- **An unknown key is dropped silently.** A key that is not in `access.menus` — not seeded, not granted to this role, hidden, or misspelled — simply does not render, with no warning. Seed the menu row in `@xeplr/auth` and grant it before looking anywhere else.
|
|
211
212
|
- **Give every drawer item an `icon`.** The collapsed rail shows icons only; an item without one is an empty button there.
|
|
212
213
|
- `group` is a section header, written in code and shown as is. Ungrouped items come first.
|
|
214
|
+
- `path` marks the item as **the current page** (accent icon on a tinted tile, `aria-current="page"`) when the URL is that path or under it — `/tasks` stays marked on `/tasks/12`. `active: true|false` overrides it. An item with neither is never marked. Give every item one: a rail that does not say where you are makes every click feel unconfirmed.
|
|
215
|
+
- Every rail control (links, logo, bell, settings) is a 36px tile with a hover, a visible press (`:active` shrink), and a keyboard-only focus ring; settings stays pressed while its menu is open.
|
|
213
216
|
- `badge` (number or short string) shows as a pill when expanded and a dot on the icon when collapsed; `0`, `''` and `null` show nothing.
|
|
214
217
|
|
|
215
218
|
`labelMenuItems(catalog, access)` is the pure function behind this — it returns the items the person may see, each with `key` and `label`, in the server's order.
|
|
@@ -240,7 +243,7 @@ Exactly one of two things renders: with at least one visible drawer item, the **
|
|
|
240
243
|
|
|
241
244
|
| prop | applies to | meaning |
|
|
242
245
|
|---|---|---|
|
|
243
|
-
| `drawerItems` | drawer | `[{ key, icon, clickHandler, group?, badge? }]` — a non-empty visible list turns the drawer on |
|
|
246
|
+
| `drawerItems` | drawer | `[{ key, icon, clickHandler, path?, active?, group?, badge? }]` — a non-empty visible list turns the drawer on |
|
|
244
247
|
| `settingsOverrides` | both | `[{ key, path }]`, shown above the built-in items in the settings menu |
|
|
245
248
|
| `notifications` | both | `{ count, onClick }` — the bell shows only when this is given **and** `access.menus` contains `Notifications`; a count above 9 shows `9+` |
|
|
246
249
|
| `logo` | both | image URL: the top bar's logo, the collapsed rail's toggle |
|
|
@@ -297,15 +300,21 @@ Call `registerMTs(slots)` once at boot with **the same shape** passed to `@xeplr
|
|
|
297
300
|
| `useActivateController` | — | `token, error, success, loading` |
|
|
298
301
|
| `useChangePasswordController` | `onSuccess` | `currentPassword, setCurrentPassword, newPassword, setNewPassword, confirmPassword, setConfirmPassword, error, success, loading, handleSubmit` |
|
|
299
302
|
| `useProfileController` | `onSuccess` | `form, error, success, loading, fetching, handleChange, handleSubmit` |
|
|
300
|
-
| `useUserRolesController` | — | `users, roles, search, setSearch, loading, error, saving, handleToggle, isAssigned, reload` |
|
|
301
|
-
| `useAccessMatrixController` |
|
|
302
|
-
| `useMasterSettingsController` | — | `tabs, activeTab, setActiveTab, currentTab, items, groupNames, search, setSearch, loading, error, saving, editingItem, editForm, startAdd, startEdit, cancelEdit, updateField, handleSave, handleDelete, reload` |
|
|
303
|
+
| `useUserRolesController` | — | `users, roles, search, setSearch, loading, error, saving, handleToggle, isAssigned, reload, newRoleName, setNewRoleName, creatingRole, createRole` |
|
|
304
|
+
| `useAccessMatrixController` | `workspaces` or `loadWorkspaces` | `roles, modules, uncategorized, uncategorizedCount, uncatSubTab, setUncatSubTab, activeView, setActiveView, search, setSearch, loading, error, handleStateChange, handleModuleToggle, handleItemToggle, isItemAssigned, isModuleSaving, isItemSaving, reload, appliesTo, setAppliesTo, scopeId, setScopeId, workspaces, users, statesSupported` |
|
|
305
|
+
| `useMasterSettingsController` | — | `tabs, activeTab, setActiveTab, currentTab, items, groupNames, search, setSearch, loading, error, saving, editingItem, editForm, startAdd, startEdit, cancelEdit, updateField, handleSave, handleDelete, reload, isSuperAdmin, handleScopeToggle, isScopeSaving` |
|
|
303
306
|
| `useNavController` | `drawerItems, settingsOverrides, notifications` | `user, accountItems, notifications, logout, accountOpen, toggleAccount, closeAccount, accountRef, drawerOpen, toggleDrawer, closeDrawer, drawerItems, drawerWidth, drawerResizing, startDrawerResize, resetDrawerWidth, nudgeDrawerWidth, drawerWidthBounds` |
|
|
304
307
|
|
|
305
308
|
- Login stores the tokens and calls `AccessProvider`'s `onLogin` when a provider is present. A login refused with "Please wait, someone will activate you." navigates to `notActivatedPath` (default `/auth/not-activated`). Supplying `onSuccess` replaces the default navigation entirely.
|
|
306
309
|
- Register, profile: `handleChange` reads `e.target.name`, so inputs need `name="email"` etc. `form` is `{ name, email, phoneNumber, password }` / `{ name, email, phoneNumber }`.
|
|
307
310
|
- Reset and activate read `?token=` (activate also passes `?workflowKey=` back to the server) and activate runs on mount.
|
|
308
311
|
- The access matrix groups items whose group is `module:action` (e.g. `account:view`) under Modules; the rest are Uncategorized. The Super Admin role is left out of its columns.
|
|
312
|
+
- **Super Admin only APIs.** On Master Settings → APIs, a Super Admin sees a "Super Admin only" switch per API (`handleScopeToggle`). Nobody else sees the column. An API pinned by a migration shows ticked and cannot be switched off, and a Super Admin only API cannot be renamed or deleted there. `isSuperAdmin` comes from `AccessProvider`'s `hasRole`.
|
|
313
|
+
- **Admin view tabs.** User Roles, Access Matrix and Master Settings each show a tab bar across the top (`AdminTabs`), so the three are one click apart. The pages and their paths are one list, `ADMIN_PAGES` (`adminPaths.js`), which the routes use too.
|
|
314
|
+
- **Adding a role.** The User Roles page has a New role field (`createRole`). The role appears as a column there and in the Access Matrix straight away. The server allows it for Super Admin only; anyone else sees its refusal.
|
|
315
|
+
- **Access states.** Each Modules cell is three-way, not a checkbox: **Enabled** (shown and usable), **Disabled** (shown, greyed out, not usable) or **Hidden** (not shown). A role only partly granted shows **Mixed**. `handleStateChange(module, action, roleId, state)` saves a change; `handleModuleToggle` still works for designs written before.
|
|
316
|
+
- **Applies to: Roles, Workspace or User.** Roles are the defaults. A workspace or a user can override them per module and action, and an override may be **Inherit** (no override). Workspaces belong to the host product, not the auth service, so the host passes them: `authRoutes({ accessMatrix: { props: { loadWorkspaces } } })`, or `workspaces` directly.
|
|
317
|
+
- **Server support.** Enabled and Hidden on roles are role mappings (`module-role`) and work today. Disabled, and every workspace or user override, are stored states that need `GET /auth/api/admin/module-states` and `POST /auth/api/admin/module-state` in `@xeplr/auth`. Until those exist the matrix says so and refuses to save them, rather than pretending they were saved.
|
|
309
318
|
- `MASTER_TYPES` is `['roles', 'apis', 'pages', 'elements', 'menus']`.
|
|
310
319
|
|
|
311
320
|
### Design validation
|
|
@@ -320,8 +329,8 @@ Each page (except activate and not-activated) checks its rendered design three s
|
|
|
320
329
|
| `RESET_PASSWORD_RULES` | `#xeplr-password`, submit |
|
|
321
330
|
| `CHANGE_PASSWORD_RULES` | `#xeplr-current-password`, `#xeplr-new-password`, `#xeplr-confirm-password`, submit |
|
|
322
331
|
| `PROFILE_RULES` | `#xeplr-profile-name`, `#xeplr-profile-email`, submit |
|
|
323
|
-
| `USER_ROLES_MATRIX_RULES` | `#xeplr-admin-user-search`, `[role="grid"]` |
|
|
324
|
-
| `ACCESS_MATRIX_RULES` | `#xeplr-admin-access-search`, `[role="tablist"]` |
|
|
332
|
+
| `USER_ROLES_MATRIX_RULES` | `#xeplr-admin-user-search`, `[role="grid"]`, `#xeplr-admin-new-role` |
|
|
333
|
+
| `ACCESS_MATRIX_RULES` | `#xeplr-admin-access-search`, `[role="tablist"]`, `#xeplr-admin-access-scope` (the Applies-to radiogroup) |
|
|
325
334
|
| `MASTER_SETTINGS_RULES` | `#xeplr-admin-master-search`, `[role="tablist"]` |
|
|
326
335
|
| `NAV_RULES` | `[aria-haspopup="menu"]` |
|
|
327
336
|
|
|
@@ -354,6 +363,9 @@ Admin (auth):
|
|
|
354
363
|
| `toggleUserRole({ userId, roleId, assign })` | `POST /auth/api/admin/user-role` |
|
|
355
364
|
| `toggleAccessRole({ type, itemId, roleId, assign })` | `POST /auth/api/admin/access-role` |
|
|
356
365
|
| `toggleModuleRole({ module, action, roleId, assign })` | `POST /auth/api/admin/module-role` |
|
|
366
|
+
| `getModuleStates({ scope, scopeId })` | `GET /auth/api/admin/module-states?scope=role\|workspace\|user&scopeId=…` → `[{ module, action, roleId?, state }]`. Server support pending |
|
|
367
|
+
| `setModuleState({ scope, scopeId, module, action, roleId, state })` | `POST /auth/api/admin/module-state`. `state` is `enabled` / `disabled` / `hidden`, or `null` to clear. Server support pending |
|
|
368
|
+
| `setApiScope(id, scope)` | `POST /auth/api/admin/master/apis/scope`. Super Admin only; `scope` is `system` or `company` |
|
|
357
369
|
| `getMasterItems(type)`, `saveMasterItem(type, data)`, `deleteMasterItem(type, id)` | `GET` / `POST /auth/api/admin/master/<type>`, `POST …/<type>/delete` |
|
|
358
370
|
| `listMenuItems`, `saveMenuItems`, `addMenuItem`, `removeMenuItem` | see [Changing the menu](#changing-the-menu) |
|
|
359
371
|
|
|
@@ -370,6 +382,8 @@ src/
|
|
|
370
382
|
mt.js, activeScope.js ─ multi-tenant levels and the scope per level
|
|
371
383
|
returnTo.js ─ where to go after a gate
|
|
372
384
|
menuLabels.js ─ labelMenuItems
|
|
385
|
+
adminPaths.js ─ ADMIN_PAGES: the admin pages, their labels and paths
|
|
386
|
+
accessStates.js ─ enabled / disabled / hidden / inherit: cell states and what a change sends
|
|
373
387
|
AccessContext.jsx ─ AccessProvider, useAccess, useAccessStrict
|
|
374
388
|
ProtectedRoute.jsx, AccessGuard.jsx
|
|
375
389
|
ThemeContext.jsx ─ ThemeProvider, useTheme
|
package/package.json
CHANGED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Access states — model layer for the Access Matrix's three-way cells.
|
|
3
|
+
* Pure logic, no React dependency.
|
|
4
|
+
*
|
|
5
|
+
* A module + action (e.g. reports / view) is, for whoever it applies to:
|
|
6
|
+
*
|
|
7
|
+
* enabled shown and usable
|
|
8
|
+
* disabled shown, greyed out, not usable — the API refuses it too
|
|
9
|
+
* hidden not shown — the API refuses it
|
|
10
|
+
*
|
|
11
|
+
* They apply at three levels. ROLES are the defaults. A WORKSPACE or a USER
|
|
12
|
+
* can override them, and an override may also be `inherit`: no override,
|
|
13
|
+
* the roles decide.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export var STATES = ['enabled', 'disabled', 'hidden'];
|
|
17
|
+
export var OVERRIDE_STATES = ['inherit', 'enabled', 'disabled', 'hidden'];
|
|
18
|
+
|
|
19
|
+
export var STATE_LABELS = {
|
|
20
|
+
inherit: 'Inherit',
|
|
21
|
+
enabled: 'Enabled',
|
|
22
|
+
disabled: 'Disabled',
|
|
23
|
+
hidden: 'Hidden',
|
|
24
|
+
partial: 'Mixed'
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export var SCOPES = ['role', 'workspace', 'user'];
|
|
28
|
+
|
|
29
|
+
/** The map key one stored state lives under. roleId only for scope 'role'. */
|
|
30
|
+
export function stateKey(module, action, roleId) {
|
|
31
|
+
return module + ':' + action + (roleId ? ':' + roleId : '');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Stored states, as the server sends them, into a lookup:
|
|
36
|
+
* [{ module, action, roleId?, state }] → { 'reports:view:r1': 'disabled' }
|
|
37
|
+
* Rows with an unknown state are dropped: a value the UI cannot show must not
|
|
38
|
+
* be shown as something else.
|
|
39
|
+
*/
|
|
40
|
+
export function indexStates(rows) {
|
|
41
|
+
var out = {};
|
|
42
|
+
(rows || []).forEach(function(r) {
|
|
43
|
+
if (!r || !r.module || !r.action) return;
|
|
44
|
+
if (STATES.indexOf(r.state) === -1) return;
|
|
45
|
+
out[stateKey(r.module, r.action, r.roleId)] = r.state;
|
|
46
|
+
});
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* What a ROLE cell shows.
|
|
52
|
+
*
|
|
53
|
+
* The role's grant decides visibility: every item of the module/action mapped
|
|
54
|
+
* to the role is `enabled`, none is `hidden`, some is `partial` (a mixed grant,
|
|
55
|
+
* shown as-is so nobody reads it as a clean answer). A stored `disabled` turns
|
|
56
|
+
* a granted cell into `disabled` — shown, but not usable.
|
|
57
|
+
*
|
|
58
|
+
* @param grant 'all' | 'partial' | 'none' (from the role mappings)
|
|
59
|
+
* @param stored the stored state for this role, or undefined
|
|
60
|
+
*/
|
|
61
|
+
export function roleCellState(grant, stored) {
|
|
62
|
+
if (grant === 'none') return 'hidden';
|
|
63
|
+
if (stored === 'disabled') return grant === 'all' ? 'disabled' : 'partial';
|
|
64
|
+
if (grant === 'all') return 'enabled';
|
|
65
|
+
return 'partial';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** What a WORKSPACE or USER cell shows: its override, else inherit. */
|
|
69
|
+
export function overrideCellState(stored) {
|
|
70
|
+
return STATES.indexOf(stored) === -1 ? 'inherit' : stored;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The server calls a role-cell change needs, in order.
|
|
75
|
+
*
|
|
76
|
+
* Visibility is the role mapping (today's module-role endpoint); `disabled` is
|
|
77
|
+
* a stored state on top of it. So:
|
|
78
|
+
* enabled → grant, and clear any stored state
|
|
79
|
+
* disabled → grant, and store 'disabled'
|
|
80
|
+
* hidden → revoke, and clear any stored state
|
|
81
|
+
*
|
|
82
|
+
* @returns [{ call: 'grant'|'revoke'|'setState'|'clearState', state? }]
|
|
83
|
+
*/
|
|
84
|
+
export function roleChangePlan(nextState) {
|
|
85
|
+
if (nextState === 'enabled') return [{ call: 'grant' }, { call: 'clearState' }];
|
|
86
|
+
if (nextState === 'disabled') return [{ call: 'grant' }, { call: 'setState', state: 'disabled' }];
|
|
87
|
+
if (nextState === 'hidden') return [{ call: 'revoke' }, { call: 'clearState' }];
|
|
88
|
+
throw new Error('Unknown access state: ' + nextState);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Does this change need the server's stored states (not just role mappings)? */
|
|
92
|
+
export function needsStoredStates(scope, nextState) {
|
|
93
|
+
if (scope !== 'role') return true;
|
|
94
|
+
return nextState === 'disabled';
|
|
95
|
+
}
|
package/src/adminApi.js
CHANGED
|
@@ -38,6 +38,29 @@ export function toggleModuleRole({ module, action, roleId, assign }) {
|
|
|
38
38
|
});
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// ── Access states: enabled / disabled / hidden, per role, workspace or user ──
|
|
42
|
+
//
|
|
43
|
+
// Server support is being added alongside this UI. Until the auth service has
|
|
44
|
+
// these routes, getModuleStates fails and the Access Matrix says so, while
|
|
45
|
+
// Enabled / Hidden on roles keep working through module-role above.
|
|
46
|
+
|
|
47
|
+
/** Stored states for one scope: [{ module, action, roleId?, state }]. scope: 'role' | 'workspace' | 'user'. */
|
|
48
|
+
export function getModuleStates({ scope, scopeId }) {
|
|
49
|
+
var q = '?scope=' + encodeURIComponent(scope) + (scopeId ? '&scopeId=' + encodeURIComponent(scopeId) : '');
|
|
50
|
+
return authFetch('/auth/api/admin/module-states' + q);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Store or clear one state. state: 'enabled' | 'disabled' | 'hidden', or
|
|
55
|
+
* 'inherit' / null to remove the stored state. roleId only for scope 'role'.
|
|
56
|
+
*/
|
|
57
|
+
export function setModuleState({ scope, scopeId, module, action, roleId, state }) {
|
|
58
|
+
return authFetch('/auth/api/admin/module-state', {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
body: JSON.stringify({ scope, scopeId, module, action, roleId, state }),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
41
64
|
// ── Menu items: key, label, order, visibility (Super Admin) ──────────────
|
|
42
65
|
|
|
43
66
|
/** Every menu item, hidden ones included: [{ name, label, shown, sortOrder, isHidden, isPublic }]. */
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The admin pages, in the order the admin view's tabs show them.
|
|
3
|
+
* One list, read by authRoutes' manifest and by the AdminTabs design, so the
|
|
4
|
+
* tabs and the routes cannot disagree about where a page lives.
|
|
5
|
+
*/
|
|
6
|
+
export var ADMIN_PAGES = [
|
|
7
|
+
{ key: 'userRoles', label: 'User Roles', path: '/auth/admin/user-roles' },
|
|
8
|
+
{ key: 'accessMatrix', label: 'Access Matrix', path: '/auth/admin/access-matrix' },
|
|
9
|
+
{ key: 'masterSettings', label: 'Master Settings', path: '/auth/admin/master-settings' }
|
|
10
|
+
];
|
package/src/authRoutes.jsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ADMIN_PAGES } from './adminPaths.js';
|
|
1
2
|
import { isValidElement } from 'react';
|
|
2
3
|
import { Route } from 'react-router-dom';
|
|
3
4
|
import { ProtectedRoute } from './ProtectedRoute.jsx';
|
|
@@ -23,11 +24,13 @@ var MANIFEST = [
|
|
|
23
24
|
{ key: 'notActivated', path: '/auth/not-activated', Page: NotActivatedPage, group: 'public' },
|
|
24
25
|
{ key: 'profile', path: '/auth/profile', Page: ProfilePage, group: 'account' },
|
|
25
26
|
{ key: 'changePassword', path: '/auth/change-password', Page: ChangePasswordPage, group: 'account' },
|
|
26
|
-
{ key: 'userRoles', path: '
|
|
27
|
-
{ key: 'accessMatrix', path: '
|
|
28
|
-
{ key: 'masterSettings', path: '
|
|
27
|
+
{ key: 'userRoles', path: adminPath('userRoles'), Page: UserRolesPage, group: 'admin' },
|
|
28
|
+
{ key: 'accessMatrix', path: adminPath('accessMatrix'), Page: AccessMatrixPage, group: 'admin' },
|
|
29
|
+
{ key: 'masterSettings', path: adminPath('masterSettings'), Page: MasterSettingsPage, group: 'admin' }
|
|
29
30
|
];
|
|
30
31
|
|
|
32
|
+
function adminPath(key) { return ADMIN_PAGES.find(function (p) { return p.key === key; }).path; }
|
|
33
|
+
|
|
31
34
|
var _pathByKey = {};
|
|
32
35
|
MANIFEST.forEach(function (e) { _pathByKey[e.key] = e.path; });
|
|
33
36
|
|
|
@@ -51,7 +54,9 @@ function resolveElement(entry, ov) {
|
|
|
51
54
|
if (!ov) return <Page />; // framework default
|
|
52
55
|
if (isValidElement(ov)) return ov; // bare element → full replace
|
|
53
56
|
if (ov.element) return ov.element; // { element } → full replace
|
|
54
|
-
if (ov.design
|
|
57
|
+
if (ov.design || ov.props) { // { design, props } → re-skin and/or feed the controller
|
|
58
|
+
return <Page design={ov.design} {...(ov.props || {})} />;
|
|
59
|
+
}
|
|
55
60
|
return <Page />; // e.g. only { path } supplied
|
|
56
61
|
}
|
|
57
62
|
|
|
@@ -67,6 +72,8 @@ function resolveElement(entry, ov) {
|
|
|
67
72
|
* @param {object} [overrides] map of manifest key → how to render it:
|
|
68
73
|
* - { design: MyDesign } re-skin: framework controller + validation, your look
|
|
69
74
|
* (design receives the controller's props)
|
|
75
|
+
* - { props: {...} } pass props to the page's controller, e.g.
|
|
76
|
+
* accessMatrix: { props: { loadWorkspaces } }
|
|
70
77
|
* - { element: <X/> } full replace: your element, framework logic ignored
|
|
71
78
|
* - a React element shorthand for { element }
|
|
72
79
|
* - false | null drop this route (app doesn't want it)
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import './admin.css';
|
|
2
|
+
import AdminTabs from './AdminTabs.jsx';
|
|
3
|
+
import { STATES, OVERRIDE_STATES, STATE_LABELS } from '../accessStates.js';
|
|
2
4
|
|
|
3
|
-
var ACTION_LABELS = { view: 'View Only', edit: 'Add / Edit', delete: 'Delete' };
|
|
4
|
-
var ACTION_CLASSES = { view: 'xeplr-admin-action-view', edit: 'xeplr-admin-action-edit', delete: 'xeplr-admin-action-delete' };
|
|
5
|
+
var ACTION_LABELS = { view: 'View Only', create: 'Create', edit: 'Add / Edit', delete: 'Delete' };
|
|
6
|
+
var ACTION_CLASSES = { view: 'xeplr-admin-action-view', create: 'xeplr-admin-action-edit', edit: 'xeplr-admin-action-edit', delete: 'xeplr-admin-action-delete' };
|
|
5
7
|
var UNCAT_TABS = [
|
|
6
8
|
{ key: 'apis', label: 'APIs' },
|
|
7
9
|
{ key: 'pages', label: 'Pages' },
|
|
@@ -12,11 +14,13 @@ var UNCAT_TABS = [
|
|
|
12
14
|
export default function AccessMatrixSample({
|
|
13
15
|
roles, modules, uncategorized, uncategorizedCount, uncatSubTab, setUncatSubTab,
|
|
14
16
|
activeView, setActiveView, search, setSearch,
|
|
15
|
-
loading, error,
|
|
16
|
-
isItemAssigned, isModuleSaving, isItemSaving, reload
|
|
17
|
+
loading, error, handleStateChange, handleItemToggle,
|
|
18
|
+
isItemAssigned, isModuleSaving, isItemSaving, reload,
|
|
19
|
+
appliesTo, setAppliesTo, scopeId, setScopeId, workspaces, users, statesSupported
|
|
17
20
|
}) {
|
|
18
21
|
return (
|
|
19
22
|
<div className="xeplr-admin-container">
|
|
23
|
+
<AdminTabs />
|
|
20
24
|
<div className="xeplr-admin-header">
|
|
21
25
|
<h2>Access Matrix</h2>
|
|
22
26
|
<button type="button" onClick={reload} className="xeplr-admin-btn-secondary">Refresh</button>
|
|
@@ -43,6 +47,15 @@ export default function AccessMatrixSample({
|
|
|
43
47
|
</button>
|
|
44
48
|
</div>
|
|
45
49
|
|
|
50
|
+
{activeView === 'modules' && renderScopeBar(appliesTo, setAppliesTo, scopeId, setScopeId, workspaces, users)}
|
|
51
|
+
|
|
52
|
+
{activeView === 'modules' && statesSupported === false && (
|
|
53
|
+
<div className="xeplr-admin-alert xeplr-admin-alert-info">
|
|
54
|
+
The server does not store access states yet. Enabled and Hidden on roles work today;
|
|
55
|
+
Disabled, and workspace or user overrides, arrive with the server update.
|
|
56
|
+
</div>
|
|
57
|
+
)}
|
|
58
|
+
|
|
46
59
|
{/* Search */}
|
|
47
60
|
<div className="xeplr-admin-toolbar">
|
|
48
61
|
<input
|
|
@@ -60,7 +73,7 @@ export default function AccessMatrixSample({
|
|
|
60
73
|
{loading ? (
|
|
61
74
|
<div className="xeplr-admin-loading">Loading...</div>
|
|
62
75
|
) : activeView === 'modules' ? (
|
|
63
|
-
renderModulesView(roles, modules,
|
|
76
|
+
renderModulesView(roles, modules, handleStateChange, isModuleSaving, appliesTo, scopeId)
|
|
64
77
|
) : (
|
|
65
78
|
renderUncategorizedView(roles, uncategorized, uncatSubTab, setUncatSubTab, handleItemToggle, isItemAssigned, isItemSaving)
|
|
66
79
|
)}
|
|
@@ -68,10 +81,61 @@ export default function AccessMatrixSample({
|
|
|
68
81
|
);
|
|
69
82
|
}
|
|
70
83
|
|
|
71
|
-
|
|
84
|
+
var SCOPE_OPTIONS = [
|
|
85
|
+
{ key: 'role', label: 'Roles' },
|
|
86
|
+
{ key: 'workspace', label: 'Workspace' },
|
|
87
|
+
{ key: 'user', label: 'User' },
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
function renderScopeBar(appliesTo, setAppliesTo, scopeId, setScopeId, workspaces, users) {
|
|
91
|
+
var list = appliesTo === 'workspace' ? (workspaces || []) : appliesTo === 'user' ? (users || []) : [];
|
|
92
|
+
return (
|
|
93
|
+
<div className="xeplr-admin-scope-bar">
|
|
94
|
+
<span className="xeplr-admin-scope-label">Applies to</span>
|
|
95
|
+
<div id="xeplr-admin-access-scope" role="radiogroup" aria-label="Applies to" className="xeplr-admin-scope-options">
|
|
96
|
+
{SCOPE_OPTIONS.map(function(opt) {
|
|
97
|
+
var on = appliesTo === opt.key;
|
|
98
|
+
return (
|
|
99
|
+
<button key={opt.key} type="button" role="radio" aria-checked={on}
|
|
100
|
+
className={'xeplr-admin-scope-option' + (on ? ' xeplr-admin-scope-option-active' : '')}
|
|
101
|
+
onClick={function() { setAppliesTo(opt.key); }}>
|
|
102
|
+
{opt.label}
|
|
103
|
+
</button>
|
|
104
|
+
);
|
|
105
|
+
})}
|
|
106
|
+
</div>
|
|
107
|
+
{appliesTo !== 'role' && (
|
|
108
|
+
<select id="xeplr-admin-access-scope-target" className="xeplr-admin-scope-select"
|
|
109
|
+
value={scopeId} onChange={function(e) { setScopeId(e.target.value); }}>
|
|
110
|
+
<option value="">{appliesTo === 'workspace' ? 'Choose a workspace…' : 'Choose a user…'}</option>
|
|
111
|
+
{list.map(function(x) {
|
|
112
|
+
return <option key={x.id} value={x.id}>{x.name || x.email || x.id}</option>;
|
|
113
|
+
})}
|
|
114
|
+
</select>
|
|
115
|
+
)}
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function stateSelect(value, options, onChange, disabled, label) {
|
|
121
|
+
return (
|
|
122
|
+
<select aria-label={label} value={value} disabled={disabled}
|
|
123
|
+
className={'xeplr-admin-state-select xeplr-admin-state-' + value}
|
|
124
|
+
onChange={function(e) { onChange(e.target.value); }}>
|
|
125
|
+
{value === 'partial' && <option value="partial" disabled>{STATE_LABELS.partial}</option>}
|
|
126
|
+
{options.map(function(s) { return <option key={s} value={s}>{STATE_LABELS[s]}</option>; })}
|
|
127
|
+
</select>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function renderModulesView(roles, modules, handleStateChange, isModuleSaving, appliesTo, scopeId) {
|
|
72
132
|
if (modules.length === 0) {
|
|
73
133
|
return <div className="xeplr-admin-empty-box">No modules found. Items need a <code>module:action</code> group value to appear here.</div>;
|
|
74
134
|
}
|
|
135
|
+
if (appliesTo !== 'role' && !scopeId) {
|
|
136
|
+
return <div className="xeplr-admin-empty-box">Choose a {appliesTo} above to see and change its overrides.</div>;
|
|
137
|
+
}
|
|
138
|
+
var byRole = appliesTo === 'role';
|
|
75
139
|
|
|
76
140
|
return (
|
|
77
141
|
<div className="xeplr-admin-matrix-wrapper">
|
|
@@ -80,9 +144,9 @@ function renderModulesView(roles, modules, handleModuleToggle, isModuleSaving) {
|
|
|
80
144
|
<tr>
|
|
81
145
|
<th className="xeplr-admin-sticky-col xeplr-admin-module-col">Module</th>
|
|
82
146
|
<th className="xeplr-admin-action-col">Action</th>
|
|
83
|
-
{roles.map(function(role) {
|
|
147
|
+
{byRole ? roles.map(function(role) {
|
|
84
148
|
return <th key={role.id} className="xeplr-admin-role-header">{role.name}</th>;
|
|
85
|
-
})}
|
|
149
|
+
}) : <th className="xeplr-admin-role-header">State</th>}
|
|
86
150
|
</tr>
|
|
87
151
|
</thead>
|
|
88
152
|
<tbody>
|
|
@@ -100,26 +164,23 @@ function renderModulesView(roles, modules, handleModuleToggle, isModuleSaving) {
|
|
|
100
164
|
<td className={'xeplr-admin-action-cell ' + actionClass}>
|
|
101
165
|
{actionLabel}
|
|
102
166
|
</td>
|
|
103
|
-
{roles.map(function(role) {
|
|
104
|
-
var state = actionEntry.getRoleState(role.id);
|
|
105
|
-
var isSaving = isModuleSaving(mod.name, actionEntry.action, role.id);
|
|
167
|
+
{byRole ? roles.map(function(role) {
|
|
106
168
|
return (
|
|
107
169
|
<td key={role.id} className="xeplr-admin-cell">
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
ref={function(el) {
|
|
113
|
-
if (el) el.indeterminate = state === 'partial';
|
|
114
|
-
}}
|
|
115
|
-
disabled={isSaving}
|
|
116
|
-
onChange={function() { handleModuleToggle(mod.name, actionEntry.action, role.id, state); }}
|
|
117
|
-
/>
|
|
118
|
-
<span className={'xeplr-admin-checkmark' + (state === 'partial' ? ' xeplr-admin-partial' : '') + (isSaving ? ' xeplr-admin-saving' : '')} />
|
|
119
|
-
</label>
|
|
170
|
+
{stateSelect(actionEntry.getCellState(role.id), STATES,
|
|
171
|
+
function(next) { handleStateChange(mod.name, actionEntry.action, role.id, next); },
|
|
172
|
+
isModuleSaving(mod.name, actionEntry.action, role.id),
|
|
173
|
+
mod.name + ' ' + actionLabel + ' for ' + role.name)}
|
|
120
174
|
</td>
|
|
121
175
|
);
|
|
122
|
-
})
|
|
176
|
+
}) : (
|
|
177
|
+
<td className="xeplr-admin-cell">
|
|
178
|
+
{stateSelect(actionEntry.getOverrideState(), OVERRIDE_STATES,
|
|
179
|
+
function(next) { handleStateChange(mod.name, actionEntry.action, null, next); },
|
|
180
|
+
isModuleSaving(mod.name, actionEntry.action, null),
|
|
181
|
+
mod.name + ' ' + actionLabel)}
|
|
182
|
+
</td>
|
|
183
|
+
)}
|
|
123
184
|
</tr>
|
|
124
185
|
);
|
|
125
186
|
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { NavLink } from 'react-router-dom';
|
|
2
|
+
import { ADMIN_PAGES } from '../adminPaths.js';
|
|
3
|
+
import './admin.css';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The admin view's own navigation: User Roles · Access Matrix · Master
|
|
7
|
+
* Settings, one click apart. Rendered at the top of each admin page's design.
|
|
8
|
+
*/
|
|
9
|
+
export default function AdminTabs() {
|
|
10
|
+
return (
|
|
11
|
+
<nav className="xeplr-admin-pages" aria-label="Admin pages">
|
|
12
|
+
{ADMIN_PAGES.map(function(page) {
|
|
13
|
+
return (
|
|
14
|
+
<NavLink key={page.key} to={page.path} end
|
|
15
|
+
className={function(s) { return 'xeplr-admin-page-link' + (s.isActive ? ' xeplr-admin-page-link-active' : ''); }}>
|
|
16
|
+
{page.label}
|
|
17
|
+
</NavLink>
|
|
18
|
+
);
|
|
19
|
+
})}
|
|
20
|
+
</nav>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import './admin.css';
|
|
2
|
+
import AdminTabs from './AdminTabs.jsx';
|
|
2
3
|
|
|
3
4
|
export default function MasterSettingsSample({
|
|
4
5
|
tabs, activeTab, setActiveTab, currentTab, items, search, setSearch,
|
|
5
6
|
loading, error, saving, editingItem, editForm, groupNames,
|
|
6
|
-
startAdd, startEdit, cancelEdit, updateField, handleSave, handleDelete, reload
|
|
7
|
+
startAdd, startEdit, cancelEdit, updateField, handleSave, handleDelete, reload,
|
|
8
|
+
isSuperAdmin, handleScopeToggle, isScopeSaving
|
|
7
9
|
}) {
|
|
10
|
+
// The "Super Admin only" column: APIs tab, Super Admin viewers only.
|
|
11
|
+
var showScope = activeTab === 'apis' && isSuperAdmin;
|
|
8
12
|
return (
|
|
9
13
|
<div className="xeplr-admin-container">
|
|
14
|
+
<AdminTabs />
|
|
10
15
|
<div className="xeplr-admin-header">
|
|
11
16
|
<h2>Master Settings</h2>
|
|
12
17
|
<div style={{ display: 'flex', gap: '8px' }}>
|
|
@@ -58,6 +63,7 @@ export default function MasterSettingsSample({
|
|
|
58
63
|
{currentTab.fields.map(function(field) {
|
|
59
64
|
return <th key={field.key} className={field.key === 'name' ? 'xeplr-admin-sticky-col' : ''}>{field.label}</th>;
|
|
60
65
|
})}
|
|
66
|
+
{showScope && <th className="xeplr-admin-scope-col">Super Admin only</th>}
|
|
61
67
|
<th className="xeplr-admin-actions-col">Actions</th>
|
|
62
68
|
</tr>
|
|
63
69
|
</thead>
|
|
@@ -86,7 +92,7 @@ export default function MasterSettingsSample({
|
|
|
86
92
|
)}
|
|
87
93
|
|
|
88
94
|
{items.length === 0 && editingItem !== '__new__' ? (
|
|
89
|
-
<tr><td colSpan={currentTab.fields.length + 1} className="xeplr-admin-empty">No {currentTab.label.toLowerCase()} found</td></tr>
|
|
95
|
+
<tr><td colSpan={currentTab.fields.length + (showScope ? 2 : 1)} className="xeplr-admin-empty">No {currentTab.label.toLowerCase()} found</td></tr>
|
|
90
96
|
) : (
|
|
91
97
|
items.map(function(item) {
|
|
92
98
|
var isEditing = editingItem === item.id;
|
|
@@ -107,6 +113,11 @@ export default function MasterSettingsSample({
|
|
|
107
113
|
</td>
|
|
108
114
|
);
|
|
109
115
|
})}
|
|
116
|
+
{showScope && (
|
|
117
|
+
<td className="xeplr-admin-scope-col">
|
|
118
|
+
{renderScopeSwitch(item, handleScopeToggle, isScopeSaving(item.id))}
|
|
119
|
+
</td>
|
|
120
|
+
)}
|
|
110
121
|
<td className="xeplr-admin-actions-col">
|
|
111
122
|
{isEditing ? (
|
|
112
123
|
<div className="xeplr-admin-action-btns">
|
|
@@ -119,10 +130,14 @@ export default function MasterSettingsSample({
|
|
|
119
130
|
</div>
|
|
120
131
|
) : (
|
|
121
132
|
<div className="xeplr-admin-action-btns">
|
|
122
|
-
<button onClick={function() { startEdit(item); }} className="xeplr-admin-btn-edit"
|
|
133
|
+
<button onClick={function() { startEdit(item); }} className="xeplr-admin-btn-edit"
|
|
134
|
+
title={item.scope === 'system' ? 'A Super Admin only API cannot be renamed' : 'Edit'}
|
|
135
|
+
disabled={editingItem !== null || item.scope === 'system'}>
|
|
123
136
|
{'\u270E'}
|
|
124
137
|
</button>
|
|
125
|
-
<button onClick={function() { if (confirm('Delete this item?')) handleDelete(item.id); }} className="xeplr-admin-btn-delete"
|
|
138
|
+
<button onClick={function() { if (confirm('Delete this item?')) handleDelete(item.id); }} className="xeplr-admin-btn-delete"
|
|
139
|
+
title={item.scope === 'system' ? 'A Super Admin only API cannot be deleted' : 'Delete'}
|
|
140
|
+
disabled={saving || editingItem !== null || item.scope === 'system'}>
|
|
126
141
|
{'\u2715'}
|
|
127
142
|
</button>
|
|
128
143
|
</div>
|
|
@@ -204,3 +219,17 @@ function renderValue(field, value) {
|
|
|
204
219
|
|
|
205
220
|
return <span className={field.key === 'name' ? 'xeplr-admin-item-name' : ''}>{value || '\u2014'}</span>;
|
|
206
221
|
}
|
|
222
|
+
|
|
223
|
+
function renderScopeSwitch(item, onToggle, busy) {
|
|
224
|
+
var on = item.scope === 'system';
|
|
225
|
+
var locked = !!item.scopeLocked;
|
|
226
|
+
return (
|
|
227
|
+
<label className="xeplr-admin-toggle"
|
|
228
|
+
title={locked ? 'Pinned as Super Admin only by a migration' : on ? 'Only Super Admin can use this API' : 'Make this API Super Admin only'}>
|
|
229
|
+
<input type="checkbox" checked={on} disabled={busy || locked}
|
|
230
|
+
aria-label={'Super Admin only: ' + item.name}
|
|
231
|
+
onChange={function() { onToggle(item); }} />
|
|
232
|
+
<span className={'xeplr-admin-checkmark' + (busy ? ' xeplr-admin-saving' : '') + (locked ? ' xeplr-admin-locked' : '')} />
|
|
233
|
+
</label>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import './admin.css';
|
|
2
|
+
import AdminTabs from './AdminTabs.jsx';
|
|
2
3
|
|
|
3
4
|
export default function UserRolesMatrixSample({
|
|
4
|
-
users, roles, search, setSearch, loading, error, saving, handleToggle, isAssigned, reload
|
|
5
|
+
users, roles, search, setSearch, loading, error, saving, handleToggle, isAssigned, reload,
|
|
6
|
+
newRoleName, setNewRoleName, creatingRole, createRole
|
|
5
7
|
}) {
|
|
6
8
|
return (
|
|
7
9
|
<div className="xeplr-admin-container">
|
|
10
|
+
<AdminTabs />
|
|
8
11
|
<div className="xeplr-admin-header">
|
|
9
12
|
<h2>User Roles</h2>
|
|
10
13
|
<div className="xeplr-admin-toolbar">
|
|
@@ -20,6 +23,21 @@ export default function UserRolesMatrixSample({
|
|
|
20
23
|
</div>
|
|
21
24
|
</div>
|
|
22
25
|
|
|
26
|
+
<form className="xeplr-admin-new-role"
|
|
27
|
+
onSubmit={function(e) { e.preventDefault(); createRole(); }}>
|
|
28
|
+
<input
|
|
29
|
+
id="xeplr-admin-new-role"
|
|
30
|
+
type="text"
|
|
31
|
+
placeholder="New role, e.g. Workspace Admin"
|
|
32
|
+
value={newRoleName}
|
|
33
|
+
onChange={function(e) { setNewRoleName(e.target.value); }}
|
|
34
|
+
className="xeplr-admin-search"
|
|
35
|
+
/>
|
|
36
|
+
<button type="submit" className="xeplr-admin-btn-primary" disabled={creatingRole || !newRoleName.trim()}>
|
|
37
|
+
{creatingRole ? 'Adding…' : 'Add role'}
|
|
38
|
+
</button>
|
|
39
|
+
</form>
|
|
40
|
+
|
|
23
41
|
{error && <div className="xeplr-admin-alert xeplr-admin-alert-error">{error}</div>}
|
|
24
42
|
|
|
25
43
|
{loading ? (
|
package/src/designs/admin.css
CHANGED
|
@@ -533,3 +533,115 @@
|
|
|
533
533
|
color: var(--xeplr-danger);
|
|
534
534
|
border: 1px solid var(--xeplr-danger-border);
|
|
535
535
|
}
|
|
536
|
+
|
|
537
|
+
/* ── Access Matrix: who it applies to, and three-way cells ─────────────── */
|
|
538
|
+
|
|
539
|
+
.xeplr-admin-scope-bar {
|
|
540
|
+
display: flex;
|
|
541
|
+
align-items: center;
|
|
542
|
+
flex-wrap: wrap;
|
|
543
|
+
gap: 10px;
|
|
544
|
+
margin: 12px 0;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
.xeplr-admin-scope-label {
|
|
548
|
+
font-size: 13px;
|
|
549
|
+
color: var(--xeplr-text-muted);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
.xeplr-admin-scope-options {
|
|
553
|
+
display: inline-flex;
|
|
554
|
+
border: 1px solid var(--xeplr-input-border);
|
|
555
|
+
border-radius: 6px;
|
|
556
|
+
overflow: hidden;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
.xeplr-admin-scope-option {
|
|
560
|
+
padding: 6px 14px;
|
|
561
|
+
border: none;
|
|
562
|
+
border-right: 1px solid var(--xeplr-input-border);
|
|
563
|
+
background: var(--xeplr-input-bg);
|
|
564
|
+
color: var(--xeplr-text-primary);
|
|
565
|
+
font-size: 13px;
|
|
566
|
+
cursor: pointer;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
.xeplr-admin-scope-option:last-child { border-right: none; }
|
|
570
|
+
.xeplr-admin-scope-option:hover { background: var(--xeplr-bg-hover); }
|
|
571
|
+
|
|
572
|
+
.xeplr-admin-scope-option-active,
|
|
573
|
+
.xeplr-admin-scope-option-active:hover {
|
|
574
|
+
background: var(--xeplr-accent);
|
|
575
|
+
color: var(--xeplr-accent-text);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
.xeplr-admin-scope-select,
|
|
579
|
+
.xeplr-admin-state-select {
|
|
580
|
+
padding: 5px 8px;
|
|
581
|
+
border: 1px solid var(--xeplr-input-border);
|
|
582
|
+
border-radius: 6px;
|
|
583
|
+
background: var(--xeplr-input-bg);
|
|
584
|
+
color: var(--xeplr-input-text);
|
|
585
|
+
font-size: 13px;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
.xeplr-admin-scope-select { min-width: 220px; }
|
|
589
|
+
.xeplr-admin-state-select { min-width: 104px; cursor: pointer; }
|
|
590
|
+
.xeplr-admin-state-select:disabled { opacity: 0.6; cursor: progress; }
|
|
591
|
+
|
|
592
|
+
/* The state is readable at a glance, not only after opening the select. */
|
|
593
|
+
.xeplr-admin-state-enabled { color: var(--xeplr-success); border-color: var(--xeplr-success-border); }
|
|
594
|
+
.xeplr-admin-state-disabled { color: var(--xeplr-warning); }
|
|
595
|
+
.xeplr-admin-state-hidden { color: var(--xeplr-text-muted); }
|
|
596
|
+
.xeplr-admin-state-inherit { color: var(--xeplr-text-muted); font-style: italic; }
|
|
597
|
+
.xeplr-admin-state-partial { color: var(--xeplr-info); }
|
|
598
|
+
|
|
599
|
+
.xeplr-admin-alert-info {
|
|
600
|
+
border: 1px solid var(--xeplr-info-border);
|
|
601
|
+
background: var(--xeplr-info-bg);
|
|
602
|
+
color: var(--xeplr-info);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/* ── Admin page: add a role ─────────────────────────────────────────────── */
|
|
606
|
+
|
|
607
|
+
.xeplr-admin-new-role {
|
|
608
|
+
display: flex;
|
|
609
|
+
gap: 8px;
|
|
610
|
+
margin: 12px 0;
|
|
611
|
+
max-width: 480px;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
.xeplr-admin-new-role .xeplr-admin-search { flex: 1; }
|
|
615
|
+
.xeplr-admin-new-role button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
616
|
+
|
|
617
|
+
/* ── Master Settings: the "Super Admin only" switch (Super Admin viewers only) ── */
|
|
618
|
+
|
|
619
|
+
.xeplr-admin-scope-col { text-align: center; white-space: nowrap; }
|
|
620
|
+
.xeplr-admin-locked { opacity: 0.55; cursor: not-allowed; }
|
|
621
|
+
|
|
622
|
+
/* ── Admin view: the pages, one click apart ─────────────────────────────── */
|
|
623
|
+
|
|
624
|
+
.xeplr-admin-pages {
|
|
625
|
+
display: flex;
|
|
626
|
+
gap: 4px;
|
|
627
|
+
margin-bottom: 18px;
|
|
628
|
+
padding-bottom: 10px;
|
|
629
|
+
border-bottom: 1px solid var(--xeplr-border-primary);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
.xeplr-admin-page-link {
|
|
633
|
+
padding: 6px 12px;
|
|
634
|
+
border-radius: 6px;
|
|
635
|
+
color: var(--xeplr-text-muted);
|
|
636
|
+
text-decoration: none;
|
|
637
|
+
font-size: 13px;
|
|
638
|
+
font-weight: 500;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
.xeplr-admin-page-link:hover { background: var(--xeplr-bg-hover); color: var(--xeplr-text-primary); }
|
|
642
|
+
|
|
643
|
+
.xeplr-admin-page-link-active,
|
|
644
|
+
.xeplr-admin-page-link-active:hover {
|
|
645
|
+
background: var(--xeplr-accent-muted);
|
|
646
|
+
color: var(--xeplr-accent);
|
|
647
|
+
}
|
package/src/designs/index.js
CHANGED
|
@@ -14,3 +14,4 @@ export { default as AccountMenu } from './AccountMenu.jsx';
|
|
|
14
14
|
export { default as NavDrawer } from './NavDrawer.jsx';
|
|
15
15
|
export { default as NavFloatingSettings } from './NavFloatingSettings.jsx';
|
|
16
16
|
export { default as NotificationsBell } from './NotificationsBell.jsx';
|
|
17
|
+
export { default as AdminTabs } from './AdminTabs.jsx';
|
package/src/index.js
CHANGED
|
@@ -56,3 +56,4 @@ export { LoginPage, RegisterPage, ForgotPasswordPage, ResetPasswordPage, Activat
|
|
|
56
56
|
|
|
57
57
|
// One-call auth routing — mount every auth page with no boilerplate; override just what you want
|
|
58
58
|
export { authRoutes, authPath } from './authRoutes.jsx';
|
|
59
|
+
export { ADMIN_PAGES } from './adminPaths.js';
|
package/src/masterApi.js
CHANGED
|
@@ -26,3 +26,15 @@ export function deleteMasterItem(type, id) {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
export { TYPES as MASTER_TYPES };
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Move an API in or out of system scope — "Super Admin only". scope:
|
|
32
|
+
* 'system' | 'company'. The server allows it for Super Admin only and refuses
|
|
33
|
+
* an API a migration pinned (scopeLocked).
|
|
34
|
+
*/
|
|
35
|
+
export function setApiScope(id, scope) {
|
|
36
|
+
return authFetch('/auth/api/admin/master/apis/scope', {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
body: JSON.stringify({ id, scope }),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
|
2
|
-
import { getRoles, getAccessItems, toggleModuleRole, toggleAccessRole } from './adminApi.js';
|
|
2
|
+
import { getRoles, getAccessItems, getUsers, toggleModuleRole, toggleAccessRole, getModuleStates, setModuleState } from './adminApi.js';
|
|
3
|
+
import { stateKey, indexStates, roleCellState, overrideCellState, roleChangePlan, needsStoredStates } from './accessStates.js';
|
|
3
4
|
|
|
4
5
|
var GROUP_FIELDS = {
|
|
5
6
|
apis: 'apiGroup',
|
|
@@ -10,7 +11,7 @@ var GROUP_FIELDS = {
|
|
|
10
11
|
|
|
11
12
|
var TYPES = ['apis', 'pages', 'elements', 'menus'];
|
|
12
13
|
|
|
13
|
-
var ACTION_ORDER = { view: 0,
|
|
14
|
+
var ACTION_ORDER = { view: 0, create: 1, edit: 2, delete: 3 };
|
|
14
15
|
|
|
15
16
|
function parseModuleGroup(groupValue) {
|
|
16
17
|
if (!groupValue) return null;
|
|
@@ -19,7 +20,14 @@ function parseModuleGroup(groupValue) {
|
|
|
19
20
|
return { module: parts[0], action: parts[1] };
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
/**
|
|
24
|
+
* @param props.workspaces [{ id, name }] the host app's workspaces, for the
|
|
25
|
+
* "Applies to: Workspace" picker. Workspaces live in
|
|
26
|
+
* the product (BI), not in the auth service.
|
|
27
|
+
* @param props.loadWorkspaces async () => [{ id, name }], instead of `workspaces`
|
|
28
|
+
*/
|
|
29
|
+
export function useAccessMatrixController(props) {
|
|
30
|
+
props = props || {};
|
|
23
31
|
var [roles, setRoles] = useState([]);
|
|
24
32
|
var [rawItems, setRawItems] = useState({ apis: [], pages: [], elements: [], menus: [] });
|
|
25
33
|
var [activeView, setActiveView] = useState('modules');
|
|
@@ -29,6 +37,40 @@ export function useAccessMatrixController() {
|
|
|
29
37
|
var [error, setError] = useState('');
|
|
30
38
|
var [saving, setSaving] = useState({});
|
|
31
39
|
|
|
40
|
+
// ── who the matrix applies to: roles (the defaults), a workspace, a user ──
|
|
41
|
+
var [appliesTo, setAppliesToState] = useState('role');
|
|
42
|
+
var [scopeId, setScopeIdState] = useState('');
|
|
43
|
+
var [workspaces, setWorkspaces] = useState(props.workspaces || []);
|
|
44
|
+
var [users, setUsers] = useState([]);
|
|
45
|
+
// Stored states for the current scope: { 'reports:view[:roleId]': state }.
|
|
46
|
+
var [storedStates, setStoredStates] = useState({});
|
|
47
|
+
// null = not known yet; false = the server has no access-state routes yet.
|
|
48
|
+
var [statesSupported, setStatesSupported] = useState(null);
|
|
49
|
+
|
|
50
|
+
useEffect(function() {
|
|
51
|
+
if (props.workspaces) { setWorkspaces(props.workspaces); return; }
|
|
52
|
+
if (typeof props.loadWorkspaces !== 'function') return;
|
|
53
|
+
var alive = true;
|
|
54
|
+
Promise.resolve(props.loadWorkspaces()).then(function(list) {
|
|
55
|
+
if (alive) setWorkspaces(list || []);
|
|
56
|
+
}).catch(function(err) { if (alive) setError(err.message); });
|
|
57
|
+
return function() { alive = false; };
|
|
58
|
+
}, [props.workspaces, props.loadWorkspaces]);
|
|
59
|
+
|
|
60
|
+
async function loadStates(scope, id) {
|
|
61
|
+
if (scope !== 'role' && !id) { setStoredStates({}); return; }
|
|
62
|
+
try {
|
|
63
|
+
var rows = await getModuleStates({ scope: scope, scopeId: scope === 'role' ? null : id });
|
|
64
|
+
setStoredStates(indexStates(rows));
|
|
65
|
+
setStatesSupported(true);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
// No server support yet: roles still work through their mappings, and
|
|
68
|
+
// the design shows a notice rather than pretending overrides exist.
|
|
69
|
+
setStoredStates({});
|
|
70
|
+
setStatesSupported(false);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
32
74
|
useEffect(function() {
|
|
33
75
|
loadData();
|
|
34
76
|
}, []);
|
|
@@ -40,6 +82,7 @@ export function useAccessMatrixController() {
|
|
|
40
82
|
var [rolesData, itemsData] = await Promise.all([getRoles(), getAccessItems()]);
|
|
41
83
|
setRoles(rolesData.filter(function(r) { return r.name !== 'Super Admin'; }));
|
|
42
84
|
setRawItems(itemsData);
|
|
85
|
+
await loadStates(appliesTo, scopeId);
|
|
43
86
|
} catch (err) {
|
|
44
87
|
setError(err.message);
|
|
45
88
|
} finally {
|
|
@@ -93,21 +136,30 @@ export function useAccessMatrixController() {
|
|
|
93
136
|
action: action,
|
|
94
137
|
itemCount: data.items.length,
|
|
95
138
|
items: data.items,
|
|
96
|
-
getRoleState:
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
139
|
+
getRoleState: getRoleState,
|
|
140
|
+
// Three-way cell for a role: enabled / disabled / hidden (or partial).
|
|
141
|
+
getCellState: function(roleId) {
|
|
142
|
+
return roleCellState(getRoleState(roleId), storedStates[stateKey(name, action, roleId)]);
|
|
143
|
+
},
|
|
144
|
+
// Three-way cell plus inherit, for the chosen workspace or user.
|
|
145
|
+
getOverrideState: function() {
|
|
146
|
+
return overrideCellState(storedStates[stateKey(name, action)]);
|
|
102
147
|
}
|
|
103
148
|
};
|
|
149
|
+
function getRoleState(roleId) {
|
|
150
|
+
var info = data.roleIds[roleId];
|
|
151
|
+
if (!info || info.total === 0) return 'none';
|
|
152
|
+
if (info.assigned === info.total) return 'all';
|
|
153
|
+
if (info.assigned > 0) return 'partial';
|
|
154
|
+
return 'none';
|
|
155
|
+
}
|
|
104
156
|
});
|
|
105
157
|
|
|
106
158
|
return { name: name, actions: actions };
|
|
107
159
|
});
|
|
108
160
|
|
|
109
161
|
return result;
|
|
110
|
-
}, [rawItems, roles, search]);
|
|
162
|
+
}, [rawItems, roles, search, storedStates]);
|
|
111
163
|
|
|
112
164
|
// ─── Uncategorized view: items without module:action group ───
|
|
113
165
|
var uncategorized = useMemo(function() {
|
|
@@ -135,45 +187,111 @@ export function useAccessMatrixController() {
|
|
|
135
187
|
return count;
|
|
136
188
|
}, [uncategorized]);
|
|
137
189
|
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
190
|
+
// Grant or revoke a module/action for a role, and mirror it locally.
|
|
191
|
+
async function applyGrant(moduleName, action, roleId, assign) {
|
|
192
|
+
await toggleModuleRole({ module: moduleName, action: action, roleId: roleId, assign: assign });
|
|
193
|
+
setRawItems(function(prev) {
|
|
194
|
+
var updated = {};
|
|
195
|
+
TYPES.forEach(function(type) {
|
|
196
|
+
var groupField = GROUP_FIELDS[type];
|
|
197
|
+
updated[type] = prev[type].map(function(item) {
|
|
198
|
+
var parsed = parseModuleGroup(item[groupField]);
|
|
199
|
+
if (!parsed || parsed.module !== moduleName || parsed.action !== action) return item;
|
|
146
200
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (assign) {
|
|
158
|
-
var hasRole = item.roles && item.roles.some(function(r) { return r.id === roleId; });
|
|
159
|
-
if (hasRole) return item;
|
|
160
|
-
var role = roles.find(function(r) { return r.id === roleId; });
|
|
161
|
-
newRoles = (item.roles || []).concat(role ? [role] : []);
|
|
162
|
-
} else {
|
|
163
|
-
newRoles = (item.roles || []).filter(function(r) { return r.id !== roleId; });
|
|
164
|
-
}
|
|
165
|
-
return { ...item, roles: newRoles };
|
|
166
|
-
});
|
|
201
|
+
var newRoles;
|
|
202
|
+
if (assign) {
|
|
203
|
+
var hasRole = item.roles && item.roles.some(function(r) { return r.id === roleId; });
|
|
204
|
+
if (hasRole) return item;
|
|
205
|
+
var role = roles.find(function(r) { return r.id === roleId; });
|
|
206
|
+
newRoles = (item.roles || []).concat(role ? [role] : []);
|
|
207
|
+
} else {
|
|
208
|
+
newRoles = (item.roles || []).filter(function(r) { return r.id !== roleId; });
|
|
209
|
+
}
|
|
210
|
+
return { ...item, roles: newRoles };
|
|
167
211
|
});
|
|
168
|
-
return updated;
|
|
169
212
|
});
|
|
213
|
+
return updated;
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function markSaving(key, on) {
|
|
218
|
+
setSaving(function(prev) { var next = { ...prev }; if (on) next[key] = true; else delete next[key]; return next; });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ─── Module toggle (bulk) — kept for designs written before three-way cells ───
|
|
222
|
+
var handleModuleToggle = useCallback(async function(moduleName, action, roleId, currentState) {
|
|
223
|
+
var key = 'module:' + moduleName + ':' + action + ':' + roleId;
|
|
224
|
+
markSaving(key, true);
|
|
225
|
+
try {
|
|
226
|
+
await applyGrant(moduleName, action, roleId, currentState !== 'all');
|
|
170
227
|
} catch (err) {
|
|
171
228
|
setError(err.message);
|
|
172
229
|
} finally {
|
|
173
|
-
|
|
230
|
+
markSaving(key, false);
|
|
174
231
|
}
|
|
175
232
|
}, [roles]);
|
|
176
233
|
|
|
234
|
+
// ─── Three-way cell change: enabled / disabled / hidden (/ inherit) ───
|
|
235
|
+
//
|
|
236
|
+
// roleId is set for the Roles view and null for a workspace or user.
|
|
237
|
+
var handleStateChange = useCallback(async function(moduleName, action, roleId, nextState) {
|
|
238
|
+
if (needsStoredStates(appliesTo, nextState) && statesSupported === false) {
|
|
239
|
+
setError('The server does not store access states yet, so "' + nextState + '" cannot be saved' +
|
|
240
|
+
(appliesTo === 'role' ? ' for a role.' : ' for a ' + appliesTo + '.') +
|
|
241
|
+
' Enabled and Hidden on roles work today.');
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
var key = 'module:' + moduleName + ':' + action + ':' + (roleId || appliesTo);
|
|
245
|
+
markSaving(key, true);
|
|
246
|
+
setError('');
|
|
247
|
+
try {
|
|
248
|
+
if (appliesTo === 'role') {
|
|
249
|
+
var plan = roleChangePlan(nextState);
|
|
250
|
+
for (var i = 0; i < plan.length; i++) {
|
|
251
|
+
var step = plan[i];
|
|
252
|
+
if (step.call === 'grant') await applyGrant(moduleName, action, roleId, true);
|
|
253
|
+
else if (step.call === 'revoke') await applyGrant(moduleName, action, roleId, false);
|
|
254
|
+
else if (statesSupported) {
|
|
255
|
+
await setModuleState({ scope: 'role', scopeId: roleId, module: moduleName, action: action, roleId: roleId,
|
|
256
|
+
state: step.call === 'setState' ? step.state : null });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
rememberState(stateKey(moduleName, action, roleId), nextState === 'disabled' ? 'disabled' : null);
|
|
260
|
+
} else {
|
|
261
|
+
await setModuleState({ scope: appliesTo, scopeId: scopeId, module: moduleName, action: action,
|
|
262
|
+
state: nextState === 'inherit' ? null : nextState });
|
|
263
|
+
rememberState(stateKey(moduleName, action), nextState === 'inherit' ? null : nextState);
|
|
264
|
+
}
|
|
265
|
+
} catch (err) {
|
|
266
|
+
setError(err.message);
|
|
267
|
+
} finally {
|
|
268
|
+
markSaving(key, false);
|
|
269
|
+
}
|
|
270
|
+
}, [roles, appliesTo, scopeId, statesSupported]);
|
|
271
|
+
|
|
272
|
+
function rememberState(k, state) {
|
|
273
|
+
setStoredStates(function(prev) {
|
|
274
|
+
var next = { ...prev };
|
|
275
|
+
if (state) next[k] = state; else delete next[k];
|
|
276
|
+
return next;
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
var setAppliesTo = useCallback(function(scope) {
|
|
281
|
+
setAppliesToState(scope);
|
|
282
|
+
setScopeIdState('');
|
|
283
|
+
setError('');
|
|
284
|
+
if (scope === 'user' && users.length === 0) {
|
|
285
|
+
getUsers().then(function(list) { setUsers(list || []); }).catch(function(err) { setError(err.message); });
|
|
286
|
+
}
|
|
287
|
+
loadStates(scope, '');
|
|
288
|
+
}, [users]);
|
|
289
|
+
|
|
290
|
+
var setScopeId = useCallback(function(id) {
|
|
291
|
+
setScopeIdState(id);
|
|
292
|
+
loadStates(appliesTo, id);
|
|
293
|
+
}, [appliesTo]);
|
|
294
|
+
|
|
177
295
|
// ─── Single item toggle (for uncategorized) ───
|
|
178
296
|
var handleItemToggle = useCallback(async function(type, itemId, roleId, currentlyAssigned) {
|
|
179
297
|
var key = 'item:' + type + ':' + itemId + ':' + roleId;
|
|
@@ -209,7 +327,7 @@ export function useAccessMatrixController() {
|
|
|
209
327
|
}
|
|
210
328
|
|
|
211
329
|
function isModuleSaving(moduleName, action, roleId) {
|
|
212
|
-
return !!saving['module:' + moduleName + ':' + action + ':' + roleId];
|
|
330
|
+
return !!saving['module:' + moduleName + ':' + action + ':' + (roleId || appliesTo)];
|
|
213
331
|
}
|
|
214
332
|
|
|
215
333
|
function isItemSaving(type, itemId, roleId) {
|
|
@@ -235,7 +353,15 @@ export function useAccessMatrixController() {
|
|
|
235
353
|
loading,
|
|
236
354
|
error,
|
|
237
355
|
handleModuleToggle,
|
|
356
|
+
handleStateChange,
|
|
238
357
|
handleItemToggle,
|
|
358
|
+
appliesTo,
|
|
359
|
+
setAppliesTo,
|
|
360
|
+
scopeId,
|
|
361
|
+
setScopeId,
|
|
362
|
+
workspaces,
|
|
363
|
+
users,
|
|
364
|
+
statesSupported,
|
|
239
365
|
isItemAssigned,
|
|
240
366
|
isModuleSaving,
|
|
241
367
|
isItemSaving,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
|
2
|
-
import { getMasterItems, saveMasterItem, deleteMasterItem } from './masterApi.js';
|
|
2
|
+
import { getMasterItems, saveMasterItem, deleteMasterItem, setApiScope } from './masterApi.js';
|
|
3
|
+
import { useAccess } from './AccessContext.jsx';
|
|
3
4
|
|
|
4
5
|
var TABS = [
|
|
5
6
|
{ key: 'roles', label: 'Roles', fields: [{ key: 'name', label: 'Name', required: true }] },
|
|
@@ -33,6 +34,12 @@ export function useMasterSettingsController() {
|
|
|
33
34
|
var [saving, setSaving] = useState(false);
|
|
34
35
|
var [editingItem, setEditingItem] = useState(null);
|
|
35
36
|
var [editForm, setEditForm] = useState({});
|
|
37
|
+
var [scopeSaving, setScopeSaving] = useState({});
|
|
38
|
+
|
|
39
|
+
// Only Super Admin sees the "Super Admin only" switch at all; the server
|
|
40
|
+
// refuses the change for anyone else anyway.
|
|
41
|
+
var access = useAccess();
|
|
42
|
+
var isSuperAdmin = !!(access && access.hasRole && access.hasRole('Super Admin'));
|
|
36
43
|
|
|
37
44
|
useEffect(function() {
|
|
38
45
|
loadItems(activeTab);
|
|
@@ -153,6 +160,27 @@ export function useMasterSettingsController() {
|
|
|
153
160
|
}
|
|
154
161
|
}
|
|
155
162
|
|
|
163
|
+
// Move an API in or out of system scope. Effective at once on the server.
|
|
164
|
+
async function handleScopeToggle(item) {
|
|
165
|
+
var next = item.scope === 'system' ? 'company' : 'system';
|
|
166
|
+
setScopeSaving(function(prev) { var n = { ...prev }; n[item.id] = true; return n; });
|
|
167
|
+
setError('');
|
|
168
|
+
try {
|
|
169
|
+
await setApiScope(item.id, next);
|
|
170
|
+
setItems(function(prev) {
|
|
171
|
+
var n = { ...prev };
|
|
172
|
+
n.apis = (n.apis || []).map(function(x) { return x.id === item.id ? { ...x, scope: next } : x; });
|
|
173
|
+
return n;
|
|
174
|
+
});
|
|
175
|
+
} catch (err) {
|
|
176
|
+
setError(err.message);
|
|
177
|
+
} finally {
|
|
178
|
+
setScopeSaving(function(prev) { var n = { ...prev }; delete n[item.id]; return n; });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function isScopeSaving(id) { return !!scopeSaving[id]; }
|
|
183
|
+
|
|
156
184
|
async function handleDelete(id) {
|
|
157
185
|
setSaving(true);
|
|
158
186
|
setError('');
|
|
@@ -194,6 +222,9 @@ export function useMasterSettingsController() {
|
|
|
194
222
|
updateField,
|
|
195
223
|
handleSave,
|
|
196
224
|
handleDelete,
|
|
225
|
+
isSuperAdmin,
|
|
226
|
+
handleScopeToggle,
|
|
227
|
+
isScopeSaving,
|
|
197
228
|
reload: function() { loadItems(activeTab); },
|
|
198
229
|
};
|
|
199
230
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
|
2
2
|
import { getUsers, getRoles, toggleUserRole } from './adminApi.js';
|
|
3
|
+
import { saveMasterItem } from './masterApi.js';
|
|
3
4
|
|
|
4
5
|
export function useUserRolesController() {
|
|
5
6
|
var [users, setUsers] = useState([]);
|
|
@@ -8,6 +9,8 @@ export function useUserRolesController() {
|
|
|
8
9
|
var [loading, setLoading] = useState(true);
|
|
9
10
|
var [error, setError] = useState('');
|
|
10
11
|
var [saving, setSaving] = useState({});
|
|
12
|
+
var [newRoleName, setNewRoleName] = useState('');
|
|
13
|
+
var [creatingRole, setCreatingRole] = useState(false);
|
|
11
14
|
|
|
12
15
|
useEffect(function() {
|
|
13
16
|
loadData();
|
|
@@ -62,6 +65,25 @@ export function useUserRolesController() {
|
|
|
62
65
|
}
|
|
63
66
|
}, [roles]);
|
|
64
67
|
|
|
68
|
+
// A new role becomes a column here and in the Access Matrix: both read the
|
|
69
|
+
// role list from the server. Creating one is Super Admin only; anyone else
|
|
70
|
+
// gets the server's refusal as the error.
|
|
71
|
+
var createRole = useCallback(async function() {
|
|
72
|
+
var name = newRoleName.trim();
|
|
73
|
+
if (!name) return;
|
|
74
|
+
setCreatingRole(true);
|
|
75
|
+
setError('');
|
|
76
|
+
try {
|
|
77
|
+
var made = await saveMasterItem('roles', { name: name });
|
|
78
|
+
setRoles(function(prev) { return prev.concat([{ id: made.id, name: name }]); });
|
|
79
|
+
setNewRoleName('');
|
|
80
|
+
} catch (err) {
|
|
81
|
+
setError(err.message);
|
|
82
|
+
} finally {
|
|
83
|
+
setCreatingRole(false);
|
|
84
|
+
}
|
|
85
|
+
}, [newRoleName]);
|
|
86
|
+
|
|
65
87
|
function isAssigned(user, roleId) {
|
|
66
88
|
return user.roles && user.roles.some(function(r) { return r.id === roleId; });
|
|
67
89
|
}
|
|
@@ -76,6 +98,10 @@ export function useUserRolesController() {
|
|
|
76
98
|
saving,
|
|
77
99
|
handleToggle,
|
|
78
100
|
isAssigned,
|
|
101
|
+
newRoleName,
|
|
102
|
+
setNewRoleName,
|
|
103
|
+
creatingRole,
|
|
104
|
+
createRole,
|
|
79
105
|
reload: loadData,
|
|
80
106
|
};
|
|
81
107
|
}
|
package/src/validateDesign.js
CHANGED
|
@@ -99,12 +99,14 @@ export var PROFILE_RULES = [
|
|
|
99
99
|
|
|
100
100
|
export var USER_ROLES_MATRIX_RULES = [
|
|
101
101
|
{ id: 'xeplr-admin-user-search', label: 'User search input' },
|
|
102
|
-
{ role: 'grid', label: 'Matrix grid table' }
|
|
102
|
+
{ role: 'grid', label: 'Matrix grid table' },
|
|
103
|
+
{ id: 'xeplr-admin-new-role', label: 'New role name input' }
|
|
103
104
|
];
|
|
104
105
|
|
|
105
106
|
export var ACCESS_MATRIX_RULES = [
|
|
106
107
|
{ id: 'xeplr-admin-access-search', label: 'Access search input' },
|
|
107
|
-
{ role: 'tablist', label: 'Tab list for Modules/Uncategorized' }
|
|
108
|
+
{ role: 'tablist', label: 'Tab list for Modules/Uncategorized' },
|
|
109
|
+
{ id: 'xeplr-admin-access-scope', label: 'Applies to: Roles / Workspace / User (role="radiogroup")' }
|
|
108
110
|
];
|
|
109
111
|
|
|
110
112
|
export var MASTER_SETTINGS_RULES = [
|