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.

The built-in Admin Panel — zero dependencies, no build step
Admin authentication flow
Setup
Step 1: Import and mount the admin router.
import express from 'express';
import { AuthConfigurator, createAdminRouter } from '@nik2208/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, {
adminSecret: process.env.ADMIN_SECRET!, // required — Bearer token for the UI
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
}));
app.listen(3000);
Step 2: Open the admin UI in your browser.
http://localhost:3000/admin/
Enter your ADMIN_SECRET when prompted. The UI stores the token in sessionStorage and sends it as Authorization: Bearer <secret> on every API call.
Set a long, random ADMIN_SECRET. Store it in an environment variable — never hardcode it.
ADMIN_SECRET=$(openssl rand -hex 32)
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.
Dashboard Tabs
Each tab is activated automatically when you pass the corresponding store:
| Tab | Required store | Features |
|---|---|---|
| Users | IUserStore.listUsers | Paginated 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 |
| Sessions | ISessionStore | All active sessions across all users; revoke by session handle |
| Roles & Permissions | IRolesPermissionsStore | List roles, create/delete roles, assign permissions |
| Tenants | ITenantStore | List tenants, create/delete tenants, manage members |
| Linked Accounts | ILinkedAccountsStore | View OAuth provider links per user |
| 🔗 Webhooks | IWebhookStore | Manage outgoing webhooks and inbound dynamic execution configs |
| ⚙️ Control | ISettingsStore | Toggle email verification mode (none/lazy/strict), 2FA policy, and global webhook action enablement |
Settings Store
The Control tab lets admins change runtime auth policy. Implement ISettingsStore:
import { ISettingsStore, AuthSettings } from '@nik2208/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, {
adminSecret: process.env.ADMIN_SECRET!,
settingsStore,
}));
REST API Reference
All admin endpoints require Authorization: Bearer <adminSecret>.
| Method | Path | Description |
|---|---|---|
GET | /admin/api/users | List users (paginated, ?page=1&limit=20&search=email) |
GET | /admin/api/users/:id | Get user details |
DELETE | /admin/api/users/:id | Delete user and all related data |
GET | /admin/api/users/:id/metadata | Get user metadata key/value pairs |
PUT | /admin/api/users/:id/metadata | Set/update user metadata |
GET | /admin/api/users/:id/linked-accounts | List OAuth provider links for user |
GET | /admin/api/users/:id/roles | List roles assigned to user |
POST | /admin/api/users/:id/roles | Assign a role to user |
DELETE | /admin/api/users/:id/roles/:role | Remove a role from user |
GET | /admin/api/users/:id/tenants | List tenants the user belongs to |
POST | /admin/api/2fa-policy | Batch-enable or disable 2FA for users |
GET | /admin/api/sessions | List all active sessions |
DELETE | /admin/api/sessions/:handle | Revoke a session by handle |
GET | /admin/api/roles | List all roles |
POST | /admin/api/roles | Create a new role |
DELETE | /admin/api/roles/:name | Delete a role |
GET | /admin/api/tenants | List all tenants |
POST | /admin/api/tenants | Create a tenant |
DELETE | /admin/api/tenants/:id | Delete a tenant |
GET | /admin/api/tenants/:id/users | List users in a tenant |
POST | /admin/api/tenants/:id/users | Add a user to a tenant |
DELETE | /admin/api/tenants/:id/users/:userId | Remove a user from a tenant |
GET | /admin/api/settings | Get current auth settings |
PUT | /admin/api/settings | Update auth settings |
GET | /admin/api/ping | Health check |
Customising listUsers
The Users tab requires a listUsers method on your IUserStore. Add it to your implementation:
import { IUserStore, BaseUser } from '@nik2208/node-auth';
export class MyUserStore implements IUserStore {
// ... other methods ...
async listUsers(opts: { page: number; limit: number; search?: string }): Promise<{
users: BaseUser[];
total: number;
}> {
const offset = (opts.page - 1) * opts.limit;
const query = db('users');
if (opts.search) {
query.where('email', 'like', `%${opts.search}%`);
}
const [users, [{ count }]] = await Promise.all([
query.clone().offset(offset).limit(opts.limit),
query.clone().count('id as count'),
]);
return { users, total: Number(count) };
}
}
🔗 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, {
adminSecret: process.env.ADMIN_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:
| Field | Example | Description |
|---|---|---|
| URL | https://hooks.slack.com/... | Destination endpoint |
| Events | identity.auth.login.success or * | Events that trigger delivery |
| Secret | whsec_… | 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 '@nik2208/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):
| Field | Example | Description |
|---|---|---|
| Provider name | stripe | Matches :provider in POST /tools/webhook/stripe |
| Allowed actions | ☑ billing.cancel | Subset of globally-enabled actions for this script only |
| JavaScript | see below | Body 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
| Rule | Behaviour |
|---|---|
Action not in enabledWebhookActions | Excluded from sandbox even if in allowedActions |
Action's dependsOn not met | Excluded from sandbox |
| Script throws / exceeds 5 s timeout | Error logged; HTTP 200 returned (no crash) |
result is null | Silently acknowledged; no event emitted |
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 '@nik2208/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.