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:

  1. checks REDIS_PREFIX, and refuses to start if it is missing or xeplr:;
  2. checks that Redis is reachable, and refuses to start if it is not;
  3. runs auth’s own migrations, then the directories in XEPLR_AUTH_MIGRATIONS;
  4. configures email and checks it can send (never fatal);
  5. starts on AUTH_PORT (default 19001) with /auth/api and /auth/api/admin mounted;
  6. 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"]
}
  • menus are keys, which app code matches on.
  • menuItems hold 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.

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 (l1l4, matching mtId1mtId4), 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 sends Content-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-Token response header;
  • on a 401, refreshes once through POST /auth/api/refresh and 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.css defines every --xeplr-* variable only inside the .xeplr-theme-dark, -light, -medium and -bright classes. There is no :root fallback, 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. body is outside the wrapper, so the variables resolve to nothing there.
  • Leave room for the rail. It is position: fixed, 60px wide when collapsed, and at z-index: 500, so a full-screen modal must stack above 500.

Last updated 16 September 2026