@xeplr/ui-account 1.0.9 → 1.0.11

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 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: 60px; } /* room for the collapsed rail */
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`, 60px wide when collapsed. It never pushes content, so the app must leave that space (e.g. `padding-left: 60px`). Expanded, it overlays the page. A full-screen modal must stack above 500 or the rail shows through it.
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` | — | `roles, modules, uncategorized, uncategorizedCount, uncatSubTab, setUncatSubTab, activeView, setActiveView, search, setSearch, loading, error, handleModuleToggle, handleItemToggle, isItemAssigned, isModuleSaving, isItemSaving, reload` |
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.** On roles, all three work: Enabled and Hidden are the role mapping (`module-role`), Disabled is the mapping row's state (`module-state`, `@xeplr/auth` 0013). Workspace and user overrides are not stored yet: the server answers `SCOPE_NOT_SUPPORTED`, and 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 }]`. `role` only so far; other scopes answer `400 SCOPE_NOT_SUPPORTED` |
367
+ | `setModuleState({ scope, scopeId, module, action, roleId, state })` | `POST /auth/api/admin/module-state`. `state` is `disabled`, or `enabled` / `null` to clear (hidden is removing the mapping, through `module-role`). `role` only so far |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/ui-account",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
4
4
  "description": "Account UI: auth, profile, RBAC admin, tenant management — React controller hooks and designs",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -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
+ ];
@@ -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: '/auth/admin/user-roles', Page: UserRolesPage, group: 'admin' },
27
- { key: 'accessMatrix', path: '/auth/admin/access-matrix', Page: AccessMatrixPage, group: 'admin' },
28
- { key: 'masterSettings', path: '/auth/admin/master-settings', Page: MasterSettingsPage, group: 'admin' }
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) return <Page design={ov.design} />; // { design } → re-skin (keeps controller)
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, handleModuleToggle, handleItemToggle,
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
+ Workspace and user overrides are not stored yet. Roles work today: Enabled, Disabled
55
+ and Hidden.
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, handleModuleToggle, isModuleSaving)
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
- function renderModulesView(roles, modules, handleModuleToggle, isModuleSaving) {
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
- <label className="xeplr-admin-toggle">
109
- <input
110
- type="checkbox"
111
- checked={state === 'all'}
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" title="Edit" disabled={editingItem !== null}>
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" title="Delete" disabled={saving || editingItem !== null}>
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 ? (
@@ -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
+ }
@@ -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, edit: 1, delete: 2 };
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
- export function useAccessMatrixController() {
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
+ // Not stored for this scope (the server stores role states only, so far):
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: function(roleId) {
97
- var info = data.roleIds[roleId];
98
- if (!info || info.total === 0) return 'none';
99
- if (info.assigned === info.total) return 'all';
100
- if (info.assigned > 0) return 'partial';
101
- return 'none';
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
- // ─── Module toggle (bulk) ───
139
- var handleModuleToggle = useCallback(async function(moduleName, action, roleId, currentState) {
140
- var assign = currentState !== 'all';
141
- var key = 'module:' + moduleName + ':' + action + ':' + roleId;
142
- setSaving(function(prev) { var next = { ...prev }; next[key] = true; return next; });
143
-
144
- try {
145
- await toggleModuleRole({ module: moduleName, action: action, roleId: roleId, assign: assign });
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
- // Optimistic: update local state
148
- setRawItems(function(prev) {
149
- var updated = {};
150
- TYPES.forEach(function(type) {
151
- var groupField = GROUP_FIELDS[type];
152
- updated[type] = prev[type].map(function(item) {
153
- var parsed = parseModuleGroup(item[groupField]);
154
- if (!parsed || parsed.module !== moduleName || parsed.action !== action) return item;
155
-
156
- var newRoles;
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
- setSaving(function(prev) { var next = { ...prev }; delete next[key]; return next; });
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
  }
@@ -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 = [