@xeplr/ui-account 1.0.7 → 1.0.9

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,391 @@
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 — **except for a `FormData` body**, which describes itself (multipart, with a boundary), so an upload can go through `authFetch` like anything else:
168
+
169
+ ```js
170
+ const form = new FormData()
171
+ form.append('file', file)
172
+ const saved = await authFetch('/factory/files/task_edit/brief', { method: 'POST', body: form })
173
+ ```
174
+
175
+ - attaches `Authorization: Bearer <token>`;
176
+ - attaches one header per registered multi-tenant level whose active scope has an `id` (see [Multi-tenancy](#multi-tenancy));
177
+ - stores the token from an `X-New-Token` response header — `@xeplr/auth`'s sliding refresh, so most expiries never become a 401;
178
+ - 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.
179
+
180
+ On failure it **throws an `Error`**:
181
+
182
+ | field | when | what |
183
+ |---|---|---|
184
+ | `message` | always | the server's `error` string, else `error.message`, else `message`, else `Something went wrong` |
185
+ | `status` | always | HTTP status |
186
+ | `body` | non-2xx | the parsed error body |
187
+ | `code`, `busy` | when the body has them | copied from the body |
188
+ | `request` | always | `"POST http://…/api/tasks"` |
189
+ | `rawBody`, `contentType`, `bodyLength`, `parseError` | the reply was not JSON | the first 600 characters and what was wrong |
190
+
191
+ Every reply must be JSON: an empty body (a `204`, say) throws too. Each failure is also logged with `console.error`, naming the request.
192
+
193
+ `configure(baseUrl, { onSessionExpired })` sets the base URL and optionally a handler; `setSessionExpiredHandler(fn)` sets the handler on its own (`AccessProvider` does this for you).
194
+
195
+ ## Menu keys and labels
196
+
197
+ `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.
198
+
199
+ **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.)
200
+
201
+ ```jsx
202
+ const navigate = useNavigate()
203
+ const drawerItems = [
204
+ { key: 'Tasks', icon: <TaskIcon />, clickHandler: () => navigate('/tasks') },
205
+ { key: 'Reports', icon: <ReportIcon />, clickHandler: () => navigate('/reports'), group: 'Insights', badge: 3 },
206
+ ]
207
+ const settingsOverrides = [{ key: 'Admin', path: '/admin' }]
208
+ ```
209
+
210
+ - **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
+ - **Give every drawer item an `icon`.** The collapsed rail shows icons only; an item without one is an empty button there.
212
+ - `group` is a section header, written in code and shown as is. Ungrouped items come first.
213
+ - `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
+
215
+ `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.
216
+
217
+ ### Changing the menu
218
+
219
+ | call | server | |
220
+ |---|---|---|
221
+ | `listMenuItems()` | `GET /auth/api/admin/menu-items` | every item, hidden ones included: `[{ name, label, shown, sortOrder, isHidden, isPublic }]` |
222
+ | `saveMenuItems(items)` | `POST /auth/api/admin/menu-items` | rename / reorder / hide by key: `[{ name, label?, sortOrder?, isHidden? }]` |
223
+ | `addMenuItem({ name, label })` | `POST /auth/api/admin/menu-items/add` | a new item; `name` is its key |
224
+ | `removeMenuItem(name)` | `POST /auth/api/admin/menu-items/remove` | |
225
+
226
+ 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.
227
+
228
+ ## NavPage
229
+
230
+ ```jsx
231
+ <NavPage
232
+ logo="/icon.svg" expandedLogo="/lockup.svg"
233
+ drawerItems={drawerItems}
234
+ settingsOverrides={settingsOverrides}
235
+ notifications={{ count: unread, onClick: openFeed }}
236
+ />
237
+ ```
238
+
239
+ 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 />`.
240
+
241
+ | prop | applies to | meaning |
242
+ |---|---|---|
243
+ | `drawerItems` | drawer | `[{ key, icon, clickHandler, group?, badge? }]` — a non-empty visible list turns the drawer on |
244
+ | `settingsOverrides` | both | `[{ key, path }]`, shown above the built-in items in the settings menu |
245
+ | `notifications` | both | `{ count, onClick }` — the bell shows only when this is given **and** `access.menus` contains `Notifications`; a count above 9 shows `9+` |
246
+ | `logo` | both | image URL: the top bar's logo, the collapsed rail's toggle |
247
+ | `expandedLogo` | drawer | image URL shown when expanded |
248
+ | `drawerPromo` | drawer | node shown under the links when expanded |
249
+ | `floatingSettings` | drawer | `true` moves the bell and settings out of the rail footer to the page's top-right corner |
250
+ | `navMiddle` | top bar | node for the middle column (e.g. a company picker) |
251
+ | `design` | top bar | your own top bar component (default `NavTopSample`) |
252
+
253
+ 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.
254
+
255
+ 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`).
256
+
257
+ ## Multi-tenancy
258
+
259
+ ```js
260
+ import { registerMTs, setActiveScope } from '@xeplr/ui-account'
261
+
262
+ registerMTs({
263
+ l1: { name: 'companyId', header: 'x-company-id' },
264
+ l2: { name: 'workspaceId', header: 'x-workspace-id' }
265
+ })
266
+
267
+ setActiveScope('l1', { id: 'acme-co', name: 'Acme Co' }) // every authFetch now sends x-company-id: acme-co
268
+ ```
269
+
270
+ 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.
271
+
272
+ | export | what it does |
273
+ |---|---|
274
+ | `getActiveScope(level)` / `setActiveScope(level, scope)` | the active value per level, in `localStorage` (`xeplr:activeScope:<level>`); `authFetch` sends its `id`. `null` removes it. |
275
+ | `clearActiveScope(level?)` | one level, or every level when omitted (`logout` does this) |
276
+ | `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. |
277
+ | `getMtConfig()` | a copy of the registered slots |
278
+
279
+ ### Returning after a gate
280
+
281
+ `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.
282
+
283
+ ## Three ways to use it
284
+
285
+ 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.
286
+ 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).
287
+ 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.
288
+
289
+ ### Controllers
290
+
291
+ | hook | options | returns |
292
+ |---|---|---|
293
+ | `useLoginController` | `onSuccess(result)`, `notActivatedPath` | `email, setEmail, password, setPassword, error, loading, handleSubmit` |
294
+ | `useRegisterController` | `onSuccess` | `form, error, success, loading, handleChange, handleSubmit` |
295
+ | `useForgotPasswordController` | — | `email, setEmail, error, success, loading, handleSubmit` |
296
+ | `useResetPasswordController` | — | `token, password, setPassword, error, success, loading, handleSubmit` |
297
+ | `useActivateController` | — | `token, error, success, loading` |
298
+ | `useChangePasswordController` | `onSuccess` | `currentPassword, setCurrentPassword, newPassword, setNewPassword, confirmPassword, setConfirmPassword, error, success, loading, handleSubmit` |
299
+ | `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
+ | `useNavController` | `drawerItems, settingsOverrides, notifications` | `user, accountItems, notifications, logout, accountOpen, toggleAccount, closeAccount, accountRef, drawerOpen, toggleDrawer, closeDrawer, drawerItems, drawerWidth, drawerResizing, startDrawerResize, resetDrawerWidth, nudgeDrawerWidth, drawerWidthBounds` |
304
+
305
+ - 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
+ - Register, profile: `handleChange` reads `e.target.name`, so inputs need `name="email"` etc. `form` is `{ name, email, phoneNumber, password }` / `{ name, email, phoneNumber }`.
307
+ - Reset and activate read `?token=` (activate also passes `?workflowKey=` back to the server) and activate runs on mount.
308
+ - 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.
309
+ - `MASTER_TYPES` is `['roles', 'apis', 'pages', 'elements', 'menus']`.
310
+
311
+ ### Design validation
312
+
313
+ 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.
314
+
315
+ | rules | required |
316
+ |---|---|
317
+ | `LOGIN_RULES` | `#xeplr-email`, `#xeplr-password`, `button[type="submit"]` |
318
+ | `REGISTER_RULES` | `#xeplr-name`, `#xeplr-email`, `#xeplr-password`, submit |
319
+ | `FORGOT_PASSWORD_RULES` | `#xeplr-email`, submit |
320
+ | `RESET_PASSWORD_RULES` | `#xeplr-password`, submit |
321
+ | `CHANGE_PASSWORD_RULES` | `#xeplr-current-password`, `#xeplr-new-password`, `#xeplr-confirm-password`, submit |
322
+ | `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"]` |
325
+ | `MASTER_SETTINGS_RULES` | `#xeplr-admin-master-search`, `[role="tablist"]` |
326
+ | `NAV_RULES` | `[aria-haspopup="menu"]` |
327
+
328
+ ### Designs
329
+
330
+ `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-*`.
331
+
332
+ ## API
333
+
334
+ Unauthenticated calls go straight to `fetch`; the rest use `authFetch`. All resolve to the parsed body and throw the same `Error` shape.
335
+
336
+ | function | request |
337
+ |---|---|
338
+ | `registerUser({ email, password, name, phoneNumber })` | `POST /auth/api/register` |
339
+ | `loginUser({ email, password })` | `POST /auth/api/login` → `{ accessToken, refreshToken, user, access }` (does not store them — the login controller does) |
340
+ | `activateAccount(token, workflowKey?)` | `GET /auth/api/activate?token=…` |
341
+ | `forgotPassword({ email })` | `POST /auth/api/forgot-password` |
342
+ | `resetPassword({ token, newPassword })` | `POST /auth/api/reset-password` |
343
+ | `changePassword({ currentPassword, newPassword })` | `POST /auth/api/change-password` (auth) |
344
+ | `getMe()` | `GET /auth/api/me` → `{ user, access }` (auth) |
345
+ | `getProfile()` / `updateProfile(fields)` | `GET` / `PUT /auth/api/profile` (auth) |
346
+ | `uploadAvatar(file)` | `POST /auth/api/profile/avatar`, multipart. Sends the token only — no scope headers, no refresh retry. |
347
+ | `logoutUser()` | clears stored auth and active scope, then `POST /auth/api/logout` without waiting |
348
+
349
+ Admin (auth):
350
+
351
+ | function | request |
352
+ |---|---|
353
+ | `getUsers()`, `getRoles()`, `getAccessItems()` | `GET /auth/api/admin/users`, `/roles`, `/access-items` |
354
+ | `toggleUserRole({ userId, roleId, assign })` | `POST /auth/api/admin/user-role` |
355
+ | `toggleAccessRole({ type, itemId, roleId, assign })` | `POST /auth/api/admin/access-role` |
356
+ | `toggleModuleRole({ module, action, roleId, assign })` | `POST /auth/api/admin/module-role` |
357
+ | `getMasterItems(type)`, `saveMasterItem(type, data)`, `deleteMasterItem(type, id)` | `GET` / `POST /auth/api/admin/master/<type>`, `POST …/<type>/delete` |
358
+ | `listMenuItems`, `saveMenuItems`, `addMenuItem`, `removeMenuItem` | see [Changing the menu](#changing-the-menu) |
359
+
360
+ Token storage (`localStorage` keys `accessToken`, `refreshToken`, `user`; `AccessProvider` adds `access`): `getToken`, `setToken`, `getRefreshToken`, `setRefreshToken`, `getUser`, `setUser`, `clearAuth`, `isAuthenticated`.
361
+
362
+ ## Files
363
+
364
+ ```
365
+ src/
366
+ index.js ─ every export
367
+ api.js ─ configure, authFetch, the auth calls
368
+ adminApi.js, masterApi.js ─ RBAC and master-data calls, menu items
369
+ token.js ─ token and user storage
370
+ mt.js, activeScope.js ─ multi-tenant levels and the scope per level
371
+ returnTo.js ─ where to go after a gate
372
+ menuLabels.js ─ labelMenuItems
373
+ AccessContext.jsx ─ AccessProvider, useAccess, useAccessStrict
374
+ ProtectedRoute.jsx, AccessGuard.jsx
375
+ ThemeContext.jsx ─ ThemeProvider, useTheme
376
+ use*Controller.js ─ one controller per screen, and useNavController
377
+ validateDesign.js ─ useDesignValidator and the *_RULES
378
+ pages.jsx ─ controller + design, per screen; NavPage
379
+ authRoutes.jsx ─ authRoutes, authPath
380
+ designs/ ─ the *Sample designs, nav parts, theme.css, auth.css, admin.css, nav.css
381
+ ```
382
+
383
+ ## Tests
384
+
385
+ ```sh
386
+ npm test # node --test test/*.test.js
387
+ ```
388
+
389
+ ## License
390
+
391
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/ui-account",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
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",
package/src/api.js CHANGED
@@ -319,8 +319,12 @@ function absorbNewToken(res) {
319
319
  export async function authFetch(endpoint, options = {}) {
320
320
  const url = endpoint.startsWith('http') ? endpoint : `${getBaseUrl()}${endpoint}`;
321
321
 
322
+ // A FormData body sets its own Content-Type, boundary and all. Declaring
323
+ // JSON over it makes the server read the upload as an empty JSON body, so a
324
+ // multipart request keeps the browser's own header.
325
+ const isForm = typeof FormData !== 'undefined' && options.body instanceof FormData;
322
326
  const headers = {
323
- 'Content-Type': 'application/json',
327
+ ...(isForm ? {} : { 'Content-Type': 'application/json' }),
324
328
  ...options.headers,
325
329
  };
326
330
 
@@ -182,9 +182,11 @@ export function useNavController(props) {
182
182
  // builtin section — role-filtered the same way for both.
183
183
  var accountItems = useMemo(function() {
184
184
  var overrides = labelMenuItems(props.settingsOverrides, access);
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) }; });
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
191
  }, [allowedMenus, access, props.settingsOverrides]);
190
192