@xeplr/ui-account 1.0.6 → 1.0.8

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 ADDED
@@ -0,0 +1,384 @@
1
+ # @xeplr/ui-account
2
+
3
+ **The React sign-in UI and app shell for [`@xeplr/auth`](https://www.npmjs.com/package/@xeplr/auth).** Every auth screen — login, register, forgot password, reset password, activate, profile, change password — the RBAC admin screens (user roles, access matrix, master settings), and `NavPage`: the top bar or side rail/drawer with the settings menu and notifications bell. `AccessProvider`, `useAccess`, `ProtectedRoute` and `AccessGuard` decide what a signed-in person may see, from the access answer `@xeplr/auth` sends.
4
+
5
+ Each screen is split in three: a **page** that wires a **controller** hook (all state and calls) to a **design** (the look), over **model** files with no React (the API client, token storage, scope, menu labels). Use the page, swap only its design, or use the hooks and model directly.
6
+
7
+ (The package name on npm is `@xeplr/ui-account` — the GitHub repo and folder are named `xeplr-ui-account`.)
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ npm i @xeplr/ui-account react-router-dom
13
+ ```
14
+
15
+ Peer dependencies: `react ^18 || ^19` and `react-router-dom ^6 || ^7`. [`@xeplr/ui-utils`](https://www.npmjs.com/package/@xeplr/ui-utils) comes as a dependency (the controllers report success and failure with its `raiseSnackbar`).
16
+
17
+ The package ships its source: `.jsx` files and CSS imported from the designs. Your bundler must compile JSX from `node_modules` and import CSS (Vite does both).
18
+
19
+ ## Setup (`main.jsx`)
20
+
21
+ ```jsx
22
+ import { createRoot } from 'react-dom/client'
23
+ import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom'
24
+ import {
25
+ configure, ThemeProvider, AccessProvider, ProtectedRoute, NavPage, authRoutes, authPath
26
+ } from '@xeplr/ui-account'
27
+
28
+ configure('http://localhost:19001') // where @xeplr/auth's /auth/api/* is served; '' = same origin
29
+
30
+ function Shell() {
31
+ return (
32
+ <div className="app"> {/* page background goes HERE, not on body */}
33
+ <NavPage logo="/logo.svg" drawerItems={drawerItems} />
34
+ <main className="app-main"><Outlet /></main>
35
+ </div>
36
+ )
37
+ }
38
+
39
+ createRoot(document.getElementById('root')).render(
40
+ <ThemeProvider theme="dark">
41
+ <AccessProvider>
42
+ <BrowserRouter>
43
+ <Routes>
44
+ {authRoutes({}, { layout: <Shell /> })}
45
+ <Route element={<ProtectedRoute><Shell /></ProtectedRoute>}>
46
+ <Route path="/" element={<Home />} />
47
+ </Route>
48
+ </Routes>
49
+ </BrowserRouter>
50
+ </AccessProvider>
51
+ </ThemeProvider>
52
+ )
53
+ ```
54
+
55
+ ```css
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 */
58
+ ```
59
+
60
+ Link to auth pages with `authPath(key)` rather than a literal URL: `<Link to={authPath('profile')}>`.
61
+
62
+ ### Rules
63
+
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
+ - **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.
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
+
69
+ ### Themes
70
+
71
+ | export | what it is |
72
+ |---|---|
73
+ | `ThemeProvider` | props `theme` (initial, default `'dark'`), `persist` (default `true`). With `persist`, the last choice saved in `localStorage` (`xeplr-theme`) wins over `theme`. |
74
+ | `useTheme()` | `{ theme, setTheme, themes, className }`, or `null` outside a provider |
75
+ | `useThemeStrict()` | the same, throws outside a provider |
76
+ | `BUILT_IN_THEMES`, `DEFAULT_THEME` | `['dark', 'light', 'medium', 'bright']`, `'dark'` |
77
+
78
+ A custom theme is a class with the same variables — `.xeplr-theme-corporate { --xeplr-bg-primary: …; … }` — used as `<ThemeProvider theme="corporate">`.
79
+
80
+ ## Auth routes
81
+
82
+ `authRoutes(overrides, opts)` returns an array of `<Route>` — spread it inside `<Routes>`.
83
+
84
+ | key | path | group |
85
+ |---|---|---|
86
+ | `login` | `/auth/login` | public |
87
+ | `register` | `/auth/register` | public |
88
+ | `forgotPassword` | `/auth/forgot-password` | public |
89
+ | `resetPassword` | `/auth/reset-password` | public |
90
+ | `activate` | `/auth/activate` | public |
91
+ | `notActivated` | `/auth/not-activated` | public |
92
+ | `profile` | `/auth/profile` | account |
93
+ | `changePassword` | `/auth/change-password` | account |
94
+ | `userRoles` | `/auth/admin/user-roles` | admin |
95
+ | `accessMatrix` | `/auth/admin/access-matrix` | admin |
96
+ | `masterSettings` | `/auth/admin/master-settings` | admin |
97
+
98
+ Public pages are open and never wrapped in the layout. Account and admin pages require sign-in (`ProtectedRoute`); with `opts.layout` they are nested under that element, which must render an `<Outlet />`. The admin group only requires sign-in here — gate it further with an override.
99
+
100
+ ```jsx
101
+ authRoutes({
102
+ login: { design: MyLogin }, // framework controller + validation, your look
103
+ register: false, // drop the route
104
+ profile: <MyProfile />, // full replace (same as { element: <MyProfile /> })
105
+ userRoles: { element: <ProtectedRoute roles={['Super Admin']}><UserRolesPage /></ProtectedRoute> },
106
+ forgotPassword: { path: '/forgot', design: MyForgot } // mount at another path
107
+ }, { layout: <Shell />, loginPath: '/auth/login', raw: true })
108
+ ```
109
+
110
+ | opt | default | meaning |
111
+ |---|---|---|
112
+ | `layout` | none | element wrapping the account and admin pages |
113
+ | `loginPath` | `/auth/login` | where `ProtectedRoute` sends a signed-out visitor |
114
+ | `raw` | `true` | also mount every page untouched at `/_<name>` (`/_login`, `/_admin/user-roles`) — the framework page, ignoring overrides and the layout |
115
+
116
+ `authPath(key)` returns the manifest path for a key, or `null`. It does not follow a `path` override.
117
+
118
+ ## Access
119
+
120
+ ```jsx
121
+ const { user, access, authenticated, hasMenu, refreshAccess } = useAccess()
122
+ ```
123
+
124
+ `AccessProvider` seeds `user` and `access` from `localStorage` (written at login), then re-reads them from `GET /auth/api/me` on mount when a token exists — once per page load. Without this, access would stay whatever it was at login until the person logged out.
125
+
126
+ | field | what it is |
127
+ |---|---|
128
+ | `user`, `access`, `authenticated` | current state; `access` is `{ roles, pages, apis, menus, menuItems, elements }` as `@xeplr/auth` sends it |
129
+ | `hasPage(name)`, `hasApi(name)`, `hasMenu(name)`, `hasElement(name)`, `hasRole(name)` | `true` when the name is in that list |
130
+ | `refreshAccess()` | re-reads `/auth/api/me` and **replaces** `user` and `access` (not merged, so a revoked page is gone). Resolves to the new access, or `null` on failure — then it keeps the current state and shows an error snackbar. |
131
+ | `onLogin(result)` | stores a login response (`useLoginController` calls it) |
132
+ | `logout()` | clears tokens, access and active scope, tells the server (best effort) |
133
+ | `setAccess(access)` | replace access by hand |
134
+
135
+ **Call `refreshAccess()` after anything that changes access** — a role granted, a menu renamed — so the signed-in UI shows it. The admin screens in this package do not call it themselves.
136
+
137
+ `useAccess()` returns `null` outside a provider (safe in optional places); `useAccessStrict()` throws. `AccessProvider` also registers `logout` as the handler `authFetch` calls when the session is dead, so a failed refresh sends `ProtectedRoute` to login instead of leaving a stale `authenticated: true`.
138
+
139
+ ### Guards
140
+
141
+ ```jsx
142
+ <ProtectedRoute page="/reports" roles={['admin']} deniedPath="/"><Reports /></ProtectedRoute>
143
+
144
+ <AccessGuard element="btn-delete-user" fallback={null}>
145
+ <button onClick={remove}>Delete</button>
146
+ </AccessGuard>
147
+ ```
148
+
149
+ | component | props | behaviour |
150
+ |---|---|---|
151
+ | `ProtectedRoute` | `page`, `roles` (any one), `loginPath` (default `/auth/login`), `deniedPath` (default `/auth/login`) | signed out → saves the current location (`saveReturnTo`) and redirects to `loginPath`; missing page or roles → `deniedPath`; else renders children |
152
+ | `AccessGuard` | `element`, `menu`, `page`, `role`, `fallback` (default `null`) | renders children only when every given check passes |
153
+
154
+ These only shape the UI. The server's own middleware decides what an API returns.
155
+
156
+ ## `authFetch`
157
+
158
+ ```js
159
+ import { authFetch } from '@xeplr/ui-account'
160
+
161
+ const rows = await authFetch('/api/tasks') // the parsed body
162
+ const saved = await authFetch('/api/tasks', { method: 'POST', body: JSON.stringify(task) })
163
+ ```
164
+
165
+ **It returns the parsed JSON body, not a `Response`** — there is no `.json()` to call. It:
166
+
167
+ - prefixes the `configure()` base URL (a URL starting with `http` is used as is) and sends `Content-Type: application/json` unless you pass your own;
168
+ - attaches `Authorization: Bearer <token>`;
169
+ - attaches one header per registered multi-tenant level whose active scope has an `id` (see [Multi-tenancy](#multi-tenancy));
170
+ - stores the token from an `X-New-Token` response header — `@xeplr/auth`'s sliding refresh, so most expiries never become a 401;
171
+ - on a 401, rotates tokens once through `POST /auth/api/refresh` (concurrent calls share one refresh) and retries the request once. If refresh fails, it clears the stored auth and calls the session-expired handler.
172
+
173
+ On failure it **throws an `Error`**:
174
+
175
+ | field | when | what |
176
+ |---|---|---|
177
+ | `message` | always | the server's `error` string, else `error.message`, else `message`, else `Something went wrong` |
178
+ | `status` | always | HTTP status |
179
+ | `body` | non-2xx | the parsed error body |
180
+ | `code`, `busy` | when the body has them | copied from the body |
181
+ | `request` | always | `"POST http://…/api/tasks"` |
182
+ | `rawBody`, `contentType`, `bodyLength`, `parseError` | the reply was not JSON | the first 600 characters and what was wrong |
183
+
184
+ Every reply must be JSON: an empty body (a `204`, say) throws too. Each failure is also logged with `console.error`, naming the request.
185
+
186
+ `configure(baseUrl, { onSessionExpired })` sets the base URL and optionally a handler; `setSessionExpiredHandler(fn)` sets the handler on its own (`AccessProvider` does this for you).
187
+
188
+ ## Menu keys and labels
189
+
190
+ `NavPage`'s `drawerItems` and `settingsOverrides` are matched by **`key`** (the legacy `name` is still accepted) against `access.menus`. The text shown is the **label from `access.menuItems`** — set by the Super Admin and stored in `@xeplr/auth`'s `menus.label` — and items appear **in the server's order**, not the array's.
191
+
192
+ **Never write labels in code.** The key is what code and roles match on and never changes; the label is what people read and is renamed from the app without a code change or a restart. (An item's own `label`, or its key, is shown only when the server sends no label.)
193
+
194
+ ```jsx
195
+ const navigate = useNavigate()
196
+ const drawerItems = [
197
+ { key: 'Tasks', icon: <TaskIcon />, clickHandler: () => navigate('/tasks') },
198
+ { key: 'Reports', icon: <ReportIcon />, clickHandler: () => navigate('/reports'), group: 'Insights', badge: 3 },
199
+ ]
200
+ const settingsOverrides = [{ key: 'Admin', path: '/admin' }]
201
+ ```
202
+
203
+ - **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.
204
+ - **Give every drawer item an `icon`.** The collapsed rail shows icons only; an item without one is an empty button there.
205
+ - `group` is a section header, written in code and shown as is. Ungrouped items come first.
206
+ - `badge` (number or short string) shows as a pill when expanded and a dot on the icon when collapsed; `0`, `''` and `null` show nothing.
207
+
208
+ `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.
209
+
210
+ ### Changing the menu
211
+
212
+ | call | server | |
213
+ |---|---|---|
214
+ | `listMenuItems()` | `GET /auth/api/admin/menu-items` | every item, hidden ones included: `[{ name, label, shown, sortOrder, isHidden, isPublic }]` |
215
+ | `saveMenuItems(items)` | `POST /auth/api/admin/menu-items` | rename / reorder / hide by key: `[{ name, label?, sortOrder?, isHidden? }]` |
216
+ | `addMenuItem({ name, label })` | `POST /auth/api/admin/menu-items/add` | a new item; `name` is its key |
217
+ | `removeMenuItem(name)` | `POST /auth/api/admin/menu-items/remove` | |
218
+
219
+ All four are **Super Admin only** on the server (anyone else gets 403). Access answers are cached server-side; saving through these calls clears that cache, so call `refreshAccess()` straight after to show the change.
220
+
221
+ ## NavPage
222
+
223
+ ```jsx
224
+ <NavPage
225
+ logo="/icon.svg" expandedLogo="/lockup.svg"
226
+ drawerItems={drawerItems}
227
+ settingsOverrides={settingsOverrides}
228
+ notifications={{ count: unread, onClick: openFeed }}
229
+ />
230
+ ```
231
+
232
+ Exactly one of two things renders: with at least one visible drawer item, the **drawer rail** is the whole nav (settings and bell in its footer); otherwise the **top bar**. `NavPage` is memoized and owns its own open/closed state, so it does not re-render when the routed page changes — keep it in the layout next to `<Outlet />`.
233
+
234
+ | prop | applies to | meaning |
235
+ |---|---|---|
236
+ | `drawerItems` | drawer | `[{ key, icon, clickHandler, group?, badge? }]` — a non-empty visible list turns the drawer on |
237
+ | `settingsOverrides` | both | `[{ key, path }]`, shown above the built-in items in the settings menu |
238
+ | `notifications` | both | `{ count, onClick }` — the bell shows only when this is given **and** `access.menus` contains `Notifications`; a count above 9 shows `9+` |
239
+ | `logo` | both | image URL: the top bar's logo, the collapsed rail's toggle |
240
+ | `expandedLogo` | drawer | image URL shown when expanded |
241
+ | `drawerPromo` | drawer | node shown under the links when expanded |
242
+ | `floatingSettings` | drawer | `true` moves the bell and settings out of the rail footer to the page's top-right corner |
243
+ | `navMiddle` | top bar | node for the middle column (e.g. a company picker) |
244
+ | `design` | top bar | your own top bar component (default `NavTopSample`) |
245
+
246
+ The settings menu lists `settingsOverrides`, then **Profile** and **Change Password** when those keys are in `access.menus` (shown by their label, like every other item, and linking to `authPath('profile')` / `authPath('changePassword')`), then Logout.
247
+
248
+ The drawer toggles by clicking its logo, has a search box when expanded, and its expanded width is resizable by dragging or with the keyboard (arrows, Home/End, Enter or double-click to reset): 180–480px, default 240, remembered per browser in `localStorage` (`xeplr-nav-drawer-width`).
249
+
250
+ ## Multi-tenancy
251
+
252
+ ```js
253
+ import { registerMTs, setActiveScope } from '@xeplr/ui-account'
254
+
255
+ registerMTs({
256
+ l1: { name: 'companyId', header: 'x-company-id' },
257
+ l2: { name: 'workspaceId', header: 'x-workspace-id' }
258
+ })
259
+
260
+ setActiveScope('l1', { id: 'acme-co', name: 'Acme Co' }) // every authFetch now sends x-company-id: acme-co
261
+ ```
262
+
263
+ Call `registerMTs(slots)` once at boot with **the same shape** passed to `@xeplr/db`'s `registerMTs` on the API. Nothing shares it at runtime, so keep one literal config in your app and import it into both entry points so they cannot drift. Levels `l1`–`l4` are read; headers are lowercased. It only tells `authFetch` which header to send — it validates nothing.
264
+
265
+ | export | what it does |
266
+ |---|---|
267
+ | `getActiveScope(level)` / `setActiveScope(level, scope)` | the active value per level, in `localStorage` (`xeplr:activeScope:<level>`); `authFetch` sends its `id`. `null` removes it. |
268
+ | `clearActiveScope(level?)` | one level, or every level when omitted (`logout` does this) |
269
+ | `getLastScope(level)` / `setLastScope(level, scope)` | the last value per level (`xeplr:lastScope:<level>`), **not** cleared by `clearActiveScope` or logout — so an app can offer to resume after signing in. Re-check eligibility before trusting it. |
270
+ | `getMtConfig()` | a copy of the registered slots |
271
+
272
+ ### Returning after a gate
273
+
274
+ `saveReturnTo(location)` records `pathname + search`; `consumeReturnTo()` reads and clears it. `ProtectedRoute` saves before redirecting to login, and the login page's default `onSuccess` navigates to `consumeReturnTo() || '/'`. An app's own gate (a company picker, say) does the same: save before redirecting, consume where the person lands. It is kept in `sessionStorage` — per tab — because it is for "send me back to what I was doing", not a link revived days later.
275
+
276
+ ## Three ways to use it
277
+
278
+ 1. **Ready-made** — `authRoutes()`, or the pages directly: `LoginPage`, `RegisterPage`, `ForgotPasswordPage`, `ResetPasswordPage`, `ActivatePage`, `NotActivatedPage`, `ChangePasswordPage`, `ProfilePage`, `UserRolesPage`, `AccessMatrixPage`, `MasterSettingsPage`, `NavPage`. Other props (`onSuccess`, …) go to the controller.
279
+ 2. **Your own design** — `<LoginPage design={MyLogin} />` or `authRoutes({ login: { design: MyLogin } })`. Your component receives the controller's return value as props, and the page still checks it for the required elements (below).
280
+ 3. **Hooks and model** — call a `use*Controller` hook in your own component, or use the React-free files (`api.js`, `adminApi.js`, `masterApi.js`, `token.js`, `mt.js`, `activeScope.js`, `returnTo.js`, `menuLabels.js`) alone.
281
+
282
+ ### Controllers
283
+
284
+ | hook | options | returns |
285
+ |---|---|---|
286
+ | `useLoginController` | `onSuccess(result)`, `notActivatedPath` | `email, setEmail, password, setPassword, error, loading, handleSubmit` |
287
+ | `useRegisterController` | `onSuccess` | `form, error, success, loading, handleChange, handleSubmit` |
288
+ | `useForgotPasswordController` | — | `email, setEmail, error, success, loading, handleSubmit` |
289
+ | `useResetPasswordController` | — | `token, password, setPassword, error, success, loading, handleSubmit` |
290
+ | `useActivateController` | — | `token, error, success, loading` |
291
+ | `useChangePasswordController` | `onSuccess` | `currentPassword, setCurrentPassword, newPassword, setNewPassword, confirmPassword, setConfirmPassword, error, success, loading, handleSubmit` |
292
+ | `useProfileController` | `onSuccess` | `form, error, success, loading, fetching, handleChange, handleSubmit` |
293
+ | `useUserRolesController` | — | `users, roles, search, setSearch, loading, error, saving, handleToggle, isAssigned, reload` |
294
+ | `useAccessMatrixController` | — | `roles, modules, uncategorized, uncategorizedCount, uncatSubTab, setUncatSubTab, activeView, setActiveView, search, setSearch, loading, error, handleModuleToggle, handleItemToggle, isItemAssigned, isModuleSaving, isItemSaving, reload` |
295
+ | `useMasterSettingsController` | — | `tabs, activeTab, setActiveTab, currentTab, items, groupNames, search, setSearch, loading, error, saving, editingItem, editForm, startAdd, startEdit, cancelEdit, updateField, handleSave, handleDelete, reload` |
296
+ | `useNavController` | `drawerItems, settingsOverrides, notifications` | `user, accountItems, notifications, logout, accountOpen, toggleAccount, closeAccount, accountRef, drawerOpen, toggleDrawer, closeDrawer, drawerItems, drawerWidth, drawerResizing, startDrawerResize, resetDrawerWidth, nudgeDrawerWidth, drawerWidthBounds` |
297
+
298
+ - 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.
299
+ - Register, profile: `handleChange` reads `e.target.name`, so inputs need `name="email"` etc. `form` is `{ name, email, phoneNumber, password }` / `{ name, email, phoneNumber }`.
300
+ - Reset and activate read `?token=` (activate also passes `?workflowKey=` back to the server) and activate runs on mount.
301
+ - 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.
302
+ - `MASTER_TYPES` is `['roles', 'apis', 'pages', 'elements', 'menus']`.
303
+
304
+ ### Design validation
305
+
306
+ Each page (except activate and not-activated) checks its rendered design three seconds after mounting and, outside production, `console.warn`s what is missing. `useDesignValidator(name, rules)` does the check for your own components.
307
+
308
+ | rules | required |
309
+ |---|---|
310
+ | `LOGIN_RULES` | `#xeplr-email`, `#xeplr-password`, `button[type="submit"]` |
311
+ | `REGISTER_RULES` | `#xeplr-name`, `#xeplr-email`, `#xeplr-password`, submit |
312
+ | `FORGOT_PASSWORD_RULES` | `#xeplr-email`, submit |
313
+ | `RESET_PASSWORD_RULES` | `#xeplr-password`, submit |
314
+ | `CHANGE_PASSWORD_RULES` | `#xeplr-current-password`, `#xeplr-new-password`, `#xeplr-confirm-password`, submit |
315
+ | `PROFILE_RULES` | `#xeplr-profile-name`, `#xeplr-profile-email`, submit |
316
+ | `USER_ROLES_MATRIX_RULES` | `#xeplr-admin-user-search`, `[role="grid"]` |
317
+ | `ACCESS_MATRIX_RULES` | `#xeplr-admin-access-search`, `[role="tablist"]` |
318
+ | `MASTER_SETTINGS_RULES` | `#xeplr-admin-master-search`, `[role="tablist"]` |
319
+ | `NAV_RULES` | `[aria-haspopup="menu"]` |
320
+
321
+ ### Designs
322
+
323
+ `LoginSample`, `RegisterSample`, `ForgotPasswordSample`, `ResetPasswordSample`, `ActivateSample`, `NotActivatedSample`, `ChangePasswordSample`, `ProfileSample`, `UserRolesMatrixSample`, `AccessMatrixSample`, `MasterSettingsSample`, `NavTopSample`, `NavDrawer`, `NavFloatingSettings`, `AccountMenu`, `NotificationsBell` — the defaults, exported as a reference or starting point. Styles are namespaced `.xeplr-auth-*`, `.xeplr-admin-*` and `.xeplr-nav-*`.
324
+
325
+ ## API
326
+
327
+ Unauthenticated calls go straight to `fetch`; the rest use `authFetch`. All resolve to the parsed body and throw the same `Error` shape.
328
+
329
+ | function | request |
330
+ |---|---|
331
+ | `registerUser({ email, password, name, phoneNumber })` | `POST /auth/api/register` |
332
+ | `loginUser({ email, password })` | `POST /auth/api/login` → `{ accessToken, refreshToken, user, access }` (does not store them — the login controller does) |
333
+ | `activateAccount(token, workflowKey?)` | `GET /auth/api/activate?token=…` |
334
+ | `forgotPassword({ email })` | `POST /auth/api/forgot-password` |
335
+ | `resetPassword({ token, newPassword })` | `POST /auth/api/reset-password` |
336
+ | `changePassword({ currentPassword, newPassword })` | `POST /auth/api/change-password` (auth) |
337
+ | `getMe()` | `GET /auth/api/me` → `{ user, access }` (auth) |
338
+ | `getProfile()` / `updateProfile(fields)` | `GET` / `PUT /auth/api/profile` (auth) |
339
+ | `uploadAvatar(file)` | `POST /auth/api/profile/avatar`, multipart. Sends the token only — no scope headers, no refresh retry. |
340
+ | `logoutUser()` | clears stored auth and active scope, then `POST /auth/api/logout` without waiting |
341
+
342
+ Admin (auth):
343
+
344
+ | function | request |
345
+ |---|---|
346
+ | `getUsers()`, `getRoles()`, `getAccessItems()` | `GET /auth/api/admin/users`, `/roles`, `/access-items` |
347
+ | `toggleUserRole({ userId, roleId, assign })` | `POST /auth/api/admin/user-role` |
348
+ | `toggleAccessRole({ type, itemId, roleId, assign })` | `POST /auth/api/admin/access-role` |
349
+ | `toggleModuleRole({ module, action, roleId, assign })` | `POST /auth/api/admin/module-role` |
350
+ | `getMasterItems(type)`, `saveMasterItem(type, data)`, `deleteMasterItem(type, id)` | `GET` / `POST /auth/api/admin/master/<type>`, `POST …/<type>/delete` |
351
+ | `listMenuItems`, `saveMenuItems`, `addMenuItem`, `removeMenuItem` | see [Changing the menu](#changing-the-menu) |
352
+
353
+ Token storage (`localStorage` keys `accessToken`, `refreshToken`, `user`; `AccessProvider` adds `access`): `getToken`, `setToken`, `getRefreshToken`, `setRefreshToken`, `getUser`, `setUser`, `clearAuth`, `isAuthenticated`.
354
+
355
+ ## Files
356
+
357
+ ```
358
+ src/
359
+ index.js ─ every export
360
+ api.js ─ configure, authFetch, the auth calls
361
+ adminApi.js, masterApi.js ─ RBAC and master-data calls, menu items
362
+ token.js ─ token and user storage
363
+ mt.js, activeScope.js ─ multi-tenant levels and the scope per level
364
+ returnTo.js ─ where to go after a gate
365
+ menuLabels.js ─ labelMenuItems
366
+ AccessContext.jsx ─ AccessProvider, useAccess, useAccessStrict
367
+ ProtectedRoute.jsx, AccessGuard.jsx
368
+ ThemeContext.jsx ─ ThemeProvider, useTheme
369
+ use*Controller.js ─ one controller per screen, and useNavController
370
+ validateDesign.js ─ useDesignValidator and the *_RULES
371
+ pages.jsx ─ controller + design, per screen; NavPage
372
+ authRoutes.jsx ─ authRoutes, authPath
373
+ designs/ ─ the *Sample designs, nav parts, theme.css, auth.css, admin.css, nav.css
374
+ ```
375
+
376
+ ## Tests
377
+
378
+ ```sh
379
+ npm test # node --test test/*.test.js
380
+ ```
381
+
382
+ ## License
383
+
384
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/ui-account",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
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",
@@ -33,5 +33,8 @@
33
33
  "peerDependencies": {
34
34
  "react": "^18.0.0 || ^19.0.0",
35
35
  "react-router-dom": "^6.0.0 || ^7.0.0"
36
+ },
37
+ "scripts": {
38
+ "test": "node --test test/*.test.js"
36
39
  }
37
40
  }
package/src/adminApi.js CHANGED
@@ -37,3 +37,24 @@ export function toggleModuleRole({ module, action, roleId, assign }) {
37
37
  body: JSON.stringify({ module, action, roleId, assign }),
38
38
  });
39
39
  }
40
+
41
+ // ── Menu items: key, label, order, visibility (Super Admin) ──────────────
42
+
43
+ /** Every menu item, hidden ones included: [{ name, label, shown, sortOrder, isHidden, isPublic }]. */
44
+ export function listMenuItems() {
45
+ return authFetch('/auth/api/admin/menu-items');
46
+ }
47
+
48
+ /** Rename / reorder / hide by key: [{ name, label?, sortOrder?, isHidden? }]. */
49
+ export function saveMenuItems(items) {
50
+ return authFetch('/auth/api/admin/menu-items', { method: 'POST', body: JSON.stringify({ items }) });
51
+ }
52
+
53
+ /** A new item — e.g. a form added to the menu: { name: key, label }. */
54
+ export function addMenuItem(item) {
55
+ return authFetch('/auth/api/admin/menu-items/add', { method: 'POST', body: JSON.stringify(item) });
56
+ }
57
+
58
+ export function removeMenuItem(name) {
59
+ return authFetch('/auth/api/admin/menu-items/remove', { method: 'POST', body: JSON.stringify({ name }) });
60
+ }
@@ -52,8 +52,8 @@ function AccountMenu({
52
52
 
53
53
  {(accountItems || []).map(function(item) {
54
54
  return (
55
- <Link key={item.name} to={item.path} className="xeplr-nav-account-item" role="menuitem" onClick={closeAccount}>
56
- {item.name}
55
+ <Link key={item.key || item.name} to={item.path} className="xeplr-nav-account-item" role="menuitem" onClick={closeAccount}>
56
+ {item.label || item.name}
57
57
  </Link>
58
58
  );
59
59
  })}
@@ -55,7 +55,7 @@ function NavDrawer({
55
55
  var visibleItems = useMemo(function() {
56
56
  if (!drawerOpen || !query.trim()) return drawerItems;
57
57
  var q = query.trim().toLowerCase();
58
- return drawerItems.filter(function(item) { return item.name.toLowerCase().indexOf(q) !== -1; });
58
+ return drawerItems.filter(function(item) { return String(item.label || item.name).toLowerCase().indexOf(q) !== -1; });
59
59
  }, [drawerItems, query, drawerOpen]);
60
60
 
61
61
  var buckets = useMemo(function() { return bucketItems(visibleItems); }, [visibleItems]);
@@ -74,13 +74,13 @@ function NavDrawer({
74
74
  var badge = item.badge === 0 || item.badge == null || item.badge === '' ? null : item.badge;
75
75
  return (
76
76
  <button
77
- key={item.name}
77
+ key={item.key || item.name}
78
78
  type="button"
79
79
  className={'xeplr-nav-drawer-link' + (badge ? ' xeplr-nav-drawer-link-badged' : '')}
80
80
  onClick={item.clickHandler}
81
81
  // The count belongs in the tooltip too — the collapsed dot says
82
82
  // "something", and the hover has to say how many.
83
- title={badge ? item.name + ' (' + badge + ')' : item.name}
83
+ title={badge ? (item.label || item.name) + ' (' + badge + ')' : (item.label || item.name)}
84
84
  >
85
85
  {item.icon && (
86
86
  <span className="xeplr-nav-drawer-icon">
@@ -88,7 +88,7 @@ function NavDrawer({
88
88
  {badge && !drawerOpen && <span className="xeplr-nav-drawer-dot" aria-hidden="true" />}
89
89
  </span>
90
90
  )}
91
- {drawerOpen && <span className="xeplr-nav-drawer-label">{item.name}</span>}
91
+ {drawerOpen && <span className="xeplr-nav-drawer-label">{item.label || item.name}</span>}
92
92
  {drawerOpen && badge && <span className="xeplr-nav-drawer-badge">{badge}</span>}
93
93
  </button>
94
94
  );
package/src/index.js CHANGED
@@ -41,7 +41,8 @@ export { ProtectedRoute } from './ProtectedRoute.jsx';
41
41
  export { AccessGuard } from './AccessGuard.jsx';
42
42
 
43
43
  // Admin API (model layer for RBAC management)
44
- export { getUsers, getRoles, getAccessItems, toggleUserRole, toggleAccessRole, toggleModuleRole } from './adminApi.js';
44
+ export { getUsers, getRoles, getAccessItems, toggleUserRole, toggleAccessRole, toggleModuleRole, listMenuItems, saveMenuItems, addMenuItem, removeMenuItem } from './adminApi.js';
45
+ export { labelMenuItems } from './menuLabels.js';
45
46
  export { getMasterItems, saveMasterItem, deleteMasterItem, MASTER_TYPES } from './masterApi.js';
46
47
 
47
48
  // Design validation (use when building custom designs)
@@ -0,0 +1,40 @@
1
+ // MENU KEYS AND LABELS — no React.
2
+ //
3
+ // An app lists its menu items by KEY (drawerItems: [{ key: 'Tasks', icon, … }]).
4
+ // The server says which keys this person may see, what each is CALLED, and in
5
+ // what order (access.menuItems — renamed from the app without touching code).
6
+ //
7
+ // `name` is still accepted as the key, so drawer catalogs written before keys
8
+ // existed keep working; with no label from the server, the item's own `label`
9
+ // or its key is shown.
10
+
11
+ /**
12
+ * @param catalog the app's items: [{ key | name, label?, icon?, … }]
13
+ * @param access { menus: [keys], menuItems?: [{ name, label, sortOrder }] }
14
+ * @returns the items this person may see, each with `key` and `label`, in the server's order
15
+ */
16
+ export function labelMenuItems(catalog, access) {
17
+ var allowed = (access && access.menus) || [];
18
+ var fromServer = {};
19
+ var position = {};
20
+ ((access && access.menuItems) || []).forEach(function(m, i) {
21
+ if (!m || !m.name) return;
22
+ fromServer[m.name] = m.label;
23
+ position[m.name] = i;
24
+ });
25
+
26
+ return (catalog || [])
27
+ .map(function(item, i) {
28
+ var key = item.key || item.name;
29
+ return { item: item, key: key, index: i };
30
+ })
31
+ .filter(function(x) { return x.key && allowed.indexOf(x.key) !== -1; })
32
+ .sort(function(a, b) {
33
+ var pa = position[a.key] === undefined ? Infinity : position[a.key];
34
+ var pb = position[b.key] === undefined ? Infinity : position[b.key];
35
+ return pa !== pb ? pa - pb : a.index - b.index;
36
+ })
37
+ .map(function(x) {
38
+ return Object.assign({}, x.item, { key: x.key, label: fromServer[x.key] || x.item.label || x.key });
39
+ });
40
+ }
@@ -1,4 +1,5 @@
1
1
  import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
2
+ import { labelMenuItems } from './menuLabels.js';
2
3
  import { useAccessStrict } from './AccessContext.jsx';
3
4
  import { authPath } from './authRoutes.jsx';
4
5
 
@@ -180,21 +181,22 @@ export function useNavController(props) {
180
181
  // Settings dropdown, in render order: app overrides first, then the fixed
181
182
  // builtin section — role-filtered the same way for both.
182
183
  var accountItems = useMemo(function() {
183
- var overrides = (props.settingsOverrides || [])
184
- .filter(function(item) { return allowedMenus.indexOf(item.name) !== -1; });
185
- var builtin = BUILTIN_SETTINGS_ITEMS
186
- .filter(function(item) { return allowedMenus.indexOf(item.menuName) !== -1; })
187
- .map(function(item) { return { name: item.menuName, path: authPath(item.authKey) }; });
184
+ var overrides = labelMenuItems(props.settingsOverrides, access);
185
+ // Same key → label treatment as the app's own items, so a Super Admin's
186
+ // rename of "Profile" shows here too.
187
+ var builtin = labelMenuItems(BUILTIN_SETTINGS_ITEMS.map(function(item) {
188
+ return { key: item.menuName, name: item.menuName, path: authPath(item.authKey) };
189
+ }), access);
188
190
  return overrides.concat(builtin);
189
- }, [allowedMenus, props.settingsOverrides]);
191
+ }, [allowedMenus, access, props.settingsOverrides]);
190
192
 
191
193
  var notificationsAllowed = allowedMenus.indexOf(NOTIFICATIONS_MENU_NAME) !== -1;
192
194
 
193
- // Role-filter the app's own drawer catalog down to what this user can actually see.
195
+ // Role-filter the app's own drawer catalog down to what this user can see —
196
+ // matched by KEY, shown by the LABEL the server holds, in the server's order.
194
197
  var drawerItems = useMemo(function() {
195
- var catalog = props.drawerItems || [];
196
- return catalog.filter(function(m) { return allowedMenus.indexOf(m.name) !== -1; });
197
- }, [props.drawerItems, allowedMenus]);
198
+ return labelMenuItems(props.drawerItems, access);
199
+ }, [props.drawerItems, access]);
198
200
 
199
201
  return {
200
202
  user: accessCtx.user,