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 — 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.

tip

Set a long, random ADMIN_SECRET. Store it in an environment variable — never hardcode it.

ADMIN_SECRET=$(openssl rand -hex 32)
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.

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

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>.

MethodPathDescription
GET/admin/api/usersList users (paginated, ?page=1&limit=20&search=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/pingHealth 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:

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 '@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):

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 '@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.