Access and menus
Sign-in with @xeplr/auth and @xeplr/ui-account, covering sessions, roles and the permission catalog, the access object, menu keys and labels, and multi-tenancy.
| Package | Does |
|---|---|
@xeplr/auth |
the sign-in service: register, activate, log in, reset password, invites, sessions in Redis, the permission catalog, tenant membership |
@xeplr/ui-account |
the React side: every sign-in screen, the admin screens, NavPage (side rail or top bar), AccessProvider, guards, authFetch |
The sign-in service
Run it as its own process:
"start-auth": "dotenv -e development.env -- xeplr-auth-server"
It reads process.env only. At startup it:
- checks
REDIS_PREFIX, and refuses to start if it is missing orxeplr:; - checks that Redis is reachable, and refuses to start if it is not;
- runs auth’s own migrations, then the directories in
XEPLR_AUTH_MIGRATIONS; - configures email and checks it can send (never fatal);
- starts on
AUTH_PORT(default19001) with/auth/apiand/auth/api/adminmounted; - prints a banner of the effective configuration, with secrets masked.
You can also run it inside your own process with auth.init(), which does not
check Redis, run migrations or print the banner.
| Setting | Meaning |
|---|---|
REDIS_PREFIX |
namespace for every auth key in Redis. Required, and not xeplr: |
ENCRYPTION_KEY |
decrypts the connection string |
XEPLR_DB_CONNECTION or AUTH_DB_CONNECTION_INFO_ENCRYPTED |
the encrypted login. The second overrides the first for this service |
AUTH_DB_NAME |
the auth database |
AUTH_JWT_SECRET |
signs access tokens. The banner shows ✗ INSECURE DEFAULT when it is unset |
AUTH_SUPER_ADMIN_EMAIL, AUTH_SUPER_ADMIN_PASSWORD |
the first account, created once |
XEPLR_AUTH_MIGRATIONS |
comma-separated app migration directories |
AUTH_ACTIVATION_URL |
the full address of your activation page. The service starts without it, but registration fails |
AUTH_ACCESS_TOKEN_TTL_MINUTES (15), AUTH_REFRESH_TOKEN_TTL_DAYS (7), AUTH_ACCESS_TOKEN_TOLERANCE_SECONDS (0), AUTH_MAX_SESSIONS_PER_USER (5) |
sessions |
Sessions and sliding refresh
A session is one login on one device: a JWT access token and a random refresh token, both recorded in Redis. A logged-out token is never accepted, and a password reset or change ends every session of that user.
When AUTH_ACCESS_TOKEN_TOLERANCE_SECONDS is above 0, a token that expired
no more than that many seconds ago, on a live session, is still served, and
the response carries a fresh token in X-New-Token. Client code must read that
header and replace its stored token. authFetch already does.
The first account
Migration 0008 creates a Super Admin from AUTH_SUPER_ADMIN_EMAIL and
AUTH_SUPER_ADMIN_PASSWORD, and refuses to run while either is unset. It runs
once per database. Changing the variables later creates nothing, and an
existing account with that email keeps its password.
Roles and the permission catalog
The auth database holds roles and four kinds of item mapped to them:
| Item | Table | Checked by |
|---|---|---|
| APIs | apis |
accessMiddleware, or the req.access.apis list |
| UI pages | uiPages |
ProtectedRoute page=…, hasPage |
| UI elements | uiElements |
AccessGuard element=…, hasElement |
| Menus | menus |
NavPage, hasMenu |
Role names themselves are checked by requireRole on the server and by
ProtectedRoute roles=… in the UI.
APIs, pages and menus each have an isPublic flag, and a public item is
available to everyone. Items are grouped as module:action (for example
reports:view), and POST /auth/api/admin/module-role grants a whole group to
a role at once. Apps add their own rows through XEPLR_AUTH_MIGRATIONS.
Auth’s own migrations seed the roles Super Admin, Admin, Editor and Viewer.
Apps seed their own: Xeplr Analytics adds CompanyAdmin and Creator, and the
@xeplr/jobs and @xeplr/factory permission migrations grant to Super Admin,
CompanyAdmin, Creator and Viewer.
Guarding your API
app.use('/api', auth.accessMiddleware()) // API name = req.baseUrl + req.path, e.g. "/api/orders"
| The API is | Result |
|---|---|
not in apis |
open |
isPublic |
open |
in apis with no roles mapped |
open |
| mapped to roles | Bearer token required; 403 Access denied unless the user holds one of those roles |
Because an unregistered API is open, register every route that must be
guarded. requireRole('Admin', 'Super Admin') checks role names in the token
with no database lookup.
The /auth/api/admin routes require a valid token, and most require nothing
more. Only the menu-items routes check for Super Admin themselves. Put
accessMiddleware() or requireRole() in front of the rest.
The access object
/login, /refresh and /me return what the user may use: the union of their
roles’ mappings and every public row.
{
"roles": ["Admin"],
"pages": ["Profile", "User Roles"],
"apis": ["/auth/api/me", "/auth/api/admin/users"],
"menus": ["Tasks", "Profile"],
"menuItems": [{ "name": "Tasks", "label": "TSK", "sortOrder": 1 }, { "name": "Profile", "label": "Profile", "sortOrder": null }],
"elements": ["Assign Role Button"]
}
menusare keys, which app code matches on.menuItemshold what to show for those keys, in rail order. Hidden items are left out of both lists.
The object is cached in Redis for 5 minutes per user. Public items and API rules are cached for 10 minutes. Menu-item changes clear every cached access object. Other catalog changes clear only the shared rules, so another user’s own access can lag by up to 5 minutes.
In the UI, useAccess() exposes it:
const { user, access, authenticated, hasMenu, refreshAccess } = useAccess()
AccessProvider re-reads /auth/api/me once per page load. Call
refreshAccess() after anything that changes access, such as a role granted
or a menu renamed. The admin screens do not call it themselves.
ProtectedRoute and AccessGuard only shape the UI. The server’s own
middleware decides what an API returns.
Menu keys and labels
| Column | |
|---|---|
name |
the key. Code matches it in drawerItems and settingsOverrides, and roles are mapped to it. Never shown, never renamed |
label |
what people see. When empty, the key is shown |
sortOrder |
position in the rail. Rows without one sort after the numbered ones |
isHidden |
kept and still role-mapped, but not shown. Unlike delete, this can be undone |
The split exists so that renaming a menu in the UI cannot break the code that looks it up or the role mappings that grant it.
const drawerItems = [
{ key: 'Tasks', icon: <TaskIcon />, clickHandler: () => navigate('/tasks') },
{ key: 'Reports', icon: <ReportIcon />, clickHandler: () => navigate('/reports'), group: 'Insights', badge: 3 },
]
const settingsOverrides = [{ key: 'Admin', path: '/admin' }]
- Never write labels in code. The text shown comes from
access.menuItems, in the server’s order, not the array’s order. - An unknown key is dropped silently. A key missing from
access.menus(not seeded, not granted, hidden or misspelled) does not render and gives no warning. Seed the menu row and grant it before looking anywhere else. - Give every drawer item an
icon. The collapsed rail shows icons only.
An app’s menu rows come from its migrations-auth SQL, with name set to the
key. After that, a Super Admin changes how they look from inside the app. In an
app made by @xeplr/cli, that is Configure UI → Menu, which renames,
reorders and hides items. A form added to the menu gets the key form:<key>.
| Call | Server | |
|---|---|---|
listMenuItems() |
GET /auth/api/admin/menu-items |
every item, hidden ones included |
saveMenuItems(items) |
POST /auth/api/admin/menu-items |
rename, reorder or hide by key. Only label, sortOrder and isHidden change |
addMenuItem({ name, label }) |
POST /auth/api/admin/menu-items/add |
an existing key is relabelled and un-hidden, not duplicated |
removeMenuItem(name) |
POST /auth/api/admin/menu-items/remove |
deletes the row and its role mappings |
All four are Super Admin only on the server. Call refreshAccess() straight
afterwards to show the change.
Multi-tenancy
Membership lives in userTenantsMapping: userId, level (l1–l4, matching
mtId1–mtId4), value (the app’s own id at that level), an optional
roleId, and isActive. Auth owns no tenant tree.
On the server, @xeplr/db’s mtMiddleware trusts whatever tenant header
it is given. mtMembershipMiddleware checks that header. For each configured
level whose header is present, the caller needs an active membership row with
that value, or gets 403 Not authorized for <slot> "<value>".
app.use(authMiddleware) // needs req.user.id
app.use(mtMiddleware()) // @xeplr/db
app.use(auth.mtMembershipMiddleware()) // auth initialised in this process
// auth running as its own service:
app.use(auth.mtMembershipMiddleware({ userTenantsMapping: authAttach.model('UserTenantsMapping') }))
In the browser, register the same levels and set the active scope:
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-co
Call registerMTs with the same shape passed to @xeplr/db’s
registerMTs on the API. Nothing shares it at runtime, so keep one literal
config and import it into both. On the browser side it only tells authFetch
which header to send, and it validates nothing.
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, so there is no
.json() to call. It:
- prefixes the
configure()base URL and sendsContent-Type: application/json; - attaches
Authorization: Bearer <token>and one header per registered tenant level with an active scope; - stores the token from an
X-New-Tokenresponse header; - on a 401, refreshes once through
POST /auth/api/refreshand retries. If that refresh fails, it clears the stored auth and calls the session-expired handler.
On failure it throws an Error with message, status, body and request.
Every reply must be JSON, so an empty body (a 204, say) throws too.
Multipart uploads cannot use authFetch, because it forces a JSON content
type. Rebuild the bearer and tenant headers yourself for those.
ThemeProvider is required
<ThemeProvider theme="dark">
<AccessProvider>
<BrowserRouter>…</BrowserRouter>
</AccessProvider>
</ThemeProvider>
theme.cssdefines every--xeplr-*variable only inside the.xeplr-theme-dark,-light,-mediumand-brightclasses. There is no:rootfallback, so without a theme class above them every screen renders unstyled and no error is reported.- Set the page background on a container inside the theme wrapper, not on
body.bodyis outside the wrapper, so the variables resolve to nothing there. - Leave room for the rail. It is
position: fixed, 60px wide when collapsed, and atz-index: 500, so a full-screen modal must stack above 500.
Last updated 16 September 2026