Skip to main content

Admin Panel

createAdminRouter mounts a self-contained admin dashboard — both a REST API and a built-in vanilla-JS UI — at any path. No external frontend build step required.

node-auth Admin Panel dashboard

The built-in Admin Panel — no dependencies, no build step - Control Tab


node-auth Admin Panel dashboard

The built-in Admin Panel — no dependencies, no build step - Api Keys Tab


Admin authentication flow (v1.8.2+)

Setup

Step 1: Import and mount the admin router.

import express from 'express';
import { AuthConfigurator, createAdminRouter } from 'awesome-node-auth';
import type { AdminOptions, AdminAccessPolicy } from 'awesome-node-auth';

const app = express();
app.use(express.json());

const auth = new AuthConfigurator(config, userStore);
app.use('/auth', auth.router());

// Mount admin panel at /admin
app.use('/admin', createAdminRouter(userStore, {
// Session-based access control (v1.8.0+)
accessPolicy: 'first-user', // see Access Policy section below
jwtSecret: process.env.ACCESS_TOKEN_SECRET!, // must match AuthConfig.accessTokenSecret (ACCESS_TOKEN_SECRET)
sessionStore, // optional — enables Sessions tab
rbacStore, // optional — enables Roles & Permissions tab
tenantStore, // optional — enables Tenants tab
userMetadataStore, // optional — enables Metadata editor per user
settingsStore, // optional — enables Control tab (email policy, 2FA policy)
linkedAccountsStore, // optional — shows Linked Accounts in user detail
templateStore, // optional — enables 📧 Email & UI templates tab
}));

app.listen(3000);

Step 2: Open the admin UI in your browser.

http://localhost:3000/admin/

Authentication Fallback

By default, if you are not authenticated, the Admin UI will serve its own built-in login form (Email + Password) that speaks directly to your /auth/login endpoint. This allows for a zero-configuration admin panel even in headless or API-only projects.

If you prefer to use your own application's login page, you can configure the loginPath option (see below).

Access Policy

The accessPolicy option controls who may access the Admin UI and REST API:

PolicyDescription
'first-user'Only the first registered user (lowest-index in listUsers) is granted access. Ideal for single-owner setups.
'is-admin-flag'Only users with BaseUser.isAdmin === true are granted access.
'open'No restriction — all authenticated users are granted access. Use only behind a VPN or IP allow-list.
(user, rbacStore?) => booleanCustom async predicate — return true to grant access.

Configuration Options (AdminOptions)

OptionTypeDescription
accessPolicyAdminAccessPolicyRequired. Defines who can access the panel.
jwtSecretstringRequired (if not open). Must match AuthConfig.accessTokenSecret.
loginPathstringOptional. Path to redirect unauthenticated browser requests. If omitted, uses the built-in login form.
apiPrefixstringOptional. The base path where the main Auth router is mounted (default: /auth). Used to resolve profile links.
cookiePrefixstringOptional. Explicit prefix for auth cookies (e.g., __Host- or __Secure-). Automatically detected if omitted.
rootUserRootUserOptional. An emergency { email, passwordHash } (bcrypt) that bypasses the userStore for Admin login.
adminSecretstringOptional (Legacy). If set, the login form allows "Bootstrap Mode": leave email blank and log in with just this password.
swaggerbooleanOptional. Enable the built-in Swagger UI at /admin/swagger.
...storesIStoreOptional. Pass various stores (RBAC, Sessions, etc.) to enable corresponding tabs.

Example: custom predicate using RBAC

app.use('/admin', createAdminRouter(userStore, {
accessPolicy: async (user, rbacStore) => {
if (!rbacStore) return user.isAdmin === true;
const roles = await rbacStore.getRolesForUser(user.id);
return roles.some(r => r.role === 'superadmin');
},
jwtSecret: process.env.ACCESS_TOKEN_SECRET!,
rbacStore,
}));
Migration from adminSecret

If you are upgrading from v1.7 or earlier, adminSecret has been removed in v1.8.0. You must switch to accessPolicy + jwtSecret as shown in the Setup section above. This change enables more granular security and better integration with your application's session system.

Production security

Mount the admin panel behind a VPN, IP allowlist, or internal-only network in production. The panel has full read/write access to users, sessions, roles and settings.

Bootstrap & Root Access

Version 1.8.2 introduces two ways to access the Admin UI without having a pre-existing user in your database:

Pass a bcrypt-hashed password hash to AdminOptions.rootUser. This user is "virtual" — they don't exist in your IUserStore, but can log in to the Admin UI to perform initial setup.

app.use('/admin', createAdminRouter(userStore, {
accessPolicy: 'first-user',
jwtSecret: process.env.ACCESS_TOKEN_SECRET!,
rootUser: {
email: 'admin@internal.com',
passwordHash: '$2a$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', // bcrypt hash
}
}));

2. Bootstrap Mode (Password-only)

If adminSecret (legacy) is configured, the built-in login form allows you to leave the email empty and log in using only the secret as the password. This is useful for first-time setup in environments where no users have been created yet.

Once logged in, you can create your first "real" admin user and then remove the adminSecret.

Dashboard Tabs

Each tab is activated automatically when you pass the corresponding store:

TabRequired storeFeatures
UsersIUserStore.listUsersPaginated user table, search by email, delete users, open Manage panel
Manage (per-user)View profile, reset password, revoke refresh token, toggle email verification, toggle 2FA
SessionsISessionStoreAll active sessions across all users; revoke by session handle
Roles & PermissionsIRolesPermissionsStoreList roles, create/delete roles, assign permissions
TenantsITenantStoreList tenants, create/delete tenants, manage members
Linked AccountsILinkedAccountsStoreView OAuth provider links per user
🔗 WebhooksIWebhookStoreManage outgoing webhooks and inbound dynamic execution configs
⚙️ ControlISettingsStoreToggle email verification mode (none/lazy/strict), 2FA policy, and global webhook action enablement
📧 Email & UIITemplateStoreLive editor for email templates (HTML/Text + per-language translations) and UI i18n strings

Settings Store

The Control tab lets admins change runtime auth policy. Implement ISettingsStore:

import { ISettingsStore, AuthSettings } from 'awesome-node-auth';

// Simple in-memory implementation (replace with DB in production)
let settings: Partial<AuthSettings> = {};

const settingsStore: ISettingsStore = {
async getSettings(): Promise<Partial<AuthSettings>> {
return { ...settings };
},
async updateSettings(patch: Partial<AuthSettings>): Promise<void> {
Object.assign(settings, patch);
},
};

Then pass it to both AuthConfigurator and createAdminRouter:

const auth = new AuthConfigurator(
{ ...config, settingsStore },
userStore
);

app.use('/admin', createAdminRouter(userStore, {
accessPolicy: 'first-user',
jwtSecret: process.env.ACCESS_TOKEN_SECRET!,
settingsStore,
}));

REST API Reference

All admin endpoints require a valid JWT access token in Authorization: Bearer <token>.

MethodPathDescription
GET/admin/api/usersList users (paginated, ?offset=0&limit=20&filter=email)
GET/admin/api/users/:idGet user details
DELETE/admin/api/users/:idDelete user and all related data
GET/admin/api/users/:id/metadataGet user metadata key/value pairs
PUT/admin/api/users/:id/metadataSet/update user metadata
GET/admin/api/users/:id/linked-accountsList OAuth provider links for user
GET/admin/api/users/:id/rolesList roles assigned to user
POST/admin/api/users/:id/rolesAssign a role to user
DELETE/admin/api/users/:id/roles/:roleRemove a role from user
GET/admin/api/users/:id/tenantsList tenants the user belongs to
POST/admin/api/2fa-policyBatch-enable or disable 2FA for users
GET/admin/api/sessionsList all active sessions
DELETE/admin/api/sessions/:handleRevoke a session by handle
GET/admin/api/rolesList all roles
POST/admin/api/rolesCreate a new role
DELETE/admin/api/roles/:nameDelete a role
GET/admin/api/tenantsList all tenants
POST/admin/api/tenantsCreate a tenant
DELETE/admin/api/tenants/:idDelete a tenant
GET/admin/api/tenants/:id/usersList users in a tenant
POST/admin/api/tenants/:id/usersAdd a user to a tenant
DELETE/admin/api/tenants/:id/users/:userIdRemove a user from a tenant
GET/admin/api/settingsGet current auth settings
PUT/admin/api/settingsUpdate auth settings
GET/admin/api/templates/mailList all mail templates
POST/admin/api/templates/mailCreate or update a mail template
GET/admin/api/templates/uiList all UI translation sets
POST/admin/api/templates/uiCreate or update UI translations for a page
GET/admin/api/pingHealth check

Customising listUsers

The Users tab requires a listUsers method on your IUserStore. Add it to your implementation:

import { IUserStore, BaseUser } from 'awesome-node-auth';

export class MyUserStore implements IUserStore {
// ... other methods ...

async listUsers(limit: number, offset: number): Promise<BaseUser[]> {
const query = db('users');
return query.offset(offset).limit(limit);
}
}

🔗 Webhooks tab

The Webhooks tab is enabled by passing an IWebhookStore to createAdminRouter. It manages both outgoing and inbound webhooks — they are fundamentally different in purpose and configuration.

app.use('/admin', createAdminRouter(userStore, {
accessPolicy: 'first-user',
jwtSecret: process.env.ACCESS_TOKEN_SECRET!,
webhookStore: myWebhookStore, // enables the Webhooks tab
settingsStore: mySettingsStore, // enables the Control tab (for global action toggles)
}));

Outgoing webhooks (library → external service)

Outgoing webhooks forward AuthEventBus events to external HTTP endpoints as HMAC-signed JSON POSTs.

In the Webhooks tab click + Register webhook and fill in:

FieldExampleDescription
URLhttps://hooks.slack.com/...Destination endpoint
Eventsidentity.auth.login.success or *Events that trigger delivery
Secretwhsec_…Optional HMAC signing secret
Tenant(leave empty)Scope to a specific tenant, or global

Outgoing webhooks are delivered with retry and exponential back-off. See the full Outgoing Webhooks guide for payload format and signature verification.


Inbound webhooks — dynamic vm sandbox (external service → library)

Inbound webhooks receive HTTP POST calls from external services (e.g. Stripe, GitHub, Paddle) and execute a JavaScript script inside a secure Node.js vm sandbox. The available functions inside the script are governed by the admin — each action must be explicitly enabled globally (Control tab) and assigned to the specific webhook.

Step 1 — Expose service methods as injectable actions

import { webhookAction, ActionRegistry } from 'awesome-node-auth';

class BillingService {
@webhookAction({
id: 'billing.cancel',
label: 'Cancel subscription',
category: 'Billing',
description: 'Marks a subscription as cancelled in the billing database.',
})
async cancel(subscriptionId: string): Promise<void> {
await db.subscriptions.update({ id: subscriptionId }, { status: 'cancelled' });
}
}

// Register a bound method — REQUIRED for instance methods
const svc = new BillingService();
ActionRegistry.register({
id: 'billing.cancel', label: 'Cancel subscription',
category: 'Billing', description: '',
fn: svc.cancel.bind(svc),
});

Step 2 — Globally enable actions (Control tab → Webhook Actions)

The Control tab shows every registered @webhookAction grouped by category with toggle switches. An action must be enabled here before it can be used by any inbound webhook script.

If an action declares dependsOn: ['other.action.id'], its toggle is locked until all dependencies are also enabled.

Step 3 — Configure an inbound webhook (Webhooks tab)

Click + Register webhook and switch the type to Inbound (dynamic):

FieldExampleDescription
Provider namestripeMatches :provider in POST /tools/webhook/stripe
Allowed actionsbilling.cancelSubset of globally-enabled actions for this script only
JavaScriptsee belowBody executed in the vm sandbox

Example script:

// Available globals: body (request payload), actions (filtered), result (write to emit an event)
if (body.type === 'customer.subscription.deleted') {
const subId = body.data.object.id;
const userId = body.data.object.metadata.userId;

await actions['billing.cancel'](subId);

result = {
event: 'identity.tenant.user.removed',
data: { subscriptionId: subId, userId },
};
}
// If result stays null the webhook is silently acknowledged (HTTP 200, no event emitted)

Governance rules

RuleBehaviour
Action not in enabledWebhookActionsExcluded from sandbox even if in allowedActions
Action's dependsOn not metExcluded from sandbox
Script throws / exceeds 5 s timeoutError logged; HTTP 200 returned (no crash)
result is nullSilently acknowledged; no event emitted
Principle of Least Privilege

Each inbound webhook can only call the intersection of globally-enabled actions and its own allowedActions list. A compromised webhook script cannot call functions that weren't explicitly granted to it.

Step 4 — Wire stores into the tools router

app.use('/tools', createToolsRouter(tools, {
webhookStore: myWebhookStore, // findByProvider() required for inbound
settingsStore: mySettingsStore, // reads enabledWebhookActions
}));

See the Webhooks guide → Dynamic inbound execution for the full API reference.


Webhook Actions panel (⚙️ Control tab)

When your application registers @webhookAction-decorated methods, the Control tab shows a Webhook Actions sub-panel where you can globally enable or disable each action with a toggle switch.

Actions are grouped by category. If an action declares dependsOn, its toggle is disabled until all its dependencies are enabled.

import { webhookAction, ActionRegistry } from 'awesome-node-auth';

class BillingService {
@webhookAction({
id: 'billing.cancelSubscription',
label: 'Cancel subscription',
category: 'Billing',
description: 'Marks a subscription as cancelled in the billing database.',
})
async cancelSubscription(subscriptionId: string): Promise<void> {
await db.subscriptions.update({ id: subscriptionId }, { status: 'cancelled' });
}
}

// Register the bound instance so the vm sandbox can call it
const billing = new BillingService();
ActionRegistry.register({
id: 'billing.cancelSubscription',
label: 'Cancel subscription',
category: 'Billing',
description: 'Marks a subscription as cancelled in the billing database.',
fn: billing.cancelSubscription.bind(billing),
});

Pass both stores to the tools router to enable the vm sandbox:

app.use('/tools', createToolsRouter(tools, {
webhookStore: myWebhookStore, // provides findByProvider()
settingsStore: mySettingsStore, // provides enabledWebhookActions
}));

See the Webhooks guide for the full dynamic execution reference.


📧 Email & UI Templates tab

The Email & UI tab is enabled by passing an ITemplateStore to createAdminRouter. It provides a live editor for:

  • Email templates — HTML body, plain-text body, and per-language translation strings
  • UI translations — per-page data-i18n key/value pairs used to localise the built-in login/register pages
import { MemoryTemplateStore } from 'awesome-node-auth';

const templateStore = new MemoryTemplateStore(); // swap for a DB implementation in production

app.use('/admin', createAdminRouter(userStore, {
accessPolicy: 'first-user',
jwtSecret: process.env.ACCESS_TOKEN_SECRET!,
templateStore, // ← enables the 📧 Email & UI tab
}));

Email template editor

Each email template has three editable sections:

SectionDescription
HTML BodyFull HTML with {{T.key}} (translation) and {{VAR}} (data) placeholders
Text BodyPlain-text fallback with the same placeholders
Translations (JSON)JSON object mapping lang → { key: value } pairs

Example translations JSON:

{
"en": {
"subject": "Reset your password",
"greeting": "Hi there,",
"cta": "Click the button below to reset your password.",
"btnLabel": "Reset password"
},
"it": {
"subject": "Reimposta la tua password",
"greeting": "Ciao,",
"cta": "Clicca il pulsante qui sotto per reimpostare la tua password.",
"btnLabel": "Reimposta password"
}
}

UI translations editor

UI translations are stored per page (e.g. login, register). The JSON format mirrors the email template translations:

{
"en": { "title": "Sign in", "emailPlaceholder": "Email address", "submitBtn": "Log in" },
"it": { "title": "Accedi", "emailPlaceholder": "Indirizzo email", "submitBtn": "Entra" }
}

The language is resolved from the ?lang= query parameter on the UI page request. See Built-in UI → Internationalization (i18n) for the full flow.

For the full interface definition see ITemplateStore API Reference.