@xeplr/ui-account
v1.4.1
Published
Account UI: auth, profile, RBAC admin, tenant management — React controller hooks and designs
Maintainers
Readme
@xeplr/ui-account
The React sign-in UI and app shell for @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.
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.
(The package name on npm is @xeplr/ui-account — the GitHub repo and folder are named xeplr-ui-account.)
Install
npm i @xeplr/ui-account react-router-domPeer dependencies: react ^18 || ^19 and react-router-dom ^6 || ^7. @xeplr/ui-utils ^1.1.2 comes as a dependency (the controllers report success and failure with its raiseSnackbar, and keep the admin pages' tabs in the URL with its useUrlParams).
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).
Setup (main.jsx)
import { createRoot } from 'react-dom/client'
import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom'
import {
configure, ThemeProvider, AccessProvider, ProtectedRoute, NavPage, authRoutes, authPath
} from '@xeplr/ui-account'
configure('http://localhost:19001') // where @xeplr/auth's /auth/api/* is served; '' = same origin
function Shell() {
return (
<div className="app"> {/* page background goes HERE, not on body */}
<NavPage logo="/logo.svg" drawerItems={drawerItems} />
<main className="app-main"><Outlet /></main>
</div>
)
}
createRoot(document.getElementById('root')).render(
<ThemeProvider theme="dark">
<AccessProvider>
<BrowserRouter>
<Routes>
{authRoutes({}, { layout: <Shell /> })}
<Route element={<ProtectedRoute><Shell /></ProtectedRoute>}>
<Route path="/" element={<Home />} />
</Route>
</Routes>
</BrowserRouter>
</AccessProvider>
</ThemeProvider>
).app { min-height: 100vh; background: var(--xeplr-bg-primary); color: var(--xeplr-text-primary); }
.app-main { padding-left: 48px; } /* room for the collapsed rail */Link to auth pages with authPath(key) rather than a literal URL: <Link to={authPath('profile')}>.
Rules
<ThemeProvider>is required.theme.cssdefines every--xeplr-*variable only inside the.xeplr-theme-dark,-light,-mediumand-brightclasses — there is no:rootfallback. Without a theme class above them, every screen renders unstyled and nothing reports an error.ThemeProviderrenders that class on a wrapper<div>(you may instead put the class on a container yourself).- Set the page background on a container inside the theme wrapper, not on
body.bodyis outside the wrapper<div>, sovar(--xeplr-bg-primary)there resolves to nothing. - Reserve room for the rail. The drawer is
position: fixedat 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. - The rail only renders when the person may see at least one drawer item. If none of
drawerItemssurvives the access filter,NavPagerenders the top bar instead (in normal flow, not fixed). If that can happen in your app, decide the padding withlabelMenuItems(drawerItems, access).length > 0.
Themes
| export | what it is |
|---|---|
| ThemeProvider | props theme (initial, default 'dark'), persist (default true). With persist, the last choice saved in localStorage (xeplr-theme) wins over theme. |
| useTheme() | { theme, setTheme, themes, className }, or null outside a provider |
| useThemeStrict() | the same, throws outside a provider |
| BUILT_IN_THEMES, DEFAULT_THEME | ['dark', 'light', 'medium', 'bright'], 'dark' |
A custom theme is a class with the same variables — .xeplr-theme-corporate { --xeplr-bg-primary: …; … } — used as <ThemeProvider theme="corporate">.
Auth routes
authRoutes(overrides, opts) returns an array of <Route> — spread it inside <Routes>.
| key | path | group |
|---|---|---|
| login | /auth/login | public |
| register | /auth/register | public |
| forgotPassword | /auth/forgot-password | public |
| resetPassword | /auth/reset-password | public |
| activate | /auth/activate | public |
| notActivated | /auth/not-activated | public |
| resendActivation | /auth/resend-activation | public |
| acceptInvite | /auth/accept-invite | public — where @xeplr/auth's invite emails point (AUTH_INVITE_URL) |
| profile | /auth/profile | account |
| changePassword | /auth/change-password | account |
| userRoles | /auth/admin/user-roles | admin |
| accessMatrix | /auth/admin/access-matrix | admin |
| masterSettings | /auth/admin/master-settings | admin |
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.
authRoutes({
login: { design: 2, props: { brand: { name: 'Acme' } } }, // built-in login design 2 (of 1–5)
register: { design: MyRegister }, // framework controller + validation, your look
notActivated: false, // drop the route
profile: <MyProfile />, // full replace (same as { element: <MyProfile /> })
userRoles: { element: <ProtectedRoute roles={['Super Admin']}><UserRolesPage /></ProtectedRoute> },
forgotPassword: { path: '/forgot', design: MyForgot }, // mount at another path
accessMatrix: { props: { loadWorkspaces } } // props for the page's controller
}, { layout: <Shell />, loginPath: '/auth/login', raw: true })| opt | default | meaning |
|---|---|---|
| layout | none | element wrapping the account and admin pages |
| loginPath | /auth/login | where ProtectedRoute sends a signed-out visitor |
| raw | true | also mount every page untouched at /_<name> (/_login, /_admin/user-roles) — the framework page, ignoring overrides and the layout |
authPath(key) returns the manifest path for a key, or null. It does not follow a path override.
Access
const { user, access, authenticated, hasMenu, refreshAccess } = useAccess()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.
| field | what it is |
|---|---|
| user, access, authenticated | current state; access is { roles, pages, apis, menus, menuItems, elements } as @xeplr/auth sends it |
| hasPage(name), hasApi(name), hasMenu(name), hasElement(name), hasRole(name) | true when the name is in that list |
| 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. |
| onLogin(result) | stores a login response (useLoginController calls it) |
| logout() | clears tokens, access and active scope, tells the server (best effort) |
| setAccess(access) | replace access by hand |
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.
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.
Guards
<ProtectedRoute page="/reports" roles={['admin']} deniedPath="/"><Reports /></ProtectedRoute>
<AccessGuard element="btn-delete-user" fallback={null}>
<button onClick={remove}>Delete</button>
</AccessGuard>| component | props | behaviour |
|---|---|---|
| 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 |
| AccessGuard | element, menu, page, role, fallback (default null) | renders children only when every given check passes |
These only shape the UI. The server's own middleware decides what an API returns.
authFetch
import { authFetch } from '@xeplr/ui-account'
const rows = await authFetch('/api/tasks') // the parsed body
const saved = await authFetch('/api/tasks', { method: 'POST', body: JSON.stringify(task) })It returns the parsed JSON body, not a Response — there is no .json() to call. It:
prefixes the
configure()base URL (a URL starting withhttpis used as is) and sendsContent-Type: application/jsonunless you pass your own — except for aFormDatabody, which describes itself (multipart, with a boundary), so an upload can go throughauthFetchlike anything else:const form = new FormData() form.append('file', file) const saved = await authFetch('/factory/files/task_edit/brief', { method: 'POST', body: form })attaches
Authorization: Bearer <token>;attaches one header per registered multi-tenant level whose active scope has an
id(see Multi-tenancy);stores the token from an
X-New-Tokenresponse header —@xeplr/auth's sliding refresh, so most expiries never become a 401;on a 401, rotates tokens once through
POST /auth/api/refresh(concurrent calls share one refresh) and retries the request once. If the server refuses the refresh, it clears the stored auth and calls the session-expired handler. If the server cannot be reached at all, the session is kept and the call fails with "Could not reach the server" — a network blip does not sign anyone out.
On failure it throws an Error:
| field | when | what |
|---|---|---|
| message | always | the server's error string, else error.message, else message, else Something went wrong |
| status | always | HTTP status |
| body | non-2xx | the parsed error body |
| code, busy | when the body has them | copied from the body |
| request | always | "POST http://…/api/tasks" |
| rawBody, contentType, bodyLength, parseError | the reply was not JSON | the first 600 characters and what was wrong |
Every reply must be JSON: an empty body (a 204, say) throws too. Each failure is also logged with console.error, naming the request.
configure(baseUrl, { onSessionExpired }) sets the base URL and optionally a handler; setSessionExpiredHandler(fn) sets the handler on its own (AccessProvider does this for you).
Menu keys and labels
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.
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.)
const navigate = useNavigate()
const drawerItems = [
{ key: 'Tasks', icon: <TaskIcon />, path: '/tasks', clickHandler: () => navigate('/tasks') },
{ key: 'Reports', icon: <ReportIcon />, path: '/reports', clickHandler: () => navigate('/reports'), group: 'Insights', badge: 3 },
]
const settingsOverrides = [{ key: 'Admin', path: '/admin' }]- 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/authand grant it before looking anywhere else. - Give every drawer item an
icon. The collapsed rail shows icons only; an item without one is an empty button there. groupis a section header, written in code and shown as is. Ungrouped items come first.pathmarks the item as the current page (accent icon on a tinted tile,aria-current="page") when the URL is that path or under it —/tasksstays marked on/tasks/12.active: true|falseoverrides 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.- Every rail control (links, logo, bell, settings) is a 36px tile with a hover, a visible press (
:activeshrink), and a keyboard-only focus ring; settings stays pressed while its menu is open. badge(number or short string) shows as a pill when expanded and a dot on the icon when collapsed;0,''andnullshow nothing.
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.
Changing the menu
| call | server | |
|---|---|---|
| listMenuItems() | GET /auth/api/admin/menu-items | every item, hidden ones included: [{ name, label, shown, sortOrder, isHidden, isPublic }] |
| saveMenuItems(items) | POST /auth/api/admin/menu-items | rename / reorder / hide by key: [{ name, label?, sortOrder?, isHidden? }] |
| addMenuItem({ name, label }) | POST /auth/api/admin/menu-items/add | a new item; name is its key |
| removeMenuItem(name) | POST /auth/api/admin/menu-items/remove | |
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.
NavPage
<NavPage
logo="/icon.svg" expandedLogo="/lockup.svg"
drawerItems={drawerItems}
settingsOverrides={settingsOverrides}
notifications={{ count: unread, onClick: openFeed }}
/>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 />.
| prop | applies to | meaning |
|---|---|---|
| drawerItems | drawer | [{ key, icon, clickHandler, path?, active?, group?, badge? }] — a non-empty visible list turns the drawer on |
| settingsOverrides | both | [{ key, path }], shown above the built-in items in the settings menu |
| notifications | both | { count, onClick } — the bell shows only when this is given and access.menus contains Notifications; a count above 9 shows 9+ |
| logo | both | image URL: the top bar's logo, the collapsed rail's toggle |
| expandedLogo | drawer | image URL shown when expanded |
| drawerPromo | drawer | node shown under the links when expanded |
| floatingSettings | drawer | true moves the bell and settings out of the rail footer to the page's top-right corner |
| navMiddle | top bar | node for the middle column (e.g. a company picker) |
| design | top bar | your own top bar component (default NavTopSample) |
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.
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).
Multi-tenancy
import { registerMTs, setActiveScope } from '@xeplr/ui-account'
registerMTs({
l1: { name: 'companyId', header: 'x-company-id' },
l2: { name: 'workspaceId', header: 'x-workspace-id' }
})
setActiveScope('l1', { id: 'acme-co', name: 'Acme Co' }) // every authFetch now sends x-company-id: acme-coCall 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.
| export | what it does |
|---|---|
| getActiveScope(level) / setActiveScope(level, scope) | the active value per level, in localStorage (xeplr:activeScope:<level>); authFetch sends its id. null removes it. |
| clearActiveScope(level?) | one level, or every level when omitted (logout does this) |
| 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. |
| getMtConfig() | a copy of the registered slots |
Returning after a gate
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.
Three ways to use it
- Ready-made —
authRoutes(), or the pages directly:LoginPage,RegisterPage,ForgotPasswordPage,ResetPasswordPage,ResendActivationPage,AcceptInvitePage,ActivatePage,NotActivatedPage,ChangePasswordPage,ProfilePage,UserRolesPage,AccessMatrixPage,MasterSettingsPage,NavPage. Other props (onSuccess, …) go to the controller. - Your own design —
<LoginPage design={MyLogin} />orauthRoutes({ login: { design: MyLogin } }). Your component receives the controller's return value as props, and the page still checks it for the required elements (below). - Hooks and model — call a
use*Controllerhook 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.
Controllers
| hook | options | returns |
|---|---|---|
| useLoginController | onSuccess(result), notActivatedPath | email, setEmail, password, setPassword, error, loading, handleSubmit |
| useRegisterController | onSuccess | form, error, success, loading, handleChange, handleSubmit |
| useForgotPasswordController | — | email, setEmail, error, success, loading, handleSubmit |
| useResetPasswordController | — | token, password, setPassword, error, success, loading, handleSubmit |
| useActivateController | — | token, error, success, loading |
| useResendActivationController | onSuccess | email, setEmail, error, success, loading, handleSubmit |
| useAcceptInviteController | onSuccess | token, invitee, name, setName, password, setPassword, confirmPassword, setConfirmPassword, error, success, checking, loading, handleSubmit |
| useChangePasswordController | onSuccess, loginPath | currentPassword, setCurrentPassword, newPassword, setNewPassword, confirmPassword, setConfirmPassword, error, success, loading, handleSubmit |
| useProfileController | onSuccess | form, error, success, loading, fetching, handleChange, handleSubmit |
| useUserRolesController | — | users, roles, search, setSearch, loading, error, saving, handleToggle, handleActivate, isAssigned, reload, newRoleName, setNewRoleName, creatingRole, createRole |
| useAccessMatrixController | workspaces or loadWorkspaces, urlKey | 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 |
| useMasterSettingsController | urlKey | tabs, activeTab, setActiveTab, currentTab, items, groupNames, search, setSearch, loading, error, saving, editingItem, editForm, startAdd, startEdit, cancelEdit, updateField, handleSave, handleDelete, reload, isSuperAdmin, handleScopeToggle, isScopeSaving |
| useNavController | drawerItems, settingsOverrides, notifications | user, accountItems, notifications, logout, accountOpen, toggleAccount, closeAccount, accountRef, drawerOpen, toggleDrawer, closeDrawer, drawerItems, drawerWidth, drawerResizing, startDrawerResize, resetDrawerWidth, nudgeDrawerWidth, drawerWidthBounds |
- Login stores the tokens and calls
AccessProvider'sonLoginwhen a provider is present. A login refused as not activated (403,code: 'NOT_ACTIVATED'; the older server's sentence is still recognised) navigates tonotActivatedPath(default/auth/not-activated). SupplyingonSuccessreplaces the default navigation entirely. - Register replaces the form with "Check your email" and a link to resend. Not-activated and a failed activation link both offer Send the link again (
/auth/resend-activation). - Accept invite reads
?token=, shows who the invite is for (invite-preview), then sets name and password (invite-accept), which activates the account. - Change password signs the person out afterwards and goes to
loginPath(default/auth/login) — the server ends every session, this one included. WithonSuccess, navigation is yours. - User roles shows Invited on people who have not accepted their invite yet (they activate by accepting it), and Not activated on accounts waiting for activation, with an Activate button (
handleActivate(userId)) for when the mail never arrived. The server also grants the app's sign-up roles, so the list reloads. - Register, profile:
handleChangereadse.target.name, so inputs needname="email"etc.formis{ name, email, phoneNumber, password }/{ name, email, phoneNumber }. - Reset and activate read
?token=(activate also passes?workflowKey=back to the server) and activate runs on mount. - 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. - 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.isSuperAdmincomes fromAccessProvider'shasRole. - 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. - 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. - 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;handleModuleTogglestill works for designs written before. - 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 } } }), orworkspacesdirectly. - 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/auth0013). Workspace and user overrides are not stored yet: the server answersSCOPE_NOT_SUPPORTED, and the matrix says so and refuses to save them rather than pretending they were saved. MASTER_TYPESis['roles', 'apis', 'pages', 'elements', 'menus'].
Where you are lives in the URL
The admin pages are routes of their own, so the tab you are on and the row you are editing are places: they are read from and written to the URL's query, and a refresh or a shared link lands on the same screen.
| page | key (default) | holds | history |
|---|---|---|---|
| Master Settings | tab | roles (absent), apis, pages, elements, menus | switching replaces the entry |
| Master Settings | item | the id of the row being edited, or __new__ for the add row | opening or closing adds one, so Back closes the editor |
| Access Matrix | view | modules (absent) or uncategorized | replaces |
| Access Matrix | uncat | the Uncategorized sub-tab: apis (absent), pages, elements, menus | replaces |
| Access Matrix | appliesTo | who the module switches apply to: role (absent), workspace, user | replaces |
| Access Matrix | scope | the chosen workspace or user id (cleared when appliesTo changes) | replaces |
urlKeychanges the keys. Pass a prefix when the page shares its URL with keys of your own:urlKey: 'ms'→msTab,msItem;urlKey: 'am'→amView,amUncat.urlKey: falsekeeps everything in memory, as before. Pages take it as a prop (<MasterSettingsPage urlKey="ms" />,authRoutes({ masterSettings: { props: { urlKey: 'ms' } } })); the controllers as an option.- Ids, not rows.
itemis resolved against the tab's rows once they have loaded, soeditingItemstaysnulluntil then, and a row that is gone opens nothing. The form being typed is not in the URL; reopening a row starts from its saved values. - Values from the URL are checked. An unknown tab, view or sub-tab falls back to the default, so an edited link never reaches the API as a type.
- The keys and checks are pure functions in
adminUrlState.js(adminUrlKey,oneOf,findItem,blankForm,itemForm).
Design validation
Each page (except activate and not-activated) checks its rendered design three seconds after mounting and, outside production, console.warns what is missing. useDesignValidator(name, rules) does the check for your own components.
| rules | required |
|---|---|
| LOGIN_RULES | #xeplr-email, #xeplr-password, button[type="submit"] |
| REGISTER_RULES | #xeplr-name, #xeplr-email, #xeplr-password, submit |
| FORGOT_PASSWORD_RULES | #xeplr-email, submit |
| RESET_PASSWORD_RULES | #xeplr-password, submit |
| RESEND_ACTIVATION_RULES | #xeplr-email, submit |
| ACCEPT_INVITE_RULES | #xeplr-password, #xeplr-confirm-password, submit |
| CHANGE_PASSWORD_RULES | #xeplr-current-password, #xeplr-new-password, #xeplr-confirm-password, submit |
| PROFILE_RULES | #xeplr-profile-name, #xeplr-profile-email, submit |
| USER_ROLES_MATRIX_RULES | #xeplr-admin-user-search, [role="grid"], #xeplr-admin-new-role |
| ACCESS_MATRIX_RULES | #xeplr-admin-access-search, [role="tablist"], #xeplr-admin-access-scope (the Applies-to radiogroup) |
| MASTER_SETTINGS_RULES | #xeplr-admin-master-search, [role="tablist"] |
| NAV_RULES | [aria-haspopup="menu"] |
Login designs
The login page comes in five built-in designs. Each fills the whole screen with the theme's colours, so it never shows as a dark box on the browser's white page — the theme variables live on the ThemeProvider wrapper, not on body, so a design that styles only a card leaves everything around it unstyled.
| design | Export | Look |
|---|---|---|
| 1 (default) | LoginDesign1 | Card — a raised card on a soft dotted page |
| 2 | LoginDesign2 | Split — a brand panel on the left, the form on the right (panel on top on phones) |
| 3 | LoginDesign3 | Minimal — no card, a large heading and underlined fields |
| 4 | LoginDesign4 | Glow — a frosted card over soft light in the accent colour |
| 5 | LoginDesign5 | App bar — a product header, the form, a footer |
Pick one by number, or pass the component — both work in authRoutes and on LoginPage. brand is optional and only changes what the design shows:
authRoutes({ login: { design: 3, props: { brand: { name: 'Acme', tagline: 'Reports your team can trust.', logo: '/logo.svg' } } } })
<LoginPage design={LoginDesign2} brand={{ name: 'Acme' }} />Without a logo, the designs show the first letter of name as a mark. An unknown number warns and shows design 1. All five share one form (#xeplr-email, #xeplr-password with a show/hide button, the submit button, forgot-password and register links), so they all pass LOGIN_RULES. Styles are namespaced .xeplr-login-*.
Designs
LoginDesign1–LoginDesign5 (above), LoginSample (the old plain login box, no longer the default), RegisterSample, ForgotPasswordSample, ResetPasswordSample, ResendActivationSample, AcceptInviteSample, 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-*.
API
Unauthenticated calls go straight to fetch; the rest use authFetch. All resolve to the parsed body and throw the same Error shape.
| function | request |
|---|---|
| registerUser({ email, password, name, phoneNumber }) | POST /auth/api/register |
| loginUser({ email, password }) | POST /auth/api/login → { accessToken, refreshToken, user, access } (does not store them — the login controller does) |
| activateAccount(token, workflowKey?) | GET /auth/api/activate?token=… |
| resendActivation({ email }) | POST /auth/api/resend-activation — the same answer for any address |
| previewInvite(token) | GET /auth/api/invite-preview?token=… → { email, name } |
| acceptInvite({ token, name, password }) | POST /auth/api/invite-accept |
| forgotPassword({ email }) | POST /auth/api/forgot-password |
| resetPassword({ token, newPassword }) | POST /auth/api/reset-password |
| changePassword({ currentPassword, newPassword }) | POST /auth/api/change-password (auth) |
| getMe() | GET /auth/api/me → { user, access } (auth) |
| getProfile() / updateProfile(fields) | GET / PUT /auth/api/profile (auth) |
| uploadAvatar(file) | POST /auth/api/profile/avatar, multipart. Sends the token only — no scope headers, no refresh retry. |
| logoutUser() | clears stored auth and active scope, then POST /auth/api/logout without waiting |
Admin (auth):
| function | request |
|---|---|
| getUsers(), getRoles(), getAccessItems() | GET /auth/api/admin/users, /roles, /access-items |
| activateUser(userId) | POST /auth/api/admin/users/:id/activate |
| toggleUserRole({ userId, roleId, assign }) | POST /auth/api/admin/user-role |
| toggleAccessRole({ type, itemId, roleId, assign }) | POST /auth/api/admin/access-role |
| toggleModuleRole({ module, action, roleId, assign }) | POST /auth/api/admin/module-role |
| 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 |
| 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 |
| setApiScope(id, scope) | POST /auth/api/admin/master/apis/scope. Super Admin only; scope is system or company |
| getMasterItems(type), saveMasterItem(type, data), deleteMasterItem(type, id) | GET / POST /auth/api/admin/master/<type>, POST …/<type>/delete |
| listMenuItems, saveMenuItems, addMenuItem, removeMenuItem | see Changing the menu |
Token storage (localStorage keys accessToken, refreshToken, user; AccessProvider adds access): getToken, setToken, getRefreshToken, setRefreshToken, getUser, setUser, clearAuth, isAuthenticated.
Files
src/
index.js ─ every export
api.js ─ configure, authFetch, the auth calls
adminApi.js, masterApi.js ─ RBAC and master-data calls, menu items
token.js ─ token and user storage
mt.js, activeScope.js ─ multi-tenant levels and the scope per level
returnTo.js ─ where to go after a gate
menuLabels.js ─ labelMenuItems
adminPaths.js ─ ADMIN_PAGES: the admin pages, their labels and paths
accessStates.js ─ enabled / disabled / hidden / inherit: cell states and what a change sends
adminUrlState.js ─ the admin pages' URL keys (tab, item, view, uncat) and checks on values read back
AccessContext.jsx ─ AccessProvider, useAccess, useAccessStrict
ProtectedRoute.jsx, AccessGuard.jsx
ThemeContext.jsx ─ ThemeProvider, useTheme
use*Controller.js ─ one controller per screen, and useNavController
validateDesign.js ─ useDesignValidator and the *_RULES
pages.jsx ─ controller + design, per screen; NavPage
authRoutes.jsx ─ authRoutes, authPath
designs/ ─ the *Sample designs, LoginDesigns.jsx + LoginForm.jsx + login.css, nav parts, theme.css, auth.css, admin.css, nav.cssTests
npm test # node --test test/*.test.jsLicense
MIT
