@awesome-lang-auth/node 1.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.detailed.md +4059 -0
- package/README.md +248 -0
- package/dist/abstract/base-auth-strategy.abstract.d.ts +7 -0
- package/dist/abstract/base-auth-strategy.abstract.d.ts.map +1 -0
- package/dist/abstract/base-auth-strategy.abstract.js +7 -0
- package/dist/abstract/base-auth-strategy.abstract.js.map +1 -0
- package/dist/abstract/base-oauth-strategy.abstract.d.ts +27 -0
- package/dist/abstract/base-oauth-strategy.abstract.d.ts.map +1 -0
- package/dist/abstract/base-oauth-strategy.abstract.js +11 -0
- package/dist/abstract/base-oauth-strategy.abstract.js.map +1 -0
- package/dist/adapters/express.d.ts +45 -0
- package/dist/adapters/express.d.ts.map +1 -0
- package/dist/adapters/express.js +49 -0
- package/dist/adapters/express.js.map +1 -0
- package/dist/adapters/fastify.d.ts +72 -0
- package/dist/adapters/fastify.d.ts.map +1 -0
- package/dist/adapters/fastify.js +63 -0
- package/dist/adapters/fastify.js.map +1 -0
- package/dist/auth-configurator.d.ts +65 -0
- package/dist/auth-configurator.d.ts.map +1 -0
- package/dist/auth-configurator.js +127 -0
- package/dist/auth-configurator.js.map +1 -0
- package/dist/events/auth-event-bus.d.ts +68 -0
- package/dist/events/auth-event-bus.d.ts.map +1 -0
- package/dist/events/auth-event-bus.js +60 -0
- package/dist/events/auth-event-bus.js.map +1 -0
- package/dist/events/auth-event-names.d.ts +34 -0
- package/dist/events/auth-event-names.d.ts.map +1 -0
- package/dist/events/auth-event-names.js +41 -0
- package/dist/events/auth-event-names.js.map +1 -0
- package/dist/http-types.d.ts +135 -0
- package/dist/http-types.d.ts.map +1 -0
- package/dist/http-types.js +18 -0
- package/dist/http-types.js.map +1 -0
- package/dist/index.d.ts +80 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +88 -0
- package/dist/index.js.map +1 -0
- package/dist/interfaces/api-key-store.interface.d.ts +94 -0
- package/dist/interfaces/api-key-store.interface.d.ts.map +1 -0
- package/dist/interfaces/api-key-store.interface.js +3 -0
- package/dist/interfaces/api-key-store.interface.js.map +1 -0
- package/dist/interfaces/auth-strategy.interface.d.ts +6 -0
- package/dist/interfaces/auth-strategy.interface.d.ts.map +1 -0
- package/dist/interfaces/auth-strategy.interface.js +3 -0
- package/dist/interfaces/auth-strategy.interface.js.map +1 -0
- package/dist/interfaces/linked-accounts-store.interface.d.ts +74 -0
- package/dist/interfaces/linked-accounts-store.interface.d.ts.map +1 -0
- package/dist/interfaces/linked-accounts-store.interface.js +3 -0
- package/dist/interfaces/linked-accounts-store.interface.js.map +1 -0
- package/dist/interfaces/pending-link-store.interface.d.ts +77 -0
- package/dist/interfaces/pending-link-store.interface.d.ts.map +1 -0
- package/dist/interfaces/pending-link-store.interface.js +3 -0
- package/dist/interfaces/pending-link-store.interface.js.map +1 -0
- package/dist/interfaces/roles-permissions-store.interface.d.ts +129 -0
- package/dist/interfaces/roles-permissions-store.interface.d.ts.map +1 -0
- package/dist/interfaces/roles-permissions-store.interface.js +3 -0
- package/dist/interfaces/roles-permissions-store.interface.js.map +1 -0
- package/dist/interfaces/session-store.interface.d.ts +92 -0
- package/dist/interfaces/session-store.interface.d.ts.map +1 -0
- package/dist/interfaces/session-store.interface.js +3 -0
- package/dist/interfaces/session-store.interface.js.map +1 -0
- package/dist/interfaces/settings-store.interface.d.ts +91 -0
- package/dist/interfaces/settings-store.interface.d.ts.map +1 -0
- package/dist/interfaces/settings-store.interface.js +3 -0
- package/dist/interfaces/settings-store.interface.js.map +1 -0
- package/dist/interfaces/sse-distributor.interface.d.ts +25 -0
- package/dist/interfaces/sse-distributor.interface.d.ts.map +1 -0
- package/dist/interfaces/sse-distributor.interface.js +3 -0
- package/dist/interfaces/sse-distributor.interface.js.map +1 -0
- package/dist/interfaces/telemetry-store.interface.d.ts +72 -0
- package/dist/interfaces/telemetry-store.interface.d.ts.map +1 -0
- package/dist/interfaces/telemetry-store.interface.js +3 -0
- package/dist/interfaces/telemetry-store.interface.js.map +1 -0
- package/dist/interfaces/template-store.interface.d.ts +37 -0
- package/dist/interfaces/template-store.interface.d.ts.map +1 -0
- package/dist/interfaces/template-store.interface.js +3 -0
- package/dist/interfaces/template-store.interface.js.map +1 -0
- package/dist/interfaces/tenant-store.interface.d.ts +97 -0
- package/dist/interfaces/tenant-store.interface.d.ts.map +1 -0
- package/dist/interfaces/tenant-store.interface.js +3 -0
- package/dist/interfaces/tenant-store.interface.js.map +1 -0
- package/dist/interfaces/token-store.interface.d.ts +10 -0
- package/dist/interfaces/token-store.interface.d.ts.map +1 -0
- package/dist/interfaces/token-store.interface.js +3 -0
- package/dist/interfaces/token-store.interface.js.map +1 -0
- package/dist/interfaces/user-metadata-store.interface.d.ts +50 -0
- package/dist/interfaces/user-metadata-store.interface.d.ts.map +1 -0
- package/dist/interfaces/user-metadata-store.interface.js +3 -0
- package/dist/interfaces/user-metadata-store.interface.js.map +1 -0
- package/dist/interfaces/user-store.interface.d.ts +113 -0
- package/dist/interfaces/user-store.interface.d.ts.map +1 -0
- package/dist/interfaces/user-store.interface.js +3 -0
- package/dist/interfaces/user-store.interface.js.map +1 -0
- package/dist/interfaces/webhook-store.interface.d.ts +139 -0
- package/dist/interfaces/webhook-store.interface.d.ts.map +1 -0
- package/dist/interfaces/webhook-store.interface.js +3 -0
- package/dist/interfaces/webhook-store.interface.js.map +1 -0
- package/dist/middleware/api-key.middleware.d.ts +35 -0
- package/dist/middleware/api-key.middleware.d.ts.map +1 -0
- package/dist/middleware/api-key.middleware.js +45 -0
- package/dist/middleware/api-key.middleware.js.map +1 -0
- package/dist/middleware/auth.middleware.d.ts +13 -0
- package/dist/middleware/auth.middleware.d.ts.map +1 -0
- package/dist/middleware/auth.middleware.js +55 -0
- package/dist/middleware/auth.middleware.js.map +1 -0
- package/dist/middleware/jwks-auth.middleware.d.ts +18 -0
- package/dist/middleware/jwks-auth.middleware.d.ts.map +1 -0
- package/dist/middleware/jwks-auth.middleware.js +77 -0
- package/dist/middleware/jwks-auth.middleware.js.map +1 -0
- package/dist/models/api-key.model.d.ts +66 -0
- package/dist/models/api-key.model.d.ts.map +1 -0
- package/dist/models/api-key.model.js +3 -0
- package/dist/models/api-key.model.js.map +1 -0
- package/dist/models/auth-config.model.d.ts +387 -0
- package/dist/models/auth-config.model.d.ts.map +1 -0
- package/dist/models/auth-config.model.js +3 -0
- package/dist/models/auth-config.model.js.map +1 -0
- package/dist/models/errors.d.ts +7 -0
- package/dist/models/errors.d.ts.map +1 -0
- package/dist/models/errors.js +14 -0
- package/dist/models/errors.js.map +1 -0
- package/dist/models/session.model.d.ts +28 -0
- package/dist/models/session.model.d.ts.map +1 -0
- package/dist/models/session.model.js +3 -0
- package/dist/models/session.model.js.map +1 -0
- package/dist/models/tenant.model.d.ts +20 -0
- package/dist/models/tenant.model.d.ts.map +1 -0
- package/dist/models/tenant.model.js +3 -0
- package/dist/models/tenant.model.js.map +1 -0
- package/dist/models/token.model.d.ts +21 -0
- package/dist/models/token.model.d.ts.map +1 -0
- package/dist/models/token.model.js +3 -0
- package/dist/models/token.model.js.map +1 -0
- package/dist/models/user.model.d.ts +92 -0
- package/dist/models/user.model.d.ts.map +1 -0
- package/dist/models/user.model.js +3 -0
- package/dist/models/user.model.js.map +1 -0
- package/dist/router/admin.router.d.ts +188 -0
- package/dist/router/admin.router.d.ts.map +1 -0
- package/dist/router/admin.router.js +1507 -0
- package/dist/router/admin.router.js.map +1 -0
- package/dist/router/auth.router.d.ts +199 -0
- package/dist/router/auth.router.d.ts.map +1 -0
- package/dist/router/auth.router.js +1636 -0
- package/dist/router/auth.router.js.map +1 -0
- package/dist/router/openapi.d.ts +98 -0
- package/dist/router/openapi.d.ts.map +1 -0
- package/dist/router/openapi.js +1518 -0
- package/dist/router/openapi.js.map +1 -0
- package/dist/router/router-events.d.ts +38 -0
- package/dist/router/router-events.d.ts.map +1 -0
- package/dist/router/router-events.js +74 -0
- package/dist/router/router-events.js.map +1 -0
- package/dist/router/tools.router.d.ts +104 -0
- package/dist/router/tools.router.d.ts.map +1 -0
- package/dist/router/tools.router.js +227 -0
- package/dist/router/tools.router.js.map +1 -0
- package/dist/router/ui.router.d.ts +49 -0
- package/dist/router/ui.router.d.ts.map +1 -0
- package/dist/router/ui.router.js +272 -0
- package/dist/router/ui.router.js.map +1 -0
- package/dist/services/api-key.service.d.ts +53 -0
- package/dist/services/api-key.service.d.ts.map +1 -0
- package/dist/services/api-key.service.js +66 -0
- package/dist/services/api-key.service.js.map +1 -0
- package/dist/services/jwks.service.d.ts +73 -0
- package/dist/services/jwks.service.d.ts.map +1 -0
- package/dist/services/jwks.service.js +174 -0
- package/dist/services/jwks.service.js.map +1 -0
- package/dist/services/mailer.service.d.ts +30 -0
- package/dist/services/mailer.service.d.ts.map +1 -0
- package/dist/services/mailer.service.js +248 -0
- package/dist/services/mailer.service.js.map +1 -0
- package/dist/services/notification.service.d.ts +110 -0
- package/dist/services/notification.service.d.ts.map +1 -0
- package/dist/services/notification.service.js +79 -0
- package/dist/services/notification.service.js.map +1 -0
- package/dist/services/password.service.d.ts +5 -0
- package/dist/services/password.service.d.ts.map +1 -0
- package/dist/services/password.service.js +17 -0
- package/dist/services/password.service.js.map +1 -0
- package/dist/services/sms.service.d.ts +14 -0
- package/dist/services/sms.service.d.ts.map +1 -0
- package/dist/services/sms.service.js +51 -0
- package/dist/services/sms.service.js.map +1 -0
- package/dist/services/token.service.d.ts +62 -0
- package/dist/services/token.service.d.ts.map +1 -0
- package/dist/services/token.service.js +354 -0
- package/dist/services/token.service.js.map +1 -0
- package/dist/stores/memory-template.store.d.ts +12 -0
- package/dist/stores/memory-template.store.d.ts.map +1 -0
- package/dist/stores/memory-template.store.js +35 -0
- package/dist/stores/memory-template.store.js.map +1 -0
- package/dist/strategies/api-key/api-key.strategy.d.ts +72 -0
- package/dist/strategies/api-key/api-key.strategy.d.ts.map +1 -0
- package/dist/strategies/api-key/api-key.strategy.js +182 -0
- package/dist/strategies/api-key/api-key.strategy.js.map +1 -0
- package/dist/strategies/local/local.strategy.d.ts +19 -0
- package/dist/strategies/local/local.strategy.d.ts.map +1 -0
- package/dist/strategies/local/local.strategy.js +45 -0
- package/dist/strategies/local/local.strategy.js.map +1 -0
- package/dist/strategies/magic-link/magic-link.strategy.d.ts +8 -0
- package/dist/strategies/magic-link/magic-link.strategy.d.ts.map +1 -0
- package/dist/strategies/magic-link/magic-link.strategy.js +53 -0
- package/dist/strategies/magic-link/magic-link.strategy.js.map +1 -0
- package/dist/strategies/oauth/generic-oauth.strategy.d.ts +120 -0
- package/dist/strategies/oauth/generic-oauth.strategy.d.ts.map +1 -0
- package/dist/strategies/oauth/generic-oauth.strategy.js +88 -0
- package/dist/strategies/oauth/generic-oauth.strategy.js.map +1 -0
- package/dist/strategies/oauth/github.strategy.d.ts +31 -0
- package/dist/strategies/oauth/github.strategy.d.ts.map +1 -0
- package/dist/strategies/oauth/github.strategy.js +72 -0
- package/dist/strategies/oauth/github.strategy.js.map +1 -0
- package/dist/strategies/oauth/google.strategy.d.ts +31 -0
- package/dist/strategies/oauth/google.strategy.d.ts.map +1 -0
- package/dist/strategies/oauth/google.strategy.js +61 -0
- package/dist/strategies/oauth/google.strategy.js.map +1 -0
- package/dist/strategies/sms/sms.strategy.d.ts +7 -0
- package/dist/strategies/sms/sms.strategy.d.ts.map +1 -0
- package/dist/strategies/sms/sms.strategy.js +39 -0
- package/dist/strategies/sms/sms.strategy.js.map +1 -0
- package/dist/strategies/two-factor/totp.strategy.d.ts +12 -0
- package/dist/strategies/two-factor/totp.strategy.d.ts.map +1 -0
- package/dist/strategies/two-factor/totp.strategy.js +32 -0
- package/dist/strategies/two-factor/totp.strategy.js.map +1 -0
- package/dist/tools/auth-tools.d.ts +200 -0
- package/dist/tools/auth-tools.d.ts.map +1 -0
- package/dist/tools/auth-tools.js +232 -0
- package/dist/tools/auth-tools.js.map +1 -0
- package/dist/tools/sse-manager.d.ts +118 -0
- package/dist/tools/sse-manager.d.ts.map +1 -0
- package/dist/tools/sse-manager.js +163 -0
- package/dist/tools/sse-manager.js.map +1 -0
- package/dist/tools/sse-notify.decorator.d.ts +74 -0
- package/dist/tools/sse-notify.decorator.d.ts.map +1 -0
- package/dist/tools/sse-notify.decorator.js +78 -0
- package/dist/tools/sse-notify.decorator.js.map +1 -0
- package/dist/tools/webhook-action.d.ts +130 -0
- package/dist/tools/webhook-action.d.ts.map +1 -0
- package/dist/tools/webhook-action.js +144 -0
- package/dist/tools/webhook-action.js.map +1 -0
- package/dist/tools/webhook-sender.d.ts +31 -0
- package/dist/tools/webhook-sender.d.ts.map +1 -0
- package/dist/tools/webhook-sender.js +80 -0
- package/dist/tools/webhook-sender.js.map +1 -0
- package/dist/ui-assets/2fa.html +115 -0
- package/dist/ui-assets/account-conflict.html +108 -0
- package/dist/ui-assets/admin.css +263 -0
- package/dist/ui-assets/admin.js +1927 -0
- package/dist/ui-assets/auth.js +725 -0
- package/dist/ui-assets/base.css +219 -0
- package/dist/ui-assets/forgot-password.html +93 -0
- package/dist/ui-assets/link-verify.html +74 -0
- package/dist/ui-assets/login.html +135 -0
- package/dist/ui-assets/magic-link.html +112 -0
- package/dist/ui-assets/register.html +122 -0
- package/dist/ui-assets/reset-password.html +103 -0
- package/dist/ui-assets/ui-i18n-keys.json +78 -0
- package/dist/ui-assets/verify-email.html +66 -0
- package/package.json +89 -0
|
@@ -0,0 +1,1507 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.createAdminRouter = createAdminRouter;
|
|
7
|
+
const express_1 = require("express");
|
|
8
|
+
const multer_1 = __importDefault(require("multer"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const fs_1 = __importDefault(require("fs"));
|
|
11
|
+
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
|
12
|
+
const bcryptjs_1 = __importDefault(require("bcryptjs"));
|
|
13
|
+
const api_key_service_1 = require("../services/api-key.service");
|
|
14
|
+
const token_service_1 = require("../services/token.service");
|
|
15
|
+
const webhook_action_1 = require("../tools/webhook-action");
|
|
16
|
+
const openapi_1 = require("./openapi");
|
|
17
|
+
const auth_event_names_1 = require("../events/auth-event-names");
|
|
18
|
+
const router_events_1 = require("./router-events");
|
|
19
|
+
/**
|
|
20
|
+
* Id of the admin the `accessPolicy` guard authorized for this request (the
|
|
21
|
+
* actor of a privilege change). Only the policy guard sets it, in
|
|
22
|
+
* `res.locals.adminActorId`; `req.user` is not read, because under
|
|
23
|
+
* `adminSecret` or `'open'` it can only come from an upstream middleware.
|
|
24
|
+
*/
|
|
25
|
+
function adminActorId(res) {
|
|
26
|
+
const actorId = res.locals['adminActorId'];
|
|
27
|
+
return typeof actorId === 'string' ? actorId : undefined;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Refuse a request whose body is not `application/json` with `415`.
|
|
31
|
+
*
|
|
32
|
+
* For admin mutations that need no body field to grant a privilege (such as
|
|
33
|
+
* `POST /api/users/:id/promote` and its alias): an HTML form or a `text/plain` POST is a CORS
|
|
34
|
+
* "simple" request that a browser sends cross-site without a preflight, an
|
|
35
|
+
* `application/json` one is not. Every route that grants admin access this
|
|
36
|
+
* way must use this check.
|
|
37
|
+
*/
|
|
38
|
+
const requireJsonBody = (req, res, next) => {
|
|
39
|
+
if (!req.is('application/json')) {
|
|
40
|
+
res.status(415).json({ error: 'Content-Type must be application/json' });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
next();
|
|
44
|
+
};
|
|
45
|
+
/** Legacy guard — still used when `adminSecret` is provided. */
|
|
46
|
+
function adminAuth(secret) {
|
|
47
|
+
return (req, res, next) => {
|
|
48
|
+
const auth = req.headers.authorization;
|
|
49
|
+
if (!auth || !auth.startsWith('Bearer ')) {
|
|
50
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const token = auth.slice(7);
|
|
54
|
+
if (token !== secret) {
|
|
55
|
+
res.status(403).json({ error: 'Forbidden' });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
next();
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Compute the cookie name for the admin session token, mirroring the same
|
|
63
|
+
* `__Host-` / `__Secure-` prefix logic that TokenService uses for regular
|
|
64
|
+
* auth tokens. This ensures the guard reads exactly the cookie that was
|
|
65
|
+
* written by the admin login handler — and vice-versa — even on HTTPS.
|
|
66
|
+
*
|
|
67
|
+
* Rules (identical to TokenService.getCookieName):
|
|
68
|
+
* - HTTP (isSecure=false) → plain name, e.g. `accessToken`
|
|
69
|
+
* - HTTPS, path=/, no domain → `__Host-accessToken` (most secure)
|
|
70
|
+
* - HTTPS, custom path/domain → `__Secure-accessToken`
|
|
71
|
+
*
|
|
72
|
+
* An explicit `cookiePrefix` from AdminOptions always overrides the prefix.
|
|
73
|
+
*/
|
|
74
|
+
function resolveAdminCookieName(isSecure, cookiePrefix, cookiePath, cookieDomain) {
|
|
75
|
+
if (cookiePrefix !== undefined) {
|
|
76
|
+
// Explicit override — respect whatever the caller configured.
|
|
77
|
+
return cookiePrefix + 'accessToken';
|
|
78
|
+
}
|
|
79
|
+
if (!isSecure)
|
|
80
|
+
return 'accessToken';
|
|
81
|
+
const isPathRoot = !cookiePath || cookiePath === '/';
|
|
82
|
+
if (isPathRoot && !cookieDomain)
|
|
83
|
+
return '__Host-accessToken';
|
|
84
|
+
return '__Secure-accessToken';
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Enforce the browser requirements for a `__Host-` prefixed cookie:
|
|
88
|
+
* - Secure flag must be set
|
|
89
|
+
* - Path must be `/`
|
|
90
|
+
* - Domain attribute must be absent
|
|
91
|
+
*
|
|
92
|
+
* Returns a new object (shallow copy of `opts`) with the required attributes
|
|
93
|
+
* applied when the cookie name starts with `__Host-`, leaving `opts` untouched.
|
|
94
|
+
*/
|
|
95
|
+
function applyHostCookieRequirements(cookieName, opts) {
|
|
96
|
+
if (!cookieName.startsWith('__Host-'))
|
|
97
|
+
return opts;
|
|
98
|
+
const result = { ...opts };
|
|
99
|
+
result.secure = true;
|
|
100
|
+
result.path = '/';
|
|
101
|
+
delete result.domain;
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* JWT-based guard that enforces `AdminAccessPolicy`.
|
|
106
|
+
*
|
|
107
|
+
* A request without a valid session gets `401`; so does a validly signed
|
|
108
|
+
* token that names no stored user (no `sub`, or an unknown or deleted user).
|
|
109
|
+
* The one exception is the
|
|
110
|
+
* guard built with `loginFormFallback` for the HTML panel route: there an
|
|
111
|
+
* unauthenticated browser request (`Accept: text/html`) is redirected to
|
|
112
|
+
* `loginPath` when it is set, or, for a `GET`, let through with the
|
|
113
|
+
* `adminNeedsAuth` marker so the panel renders its built-in sign-in form.
|
|
114
|
+
* No other route may use that guard: its handlers do not check the marker.
|
|
115
|
+
*/
|
|
116
|
+
function buildPolicyGuard(policy, userStore, jwtSecret, rbacStore, loginPath, cookiePrefix, loginFormFallback = false) {
|
|
117
|
+
return async (req, res, next) => {
|
|
118
|
+
// 'open' — no auth required at all
|
|
119
|
+
if (policy === 'open') {
|
|
120
|
+
next();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// ── 1. Extract and verify JWT ──────────────────────────────────────────
|
|
124
|
+
let payload = null;
|
|
125
|
+
if (jwtSecret) {
|
|
126
|
+
// Try Authorization header first, then cookie
|
|
127
|
+
const bearerToken = req.headers.authorization?.startsWith('Bearer ')
|
|
128
|
+
? req.headers.authorization.slice(7)
|
|
129
|
+
: undefined;
|
|
130
|
+
// Prefer cookie-parser parsed cookies; fall back to raw Cookie header parsing
|
|
131
|
+
// in case the app does not use cookie-parser middleware.
|
|
132
|
+
let cookies = req.cookies;
|
|
133
|
+
if (!cookies) {
|
|
134
|
+
const cookieHeader = req.headers.cookie ?? '';
|
|
135
|
+
cookies = cookieHeader.split(';').reduce((acc, part) => {
|
|
136
|
+
const [key, ...val] = part.trim().split('=');
|
|
137
|
+
if (key)
|
|
138
|
+
acc[key.trim()] = decodeURIComponent(val.join('='));
|
|
139
|
+
return acc;
|
|
140
|
+
}, {});
|
|
141
|
+
}
|
|
142
|
+
const cookieName = cookiePrefix ? `${cookiePrefix}accessToken` : undefined;
|
|
143
|
+
const cookieToken = cookieName
|
|
144
|
+
? cookies[cookieName]
|
|
145
|
+
: (cookies['__Host-accessToken'] ?? cookies['__Secure-accessToken'] ?? cookies['accessToken']);
|
|
146
|
+
const rawToken = bearerToken || cookieToken;
|
|
147
|
+
if (rawToken) {
|
|
148
|
+
try {
|
|
149
|
+
payload = jsonwebtoken_1.default.verify(rawToken, jwtSecret);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
payload = null;
|
|
153
|
+
}
|
|
154
|
+
// The 2FA step-up token proves a password, not a session: refuse it.
|
|
155
|
+
if (payload && payload[token_service_1.TOKEN_PURPOSE_CLAIM] === token_service_1.TEMP_TOKEN_PURPOSE) {
|
|
156
|
+
payload = null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// ── 2. Unauthenticated → 401, or (panel route only) redirect / sign-in form
|
|
161
|
+
// Also used when a validly signed token names no usable user (no `sub`,
|
|
162
|
+
// unknown or deleted user), so the panel still offers a way to sign in.
|
|
163
|
+
const unauthenticated = () => {
|
|
164
|
+
const acceptsHtml = req.headers.accept?.includes('text/html');
|
|
165
|
+
if (acceptsHtml && loginFormFallback) {
|
|
166
|
+
// 1. External redirect if configured
|
|
167
|
+
if (loginPath) {
|
|
168
|
+
const redirectTo = encodeURIComponent(req.baseUrl + req.path);
|
|
169
|
+
res.redirect(302, `${loginPath}?redirect=${redirectTo}`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
// 2. Internal fallback: let the GET request through but mark as unauthenticated
|
|
173
|
+
// so the panel handler can show the built-in login form.
|
|
174
|
+
if (req.method === 'GET') {
|
|
175
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
176
|
+
req.adminNeedsAuth = true;
|
|
177
|
+
next();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
182
|
+
};
|
|
183
|
+
if (!payload) {
|
|
184
|
+
unauthenticated();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
// ── 3. Load the full user record ───────────────────────────────────────
|
|
188
|
+
const userId = payload['sub'];
|
|
189
|
+
if (!userId) {
|
|
190
|
+
unauthenticated();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
// Handle root/bootstrap override. Only the admin console token minted by
|
|
194
|
+
// POST /login may carry it: on any other token (for example one whose
|
|
195
|
+
// claims come from `buildTokenPayload`) `isRoot` is ignored.
|
|
196
|
+
if (payload['isRoot'] === true && payload[token_service_1.TOKEN_PURPOSE_CLAIM] === token_service_1.ADMIN_TOKEN_PURPOSE) {
|
|
197
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
198
|
+
req.user = {
|
|
199
|
+
id: userId,
|
|
200
|
+
email: payload['email'] || 'root@admin',
|
|
201
|
+
isAdmin: true,
|
|
202
|
+
roles: ['admin'],
|
|
203
|
+
};
|
|
204
|
+
res.locals['adminActorId'] = userId;
|
|
205
|
+
next();
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
let user = null;
|
|
209
|
+
try {
|
|
210
|
+
user = await userStore.findById(userId);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
user = null;
|
|
214
|
+
}
|
|
215
|
+
if (!user) {
|
|
216
|
+
unauthenticated();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const roles = rbacStore ? await rbacStore.getRolesForUser(user.id).catch(() => []) : [];
|
|
220
|
+
const authorizedUser = { ...user, roles };
|
|
221
|
+
// ── 4. Evaluate the policy ─────────────────────────────────────────────
|
|
222
|
+
let granted = false;
|
|
223
|
+
try {
|
|
224
|
+
if (policy === 'is-admin-flag') {
|
|
225
|
+
granted = authorizedUser.isAdmin === true;
|
|
226
|
+
}
|
|
227
|
+
else if (policy === 'first-user') {
|
|
228
|
+
if (typeof userStore.listUsers === 'function') {
|
|
229
|
+
const firstPage = await userStore.listUsers(1, 0);
|
|
230
|
+
granted = firstPage.length > 0 && firstPage[0].id === authorizedUser.id;
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
// If listUsers is not implemented, fall back to denying access with a clear error
|
|
234
|
+
res.status(500).json({ error: 'accessPolicy: first-user requires IUserStore.listUsers to be implemented' });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
else if (typeof policy === 'function') {
|
|
239
|
+
granted = await policy(authorizedUser, rbacStore);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
granted = false;
|
|
244
|
+
}
|
|
245
|
+
if (!granted) {
|
|
246
|
+
res.status(403).json({ error: 'Forbidden' });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
// Store user on request for downstream handlers
|
|
250
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
251
|
+
req.user = authorizedUser;
|
|
252
|
+
res.locals['adminActorId'] = authorizedUser.id;
|
|
253
|
+
next();
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// Admin UI — static assets (admin.css / admin.js) are served from the same
|
|
258
|
+
// directory as the auth UI assets. buildAdminHtml returns only a thin HTML
|
|
259
|
+
// shell that injects the runtime config and references those files, keeping
|
|
260
|
+
// the compiled router free of large embedded strings.
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
function buildAdminHtml(baseUrl, features) {
|
|
263
|
+
// Config object injected as window.__ADMIN_CONFIG__ and read by admin.js.
|
|
264
|
+
const cfg = JSON.stringify({
|
|
265
|
+
base: baseUrl,
|
|
266
|
+
featSessions: features.sessions,
|
|
267
|
+
featRoles: features.roles,
|
|
268
|
+
featTenants: features.tenants,
|
|
269
|
+
featMetadata: features.metadata,
|
|
270
|
+
feat2faPolicy: features.twoFAPolicy,
|
|
271
|
+
featControl: features.control,
|
|
272
|
+
featLinkedAccounts: features.linkedAccounts,
|
|
273
|
+
featApiKeys: features.apiKeys,
|
|
274
|
+
featWebhooks: features.webhooks,
|
|
275
|
+
featTemplates: features.templates,
|
|
276
|
+
featUpload: features.upload,
|
|
277
|
+
uploadBaseUrl: features.uploadBaseUrl,
|
|
278
|
+
sessionBased: !!features.sessionBased,
|
|
279
|
+
authApiPrefix: features.authApiPrefix || '/auth',
|
|
280
|
+
cookiePrefix: features.cookiePrefix,
|
|
281
|
+
});
|
|
282
|
+
const showLogin = features.showLogin || !features.sessionBased;
|
|
283
|
+
// When using accessPolicy the login screen is omitted: the server-side
|
|
284
|
+
// guard has already verified the session and will redirect unauthenticated
|
|
285
|
+
// browsers to the app login page before this HTML is ever served.
|
|
286
|
+
const loginScreen = !showLogin ? '' : `
|
|
287
|
+
<!-- Login screen -->
|
|
288
|
+
<div id="login">
|
|
289
|
+
<div class="login-card">
|
|
290
|
+
<h1>🔐 awesome-node-auth</h1>
|
|
291
|
+
<p>Administration panel</p>
|
|
292
|
+
<div id="login-error" class="alert alert-error" style="display:none"></div>
|
|
293
|
+
<div style="display:flex;flex-direction:column;gap:.75rem">
|
|
294
|
+
${features.sessionBased ? '<input type="email" id="email-input" placeholder="Email" autofocus>' : ''}
|
|
295
|
+
<input type="password" id="secret-input" placeholder="${features.sessionBased ? 'Password' : 'Admin secret'}" ${features.sessionBased ? '' : 'autofocus'}>
|
|
296
|
+
<button class="btn btn-primary" onclick="doLogin()">Sign in</button>
|
|
297
|
+
</div>
|
|
298
|
+
<p style="margin-top:.75rem;font-size:.8rem;color:#6b7280">End-user login is at <code>/auth/ui/login</code>. This is the admin panel.</p>
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
301
|
+
`;
|
|
302
|
+
return `<!DOCTYPE html>
|
|
303
|
+
<html lang="en">
|
|
304
|
+
<head>
|
|
305
|
+
<meta charset="UTF-8">
|
|
306
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
307
|
+
<title>awesome-node-auth Admin</title>
|
|
308
|
+
<link rel="stylesheet" href="${baseUrl}/assets/admin.css">
|
|
309
|
+
</head>
|
|
310
|
+
<body>
|
|
311
|
+
${loginScreen}
|
|
312
|
+
<!-- Main app -->
|
|
313
|
+
<div id="app">
|
|
314
|
+
<div id="flash"></div>
|
|
315
|
+
<header>
|
|
316
|
+
<h1>🔐 awesome-node-auth Admin</h1>
|
|
317
|
+
<span id="header-meta"></span>
|
|
318
|
+
</header>
|
|
319
|
+
<nav id="nav">
|
|
320
|
+
<button class="btn logout-btn" style="margin-left:auto;margin-top:.4rem;margin-bottom:.4rem;font-size:.75rem;padding:.25rem .75rem;background:#f3f4f6;border:1px solid #e5e7eb;border-radius:6px" onclick="doLogout()">Logout</button>
|
|
321
|
+
</nav>
|
|
322
|
+
<main id="main"></main>
|
|
323
|
+
</div>
|
|
324
|
+
|
|
325
|
+
<script>window.__ADMIN_CONFIG__ = ${cfg};</script>
|
|
326
|
+
<script src="${baseUrl}/assets/admin.js"></script>
|
|
327
|
+
</body>
|
|
328
|
+
</html>`;
|
|
329
|
+
}
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
// Admin REST API + UI router
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
function createAdminRouter(userStore, options) {
|
|
334
|
+
// An `adminSecret` that is present but empty (`''`, or an unset environment
|
|
335
|
+
// variable) would otherwise fall through to the unprotected router below.
|
|
336
|
+
if (options.accessPolicy === undefined
|
|
337
|
+
&& Object.prototype.hasOwnProperty.call(options, 'adminSecret')
|
|
338
|
+
&& (typeof options.adminSecret !== 'string' || options.adminSecret === '')) {
|
|
339
|
+
throw new Error('[awesome-node-auth] createAdminRouter: `adminSecret` is empty. Set it to a non-empty secret, ' +
|
|
340
|
+
'or configure `accessPolicy` instead (`accessPolicy: \'open\'` mounts the admin routes without protection on purpose).');
|
|
341
|
+
}
|
|
342
|
+
const router = (0, express_1.Router)();
|
|
343
|
+
const eventBus = options.eventBus;
|
|
344
|
+
const rateLimiter = options.rateLimiter ? [options.rateLimiter] : [];
|
|
345
|
+
// Ensure request bodies are parsed even if the parent app has no global body parser.
|
|
346
|
+
router.use((0, express_1.json)());
|
|
347
|
+
// ── Select the appropriate authentication guard ──────────────────────────
|
|
348
|
+
// Priority: accessPolicy (new) > adminSecret (legacy) > open (no auth).
|
|
349
|
+
let guard;
|
|
350
|
+
// Guard of the HTML panel route only (see buildPolicyGuard): the one route
|
|
351
|
+
// that may answer an unauthenticated browser with the sign-in form.
|
|
352
|
+
let panelGuard;
|
|
353
|
+
let sessionBased = false;
|
|
354
|
+
if (options.accessPolicy !== undefined) {
|
|
355
|
+
// New session-based guard
|
|
356
|
+
guard = buildPolicyGuard(options.accessPolicy, userStore, options.jwtSecret, options.rbacStore, options.loginPath, options.cookiePrefix);
|
|
357
|
+
panelGuard = buildPolicyGuard(options.accessPolicy, userStore, options.jwtSecret, options.rbacStore, options.loginPath, options.cookiePrefix, true);
|
|
358
|
+
sessionBased = options.accessPolicy !== 'open';
|
|
359
|
+
}
|
|
360
|
+
else if (options.adminSecret) {
|
|
361
|
+
// Legacy Bearer-token guard (backward compat)
|
|
362
|
+
guard = adminAuth(options.adminSecret);
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
// Neither provided — warn and default to open (developer must protect externally)
|
|
366
|
+
process.stderr.write('[awesome-node-auth] WARNING: createAdminRouter called without `accessPolicy` or `adminSecret`. ' +
|
|
367
|
+
'Admin routes are unprotected. Set accessPolicy in production.\n');
|
|
368
|
+
guard = (_req, _res, next) => next();
|
|
369
|
+
}
|
|
370
|
+
const secret = options.jwtSecret;
|
|
371
|
+
// ── Local Login Handler (Self-contained Auth) ──────────────────────────
|
|
372
|
+
if (sessionBased && secret) {
|
|
373
|
+
router.post('/login', async (req, res) => {
|
|
374
|
+
const { email, password } = req.body;
|
|
375
|
+
if (!password) {
|
|
376
|
+
res.status(400).json({ error: 'Password required' });
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
let authedUser = null;
|
|
380
|
+
// 1. Check Root User
|
|
381
|
+
if (options.rootUser && email === options.rootUser.email) {
|
|
382
|
+
if (await bcryptjs_1.default.compare(password, options.rootUser.passwordHash)) {
|
|
383
|
+
authedUser = { id: 'root', email: options.rootUser.email, isRoot: true };
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
// 2. Check Admin Secret (Bootstrap override)
|
|
387
|
+
if (!authedUser && options.adminSecret && (!email || email === 'admin')) {
|
|
388
|
+
if (password === options.adminSecret) {
|
|
389
|
+
authedUser = { id: 'admin', email: 'admin@bootstrap', isRoot: true };
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
// 3. Fallback to UserStore (standard login)
|
|
393
|
+
if (!authedUser && email) {
|
|
394
|
+
try {
|
|
395
|
+
const user = await userStore.findByEmail(email);
|
|
396
|
+
if (user && user.password && await bcryptjs_1.default.compare(password, user.password)) {
|
|
397
|
+
authedUser = { id: user.id || '', email: user.email };
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
catch { /* ignore */ }
|
|
401
|
+
}
|
|
402
|
+
if (!authedUser) {
|
|
403
|
+
res.status(401).json({ error: 'Invalid credentials' });
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
// Sign JWT. `purpose: 'admin'` confines it to the admin console: the
|
|
407
|
+
// app's auth middleware refuses it even when `jwtSecret` is the
|
|
408
|
+
// access-token secret.
|
|
409
|
+
const token = jsonwebtoken_1.default.sign({ sub: authedUser.id, email: authedUser.email, isRoot: authedUser.isRoot, [token_service_1.TOKEN_PURPOSE_CLAIM]: token_service_1.ADMIN_TOKEN_PURPOSE }, secret, { expiresIn: '24h' });
|
|
410
|
+
// Determine Secure flag dynamically: honour explicit cookieOptions if provided,
|
|
411
|
+
// otherwise use the actual protocol of the request so the cookie works correctly
|
|
412
|
+
// whether the app is behind an HTTPS-terminating reverse proxy or served directly.
|
|
413
|
+
const isSecure = req.secure === true || req.headers['x-forwarded-proto'] === 'https';
|
|
414
|
+
// Fix: use the same __Host- / __Secure- naming logic as TokenService so
|
|
415
|
+
// the guard can always read back exactly the cookie that was just written,
|
|
416
|
+
// even when a regular-auth session (e.g. __Host-accessToken) is also active.
|
|
417
|
+
const explicitOptions = options.cookieOptions;
|
|
418
|
+
const cookieName = resolveAdminCookieName(isSecure, options.cookiePrefix, explicitOptions?.path, explicitOptions?.domain);
|
|
419
|
+
const cookieOptions = applyHostCookieRequirements(cookieName, explicitOptions || {
|
|
420
|
+
httpOnly: true,
|
|
421
|
+
secure: isSecure,
|
|
422
|
+
sameSite: 'lax',
|
|
423
|
+
path: '/',
|
|
424
|
+
// Align maxAge with the JWT expiresIn ('24h') so the cookie persists across
|
|
425
|
+
// browser restarts and does not vanish when the tab is closed.
|
|
426
|
+
maxAge: 24 * 60 * 60 * 1000,
|
|
427
|
+
});
|
|
428
|
+
res.cookie(cookieName, token, cookieOptions);
|
|
429
|
+
res.json({ success: true });
|
|
430
|
+
});
|
|
431
|
+
// ── Local Logout Handler ───────────────────────────────────────────────
|
|
432
|
+
router.post('/logout', (req, res) => {
|
|
433
|
+
const isSecure = req.secure === true || req.headers['x-forwarded-proto'] === 'https';
|
|
434
|
+
const explicitOptions = options.cookieOptions;
|
|
435
|
+
const cookieName = resolveAdminCookieName(isSecure, options.cookiePrefix, explicitOptions?.path, explicitOptions?.domain);
|
|
436
|
+
const cookieOptions = applyHostCookieRequirements(cookieName, explicitOptions || {
|
|
437
|
+
httpOnly: true,
|
|
438
|
+
secure: isSecure,
|
|
439
|
+
sameSite: 'lax',
|
|
440
|
+
path: '/',
|
|
441
|
+
});
|
|
442
|
+
res.clearCookie(cookieName, cookieOptions);
|
|
443
|
+
res.json({ success: true });
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
// Resolve effective uploadBaseUrl for both UI script and API responses
|
|
447
|
+
let effectiveUploadBaseUrl = options.uploadBaseUrl || '';
|
|
448
|
+
if (!effectiveUploadBaseUrl && options.apiPrefix && options.uploadDir) {
|
|
449
|
+
const prefix = options.apiPrefix.replace(/\/$/, '');
|
|
450
|
+
effectiveUploadBaseUrl = `${prefix}/ui/assets/uploads`;
|
|
451
|
+
}
|
|
452
|
+
const featSessions = !!options.sessionStore;
|
|
453
|
+
const featRoles = !!options.rbacStore;
|
|
454
|
+
const featTenants = !!options.tenantStore;
|
|
455
|
+
const featMetadata = !!options.userMetadataStore;
|
|
456
|
+
const featTwoFAPolicy = typeof userStore['updateRequire2FA'] === 'function'
|
|
457
|
+
&& typeof userStore.listUsers === 'function';
|
|
458
|
+
const featControl = !!options.settingsStore;
|
|
459
|
+
const featLinkedAccounts = !!options.linkedAccountsStore;
|
|
460
|
+
const featApiKeys = !!options.apiKeyStore;
|
|
461
|
+
const featWebhooks = !!options.webhookStore;
|
|
462
|
+
const featTemplates = !!options.templateStore;
|
|
463
|
+
const featUpload = !!options.uploadDir;
|
|
464
|
+
if (!options.silent) {
|
|
465
|
+
const featureEntries = [
|
|
466
|
+
['Sessions', featSessions],
|
|
467
|
+
['Roles & Permissions', featRoles],
|
|
468
|
+
['Tenants', featTenants],
|
|
469
|
+
['Metadata', featMetadata],
|
|
470
|
+
['Control', featControl],
|
|
471
|
+
['Linked Accounts', featLinkedAccounts],
|
|
472
|
+
['API Keys', featApiKeys],
|
|
473
|
+
['Webhooks', featWebhooks],
|
|
474
|
+
['Email & UI', featTemplates],
|
|
475
|
+
['Logo Upload', featUpload],
|
|
476
|
+
];
|
|
477
|
+
const enabled = featureEntries.filter(([, enabledFlag]) => enabledFlag).map(([label]) => label);
|
|
478
|
+
const disabled = featureEntries.filter(([, enabledFlag]) => !enabledFlag).map(([label]) => label);
|
|
479
|
+
process.stderr.write(`[awesome-node-auth] INFO: Admin tabs enabled=[${enabled.join(', ') || 'none'}] disabled=[${disabled.join(', ') || 'none'}]\n`);
|
|
480
|
+
}
|
|
481
|
+
// Resolve the directory that contains admin.css / admin.js.
|
|
482
|
+
// Mirrors the same candidate-list pattern used in ui.router.ts.
|
|
483
|
+
let _dirname = '';
|
|
484
|
+
try {
|
|
485
|
+
_dirname = __dirname;
|
|
486
|
+
}
|
|
487
|
+
catch { /* __dirname not available in some ESM contexts, fall back to cwd */
|
|
488
|
+
_dirname = process.cwd();
|
|
489
|
+
}
|
|
490
|
+
const adminAssetCandidates = [
|
|
491
|
+
path_1.default.resolve(_dirname, '../ui-assets'), // dist
|
|
492
|
+
path_1.default.resolve(_dirname, '../../ui-assets'), // alternate dist
|
|
493
|
+
path_1.default.resolve(_dirname, '../ui/assets'), // src
|
|
494
|
+
path_1.default.resolve(_dirname, '../../src/ui/assets'), // alternate src
|
|
495
|
+
path_1.default.resolve(process.cwd(), 'node_modules/@awesome-lang-auth/node/dist/ui-assets'),
|
|
496
|
+
path_1.default.resolve(process.cwd(), 'node_modules/@awesome-lang-auth/node/src/ui/assets'),
|
|
497
|
+
path_1.default.resolve(process.cwd(), 'node_modules/awesome-node-auth/dist/ui-assets'),
|
|
498
|
+
path_1.default.resolve(process.cwd(), 'node_modules/awesome-node-auth/src/ui/assets'),
|
|
499
|
+
];
|
|
500
|
+
// Read admin static assets once at startup to avoid repeated filesystem access.
|
|
501
|
+
// These files are small (<100 KB each) so in-memory caching is fine.
|
|
502
|
+
const adminCss = (() => {
|
|
503
|
+
for (const candidate of adminAssetCandidates) {
|
|
504
|
+
const f = path_1.default.join(candidate, 'admin.css');
|
|
505
|
+
if (fs_1.default.existsSync(f)) {
|
|
506
|
+
try {
|
|
507
|
+
return fs_1.default.readFileSync(f, 'utf8');
|
|
508
|
+
}
|
|
509
|
+
catch { /* fall through */ }
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return '';
|
|
513
|
+
})();
|
|
514
|
+
const adminJs = (() => {
|
|
515
|
+
for (const candidate of adminAssetCandidates) {
|
|
516
|
+
const f = path_1.default.join(candidate, 'admin.js');
|
|
517
|
+
if (fs_1.default.existsSync(f)) {
|
|
518
|
+
try {
|
|
519
|
+
return fs_1.default.readFileSync(f, 'utf8');
|
|
520
|
+
}
|
|
521
|
+
catch { /* fall through */ }
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return '';
|
|
525
|
+
})();
|
|
526
|
+
// Serve admin.css and admin.js as public static assets (no auth required).
|
|
527
|
+
// Content is cached in memory (read once above) to avoid repeated fs access.
|
|
528
|
+
router.get('/assets/admin.css', (_req, res) => {
|
|
529
|
+
if (!adminCss) {
|
|
530
|
+
res.status(404).send('Not found');
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
res.setHeader('Content-Type', 'text/css; charset=utf-8');
|
|
534
|
+
res.setHeader('Cache-Control', 'public, max-age=3600');
|
|
535
|
+
res.send(adminCss);
|
|
536
|
+
});
|
|
537
|
+
router.get('/assets/admin.js', (_req, res) => {
|
|
538
|
+
if (!adminJs) {
|
|
539
|
+
res.status(404).send('Not found');
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
|
543
|
+
res.setHeader('Cache-Control', 'public, max-age=3600');
|
|
544
|
+
res.send(adminJs);
|
|
545
|
+
});
|
|
546
|
+
// GET /admin — serve the HTML UI.
|
|
547
|
+
// When using accessPolicy (session-based): guard is applied so unauthenticated browsers are
|
|
548
|
+
// redirected to the login page before the HTML is served.
|
|
549
|
+
// When using legacy adminSecret: the HTML is served without auth (the client-side JS handles login).
|
|
550
|
+
const htmlRoute = sessionBased && panelGuard
|
|
551
|
+
? [panelGuard, (_req, res) => {
|
|
552
|
+
const needsAuth = (_req.adminNeedsAuth === true);
|
|
553
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
554
|
+
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
|
|
555
|
+
res.setHeader('Pragma', 'no-cache');
|
|
556
|
+
res.setHeader('Expires', '0');
|
|
557
|
+
res.send(buildAdminHtml(_req.baseUrl, {
|
|
558
|
+
sessions: featSessions, roles: featRoles, tenants: featTenants, metadata: featMetadata,
|
|
559
|
+
twoFAPolicy: featTwoFAPolicy, control: featControl, linkedAccounts: featLinkedAccounts,
|
|
560
|
+
apiKeys: featApiKeys, webhooks: featWebhooks, templates: featTemplates, upload: featUpload,
|
|
561
|
+
uploadBaseUrl: effectiveUploadBaseUrl, sessionBased,
|
|
562
|
+
showLogin: needsAuth,
|
|
563
|
+
authApiPrefix: options.apiPrefix,
|
|
564
|
+
cookiePrefix: options.cookiePrefix,
|
|
565
|
+
}));
|
|
566
|
+
}]
|
|
567
|
+
: [(_req, res) => {
|
|
568
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
569
|
+
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
|
|
570
|
+
res.setHeader('Pragma', 'no-cache');
|
|
571
|
+
res.setHeader('Expires', '0');
|
|
572
|
+
res.send(buildAdminHtml(_req.baseUrl, {
|
|
573
|
+
sessions: featSessions, roles: featRoles, tenants: featTenants, metadata: featMetadata,
|
|
574
|
+
twoFAPolicy: featTwoFAPolicy, control: featControl, linkedAccounts: featLinkedAccounts,
|
|
575
|
+
apiKeys: featApiKeys, webhooks: featWebhooks, templates: featTemplates, upload: featUpload,
|
|
576
|
+
uploadBaseUrl: effectiveUploadBaseUrl, sessionBased,
|
|
577
|
+
authApiPrefix: options.apiPrefix,
|
|
578
|
+
cookiePrefix: options.cookiePrefix,
|
|
579
|
+
}));
|
|
580
|
+
}];
|
|
581
|
+
router.get('/', ...htmlRoute);
|
|
582
|
+
// GET /admin/api/ping — health / auth check
|
|
583
|
+
router.get('/api/ping', guard, (_req, res) => {
|
|
584
|
+
res.json({ ok: true, features: { sessions: featSessions, roles: featRoles, tenants: featTenants, metadata: featMetadata, twoFAPolicy: featTwoFAPolicy, control: featControl, linkedAccounts: featLinkedAccounts, apiKeys: featApiKeys, webhooks: featWebhooks, templates: featTemplates, upload: featUpload } });
|
|
585
|
+
});
|
|
586
|
+
// ---- Users ----------------------------------------------------------------
|
|
587
|
+
// GET /admin/api/users?limit=&offset=
|
|
588
|
+
router.get('/api/users', guard, async (req, res) => {
|
|
589
|
+
try {
|
|
590
|
+
const limit = Math.min(parseInt(req.query['limit'] || '20', 10), 100);
|
|
591
|
+
const offset = parseInt(req.query['offset'] || '0', 10);
|
|
592
|
+
const filter = (req.query['filter'] || '').toLowerCase().trim();
|
|
593
|
+
if (!userStore.listUsers) {
|
|
594
|
+
res.status(501).json({ error: 'IUserStore.listUsers is not implemented', users: [], total: 0 });
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
// When a filter is provided, fetch up to 500 users to apply in-memory filtering.
|
|
598
|
+
// This is a best-effort approach for stores that don't implement native filtering.
|
|
599
|
+
// For large deployments, implement server-side filtering directly in IUserStore.listUsers.
|
|
600
|
+
const batchLimit = filter ? 500 : limit;
|
|
601
|
+
const batchOffset = filter ? 0 : offset;
|
|
602
|
+
const users = await userStore.listUsers(batchLimit, batchOffset);
|
|
603
|
+
// Strip sensitive fields before sending to admin
|
|
604
|
+
let safe = users.map(u => ({
|
|
605
|
+
id: u.id,
|
|
606
|
+
email: u.email,
|
|
607
|
+
role: u.role,
|
|
608
|
+
isEmailVerified: u.isEmailVerified,
|
|
609
|
+
isTotpEnabled: u.isTotpEnabled,
|
|
610
|
+
require2FA: u.require2FA,
|
|
611
|
+
phoneNumber: u.phoneNumber,
|
|
612
|
+
createdAt: u['createdAt'],
|
|
613
|
+
}));
|
|
614
|
+
if (filter) {
|
|
615
|
+
safe = safe.filter(u => u.email.toLowerCase().includes(filter) || u.id.toLowerCase().includes(filter));
|
|
616
|
+
const total = safe.length;
|
|
617
|
+
safe = safe.slice(offset, offset + limit);
|
|
618
|
+
res.json({ users: safe, total });
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
// Return total as the count of users returned (best-effort — stores may not expose total)
|
|
622
|
+
res.json({ users: safe, total: safe.length + offset + (safe.length === limit ? 1 : 0) });
|
|
623
|
+
}
|
|
624
|
+
catch (err) {
|
|
625
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
// GET /admin/api/users/:id
|
|
629
|
+
router.get('/api/users/:id', guard, async (req, res) => {
|
|
630
|
+
try {
|
|
631
|
+
const user = await userStore.findById(req.params['id']);
|
|
632
|
+
if (!user) {
|
|
633
|
+
res.status(404).json({ error: 'User not found' });
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
res.json({
|
|
637
|
+
id: user.id, email: user.email, role: user.role,
|
|
638
|
+
isEmailVerified: user.isEmailVerified, isTotpEnabled: user.isTotpEnabled,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
catch {
|
|
642
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
// DELETE /admin/api/users/:id — delete user (requires userStore to have a delete method if available)
|
|
646
|
+
router.delete('/api/users/:id', guard, async (req, res) => {
|
|
647
|
+
try {
|
|
648
|
+
const store = userStore;
|
|
649
|
+
if (typeof store['deleteUser'] === 'function') {
|
|
650
|
+
await store['deleteUser'](req.params['id']);
|
|
651
|
+
res.json({ success: true });
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
res.status(501).json({ error: 'IUserStore.deleteUser is not implemented' });
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
catch {
|
|
658
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
// ---- User Metadata --------------------------------------------------------
|
|
662
|
+
// POST /admin/api/2fa-policy — bulk set / clear the require-2FA flag on all users
|
|
663
|
+
router.post('/api/2fa-policy', guard, async (req, res) => {
|
|
664
|
+
try {
|
|
665
|
+
const { required } = req.body;
|
|
666
|
+
if (typeof required !== 'boolean') {
|
|
667
|
+
res.status(400).json({ error: '"required" must be a boolean' });
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
if (typeof userStore['updateRequire2FA'] !== 'function') {
|
|
671
|
+
res.status(501).json({ error: 'IUserStore.updateRequire2FA is not implemented' });
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (!userStore.listUsers) {
|
|
675
|
+
res.status(501).json({ error: 'IUserStore.listUsers is not implemented' });
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
const updateFn = userStore.updateRequire2FA.bind(userStore);
|
|
679
|
+
let offset = 0;
|
|
680
|
+
const batchSize = 100;
|
|
681
|
+
let updated = 0;
|
|
682
|
+
// eslint-disable-next-line no-constant-condition
|
|
683
|
+
while (true) {
|
|
684
|
+
const batch = await userStore.listUsers(batchSize, offset);
|
|
685
|
+
if (batch.length === 0)
|
|
686
|
+
break;
|
|
687
|
+
await Promise.all(batch.map(u => updateFn(u.id, required)));
|
|
688
|
+
updated += batch.length;
|
|
689
|
+
if (batch.length < batchSize)
|
|
690
|
+
break;
|
|
691
|
+
offset += batchSize;
|
|
692
|
+
}
|
|
693
|
+
res.json({ success: true, updated });
|
|
694
|
+
}
|
|
695
|
+
catch {
|
|
696
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
697
|
+
}
|
|
698
|
+
});
|
|
699
|
+
// GET /admin/api/users/:id/metadata
|
|
700
|
+
router.get('/api/users/:id/metadata', guard, async (req, res) => {
|
|
701
|
+
if (!options.userMetadataStore) {
|
|
702
|
+
res.status(404).json({ error: 'User metadata store not configured' });
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
try {
|
|
706
|
+
const metadata = await options.userMetadataStore.getMetadata(req.params['id']);
|
|
707
|
+
res.json(metadata);
|
|
708
|
+
}
|
|
709
|
+
catch {
|
|
710
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
// PUT /admin/api/users/:id/metadata
|
|
714
|
+
router.put('/api/users/:id/metadata', guard, async (req, res) => {
|
|
715
|
+
if (!options.userMetadataStore) {
|
|
716
|
+
res.status(404).json({ error: 'User metadata store not configured' });
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
try {
|
|
720
|
+
const metadata = req.body;
|
|
721
|
+
await options.userMetadataStore.updateMetadata(req.params['id'], metadata);
|
|
722
|
+
res.json({ success: true });
|
|
723
|
+
}
|
|
724
|
+
catch {
|
|
725
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
// ---- Linked Accounts (read-only view in admin panel) ----------------------
|
|
729
|
+
// GET /admin/api/users/:id/linked-accounts
|
|
730
|
+
router.get('/api/users/:id/linked-accounts', guard, async (req, res) => {
|
|
731
|
+
if (!options.linkedAccountsStore) {
|
|
732
|
+
res.status(404).json({ error: 'Linked accounts store not configured' });
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
try {
|
|
736
|
+
const linkedAccounts = await options.linkedAccountsStore.getLinkedAccounts(req.params['id']);
|
|
737
|
+
res.json({ linkedAccounts });
|
|
738
|
+
}
|
|
739
|
+
catch {
|
|
740
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
// ---- User ↔ Role assignment -----------------------------------------------
|
|
744
|
+
// GET /admin/api/users/:id/roles
|
|
745
|
+
router.get('/api/users/:id/roles', guard, async (req, res) => {
|
|
746
|
+
if (!options.rbacStore) {
|
|
747
|
+
res.status(404).json({ error: 'RBAC store not configured' });
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
try {
|
|
751
|
+
const roles = await options.rbacStore.getRolesForUser(req.params['id']);
|
|
752
|
+
res.json({ roles });
|
|
753
|
+
}
|
|
754
|
+
catch {
|
|
755
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
756
|
+
}
|
|
757
|
+
});
|
|
758
|
+
// POST /admin/api/users/:id/roles — assign a role to a user
|
|
759
|
+
router.post('/api/users/:id/roles', guard, async (req, res) => {
|
|
760
|
+
if (!options.rbacStore) {
|
|
761
|
+
res.status(404).json({ error: 'RBAC store not configured' });
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
try {
|
|
765
|
+
const { role, tenantId } = req.body;
|
|
766
|
+
if (!role) {
|
|
767
|
+
res.status(400).json({ error: 'role is required' });
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
const userId = req.params['id'];
|
|
771
|
+
await options.rbacStore.addRoleToUser(userId, role, tenantId);
|
|
772
|
+
(0, router_events_1.publishRequestEvent)(eventBus, auth_event_names_1.AuthEventNames.ROLE_ASSIGNED, req, {
|
|
773
|
+
userId,
|
|
774
|
+
tenantId,
|
|
775
|
+
data: { role, actorId: adminActorId(res) },
|
|
776
|
+
});
|
|
777
|
+
res.json({ success: true });
|
|
778
|
+
}
|
|
779
|
+
catch {
|
|
780
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
// DELETE /admin/api/users/:id/roles/:role — remove a role from a user
|
|
784
|
+
router.delete('/api/users/:id/roles/:role', guard, async (req, res) => {
|
|
785
|
+
if (!options.rbacStore) {
|
|
786
|
+
res.status(404).json({ error: 'RBAC store not configured' });
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
try {
|
|
790
|
+
const userId = req.params['id'];
|
|
791
|
+
const role = decodeURIComponent(req.params['role']);
|
|
792
|
+
await options.rbacStore.removeRoleFromUser(userId, role);
|
|
793
|
+
(0, router_events_1.publishRequestEvent)(eventBus, auth_event_names_1.AuthEventNames.ROLE_REVOKED, req, {
|
|
794
|
+
userId,
|
|
795
|
+
data: { role, actorId: adminActorId(res) },
|
|
796
|
+
});
|
|
797
|
+
res.json({ success: true });
|
|
798
|
+
}
|
|
799
|
+
catch {
|
|
800
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
// POST /admin/api/users/:id/promote — grant admin access (role or isAdmin flag).
|
|
804
|
+
// POST /admin/users/:id/promote is the deprecated alias without `/api`, with
|
|
805
|
+
// the same chain. requireJsonBody is the CSRF defence of both (see above).
|
|
806
|
+
const promoteHandler = async (req, res) => {
|
|
807
|
+
const userId = req.params['id'];
|
|
808
|
+
const method = req.body?.method ?? 'role';
|
|
809
|
+
// Refuse an unknown method, like promoteToAdmin(): falling back to the role
|
|
810
|
+
// would grant something the caller did not ask for.
|
|
811
|
+
if (method !== 'flag' && method !== 'role') {
|
|
812
|
+
res.status(400).json({ error: 'method must be "flag" or "role"' });
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
try {
|
|
816
|
+
if (method === 'flag') {
|
|
817
|
+
const writableUserStore = userStore;
|
|
818
|
+
if (typeof writableUserStore.update !== 'function') {
|
|
819
|
+
res.status(501).json({ error: 'IUserStore.update is required for method=flag' });
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
await writableUserStore.update(userId, { isAdmin: true });
|
|
823
|
+
(0, router_events_1.publishRequestEvent)(eventBus, auth_event_names_1.AuthEventNames.ROLE_ASSIGNED, req, {
|
|
824
|
+
userId,
|
|
825
|
+
data: { role: 'admin', method: 'flag', actorId: adminActorId(res) },
|
|
826
|
+
});
|
|
827
|
+
res.json({ success: true, method });
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
if (!options.rbacStore) {
|
|
831
|
+
res.status(404).json({ error: 'RBAC store not configured' });
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
await options.rbacStore.createRole('admin');
|
|
835
|
+
await options.rbacStore.addRoleToUser(userId, 'admin');
|
|
836
|
+
(0, router_events_1.publishRequestEvent)(eventBus, auth_event_names_1.AuthEventNames.ROLE_ASSIGNED, req, {
|
|
837
|
+
userId,
|
|
838
|
+
data: { role: 'admin', method: 'role', actorId: adminActorId(res) },
|
|
839
|
+
});
|
|
840
|
+
res.json({ success: true, method });
|
|
841
|
+
}
|
|
842
|
+
catch {
|
|
843
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
844
|
+
}
|
|
845
|
+
};
|
|
846
|
+
router.post('/api/users/:id/promote', ...rateLimiter, guard, requireJsonBody, promoteHandler);
|
|
847
|
+
/** @deprecated Use `POST /api/users/:id/promote`; kept as an alias with the same guard and behaviour. */
|
|
848
|
+
router.post('/users/:id/promote', ...rateLimiter, guard, requireJsonBody, promoteHandler);
|
|
849
|
+
// ---- User ↔ Tenant assignment (from user panel) --------------------------
|
|
850
|
+
// GET /admin/api/users/:id/tenants
|
|
851
|
+
router.get('/api/users/:id/tenants', guard, async (req, res) => {
|
|
852
|
+
if (!options.tenantStore) {
|
|
853
|
+
res.status(404).json({ error: 'Tenant store not configured' });
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
try {
|
|
857
|
+
const tenants = await options.tenantStore.getTenantsForUser(req.params['id']);
|
|
858
|
+
res.json({ tenantIds: tenants.map(t => t.id) });
|
|
859
|
+
}
|
|
860
|
+
catch {
|
|
861
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
// ---- Control settings -----------------------------------------------------
|
|
865
|
+
// GET /admin/api/actions — return registered webhook action metadata
|
|
866
|
+
router.get('/api/actions', guard, (_req, res) => {
|
|
867
|
+
res.json({ actions: webhook_action_1.ActionRegistry.getAllMeta() });
|
|
868
|
+
});
|
|
869
|
+
// GET /admin/api/settings
|
|
870
|
+
router.get('/api/settings', guard, async (_req, res) => {
|
|
871
|
+
if (!options.settingsStore) {
|
|
872
|
+
res.status(404).json({ error: 'Settings store not configured' });
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
try {
|
|
876
|
+
const settings = await options.settingsStore.getSettings();
|
|
877
|
+
res.json(settings);
|
|
878
|
+
}
|
|
879
|
+
catch {
|
|
880
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
881
|
+
}
|
|
882
|
+
});
|
|
883
|
+
// PUT /admin/api/settings
|
|
884
|
+
router.put('/api/settings', guard, async (req, res) => {
|
|
885
|
+
if (!options.settingsStore) {
|
|
886
|
+
res.status(404).json({ error: 'Settings store not configured' });
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
try {
|
|
890
|
+
const updates = req.body;
|
|
891
|
+
await options.settingsStore.updateSettings(updates);
|
|
892
|
+
res.json({ success: true });
|
|
893
|
+
}
|
|
894
|
+
catch {
|
|
895
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
896
|
+
}
|
|
897
|
+
});
|
|
898
|
+
// PATCH /admin/api/settings/ui — deep-merge only the UI sub-object, avoids the GET+PUT race
|
|
899
|
+
router.patch('/api/settings/ui', guard, async (req, res) => {
|
|
900
|
+
if (!options.settingsStore) {
|
|
901
|
+
res.status(404).json({ error: 'Settings store not configured' });
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
try {
|
|
905
|
+
const uiPatch = req.body;
|
|
906
|
+
// Read current settings, merge new UI fields, write back
|
|
907
|
+
const current = await options.settingsStore.getSettings();
|
|
908
|
+
const mergedUi = { ...(current.ui || {}), ...uiPatch };
|
|
909
|
+
await options.settingsStore.updateSettings({ ui: mergedUi });
|
|
910
|
+
res.json({ success: true });
|
|
911
|
+
}
|
|
912
|
+
catch (err) {
|
|
913
|
+
const msg = err instanceof Error ? err.message : 'Internal server error';
|
|
914
|
+
res.status(500).json({ error: msg });
|
|
915
|
+
}
|
|
916
|
+
});
|
|
917
|
+
// ---- File upload (requires uploadDir) -------------------------------------
|
|
918
|
+
if (options.uploadDir) {
|
|
919
|
+
const uploadDir = options.uploadDir;
|
|
920
|
+
// Ensure uploadDir exists
|
|
921
|
+
if (!fs_1.default.existsSync(uploadDir)) {
|
|
922
|
+
try {
|
|
923
|
+
fs_1.default.mkdirSync(uploadDir, { recursive: true });
|
|
924
|
+
}
|
|
925
|
+
catch (err) {
|
|
926
|
+
console.error('[awesome-node-auth] Failed to create uploadDir:', err);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
const upload = (0, multer_1.default)({
|
|
930
|
+
storage: multer_1.default.diskStorage({
|
|
931
|
+
destination: (_req, _file, cb) => cb(null, uploadDir),
|
|
932
|
+
filename: (_req, file, cb) => {
|
|
933
|
+
// Sanitize name (no dots, max 40 chars) and keep a clean extension
|
|
934
|
+
const rawExt = path_1.default.extname(file.originalname).toLowerCase();
|
|
935
|
+
const ext = rawExt.replace(/[^a-z0-9]/g, ''); // strip anything that isn't letters/digits
|
|
936
|
+
const name = path_1.default.basename(file.originalname, path_1.default.extname(file.originalname))
|
|
937
|
+
.replace(/[^a-z0-9_-]/gi, '_')
|
|
938
|
+
.slice(0, 40);
|
|
939
|
+
cb(null, `${name}_${Date.now()}.${ext}`);
|
|
940
|
+
},
|
|
941
|
+
}),
|
|
942
|
+
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
|
|
943
|
+
fileFilter: (_req, file, cb) => {
|
|
944
|
+
const allowed = /\.(png|jpg|jpeg|gif|svg|webp|ico)$/i;
|
|
945
|
+
if (allowed.test(file.originalname))
|
|
946
|
+
cb(null, true);
|
|
947
|
+
else
|
|
948
|
+
cb(new Error('Only image files are allowed'));
|
|
949
|
+
},
|
|
950
|
+
});
|
|
951
|
+
// POST /admin/api/upload/logo — upload logo image
|
|
952
|
+
router.post('/api/upload/logo', guard, upload.single('file'), (req, res) => {
|
|
953
|
+
if (!req.file) {
|
|
954
|
+
res.status(400).json({ error: 'No file uploaded' });
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
const filename = req.file.filename;
|
|
958
|
+
// Return the filename; the browser resolves the full URL via effectiveUploadBaseUrl
|
|
959
|
+
const url = effectiveUploadBaseUrl
|
|
960
|
+
? `${effectiveUploadBaseUrl.replace(/\/$/, '')}/${encodeURIComponent(filename)}`
|
|
961
|
+
: filename;
|
|
962
|
+
res.json({ success: true, filename, url });
|
|
963
|
+
});
|
|
964
|
+
// POST /admin/api/upload/bg-image — upload background image
|
|
965
|
+
router.post('/api/upload/bg-image', guard, upload.single('file'), (req, res) => {
|
|
966
|
+
if (!req.file) {
|
|
967
|
+
res.status(400).json({ error: 'No file uploaded' });
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
const filename = req.file.filename;
|
|
971
|
+
const url = effectiveUploadBaseUrl
|
|
972
|
+
? `${effectiveUploadBaseUrl.replace(/\/$/, '')}/${encodeURIComponent(filename)}`
|
|
973
|
+
: filename;
|
|
974
|
+
res.json({ success: true, filename, url });
|
|
975
|
+
});
|
|
976
|
+
// GET /admin/api/upload/files — list uploaded files
|
|
977
|
+
// Access is already gated behind adminAuth (Bearer token), so unauthorized
|
|
978
|
+
// callers are rejected before the filesystem is read.
|
|
979
|
+
router.get('/api/upload/files', guard, (_req, res) => {
|
|
980
|
+
try {
|
|
981
|
+
const files = fs_1.default.readdirSync(uploadDir)
|
|
982
|
+
.filter(f => /\.(png|jpg|jpeg|gif|svg|webp|ico)$/i.test(f))
|
|
983
|
+
.map(f => {
|
|
984
|
+
const stat = fs_1.default.statSync(path_1.default.join(uploadDir, f));
|
|
985
|
+
return { name: f, size: stat.size, mtime: stat.mtime.toISOString() };
|
|
986
|
+
})
|
|
987
|
+
.sort((a, b) => b.mtime.localeCompare(a.mtime));
|
|
988
|
+
res.json({ files });
|
|
989
|
+
}
|
|
990
|
+
catch (err) {
|
|
991
|
+
const msg = err instanceof Error ? err.message : 'Could not list files';
|
|
992
|
+
res.status(500).json({ error: msg });
|
|
993
|
+
}
|
|
994
|
+
});
|
|
995
|
+
// DELETE /admin/api/upload/:filename — delete an uploaded file
|
|
996
|
+
// Access is already gated behind adminAuth (Bearer token), so unauthorized
|
|
997
|
+
// callers are rejected before any filesystem mutation occurs.
|
|
998
|
+
router.delete('/api/upload/:filename', guard, (req, res) => {
|
|
999
|
+
const filename = req.params['filename'];
|
|
1000
|
+
// Prevent path traversal
|
|
1001
|
+
if (filename.includes('/') || filename.includes('\\') || filename.startsWith('.')) {
|
|
1002
|
+
res.status(400).json({ error: 'Invalid filename' });
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
const filepath = path_1.default.join(uploadDir, filename);
|
|
1006
|
+
if (!fs_1.default.existsSync(filepath)) {
|
|
1007
|
+
res.status(404).json({ error: 'File not found' });
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
try {
|
|
1011
|
+
fs_1.default.unlinkSync(filepath);
|
|
1012
|
+
res.json({ success: true });
|
|
1013
|
+
}
|
|
1014
|
+
catch (err) {
|
|
1015
|
+
const msg = err instanceof Error ? err.message : 'Could not delete file';
|
|
1016
|
+
res.status(500).json({ error: msg });
|
|
1017
|
+
}
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
// GET /admin/api/sessions?limit=&offset=&filter=
|
|
1021
|
+
router.get('/api/sessions', guard, async (req, res) => {
|
|
1022
|
+
if (!options.sessionStore) {
|
|
1023
|
+
res.status(404).json({ error: 'Session store not configured' });
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
try {
|
|
1027
|
+
const limit = Math.min(parseInt(req.query['limit'] || '20', 10), 100);
|
|
1028
|
+
const offset = parseInt(req.query['offset'] || '0', 10);
|
|
1029
|
+
const filter = (req.query['filter'] || '').toLowerCase().trim();
|
|
1030
|
+
if (!options.sessionStore.getAllSessions) {
|
|
1031
|
+
res.status(501).json({ error: 'ISessionStore.getAllSessions is not implemented', sessions: [], total: 0 });
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
if (filter) {
|
|
1035
|
+
// Best-effort in-memory filter (up to 500 records). For large deployments,
|
|
1036
|
+
// implement native filtering in ISessionStore.getAllSessions.
|
|
1037
|
+
const all = await options.sessionStore.getAllSessions(500, 0);
|
|
1038
|
+
const filtered = all.filter(s => s.userId.toLowerCase().includes(filter) ||
|
|
1039
|
+
(s.ipAddress ?? '').toLowerCase().includes(filter));
|
|
1040
|
+
const total = filtered.length;
|
|
1041
|
+
res.json({ sessions: filtered.slice(offset, offset + limit), total });
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
const sessions = await options.sessionStore.getAllSessions(limit, offset);
|
|
1045
|
+
res.json({ sessions, total: sessions.length + offset + (sessions.length === limit ? 1 : 0) });
|
|
1046
|
+
}
|
|
1047
|
+
catch {
|
|
1048
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
1049
|
+
}
|
|
1050
|
+
});
|
|
1051
|
+
// DELETE /admin/api/sessions/:handle
|
|
1052
|
+
router.delete('/api/sessions/:handle', guard, async (req, res) => {
|
|
1053
|
+
if (!options.sessionStore) {
|
|
1054
|
+
res.status(404).json({ error: 'Session store not configured' });
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
try {
|
|
1058
|
+
await options.sessionStore.revokeSession(decodeURIComponent(req.params['handle']));
|
|
1059
|
+
res.json({ success: true });
|
|
1060
|
+
}
|
|
1061
|
+
catch {
|
|
1062
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
1063
|
+
}
|
|
1064
|
+
});
|
|
1065
|
+
// ---- Roles & Permissions --------------------------------------------------
|
|
1066
|
+
// GET /admin/api/roles
|
|
1067
|
+
router.get('/api/roles', guard, async (_req, res) => {
|
|
1068
|
+
if (!options.rbacStore) {
|
|
1069
|
+
res.status(404).json({ error: 'RBAC store not configured' });
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
try {
|
|
1073
|
+
if (!options.rbacStore.getAllRoles) {
|
|
1074
|
+
res.status(501).json({ error: 'IRolesPermissionsStore.getAllRoles is not implemented', roles: [] });
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
const roleNames = await options.rbacStore.getAllRoles();
|
|
1078
|
+
const roles = await Promise.all(roleNames.map(async (name) => ({
|
|
1079
|
+
name,
|
|
1080
|
+
permissions: await options.rbacStore.getPermissionsForRole(name),
|
|
1081
|
+
})));
|
|
1082
|
+
res.json({ roles });
|
|
1083
|
+
}
|
|
1084
|
+
catch {
|
|
1085
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
1086
|
+
}
|
|
1087
|
+
});
|
|
1088
|
+
// POST /admin/api/roles
|
|
1089
|
+
router.post('/api/roles', guard, async (req, res) => {
|
|
1090
|
+
if (!options.rbacStore) {
|
|
1091
|
+
res.status(404).json({ error: 'RBAC store not configured' });
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
try {
|
|
1095
|
+
const { name, permissions } = req.body;
|
|
1096
|
+
if (!name) {
|
|
1097
|
+
res.status(400).json({ error: 'name is required' });
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
await options.rbacStore.createRole(name, permissions);
|
|
1101
|
+
res.json({ success: true });
|
|
1102
|
+
}
|
|
1103
|
+
catch {
|
|
1104
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
1107
|
+
// DELETE /admin/api/roles/:name
|
|
1108
|
+
router.delete('/api/roles/:name', guard, async (req, res) => {
|
|
1109
|
+
if (!options.rbacStore) {
|
|
1110
|
+
res.status(404).json({ error: 'RBAC store not configured' });
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
try {
|
|
1114
|
+
await options.rbacStore.deleteRole(decodeURIComponent(req.params['name']));
|
|
1115
|
+
res.json({ success: true });
|
|
1116
|
+
}
|
|
1117
|
+
catch {
|
|
1118
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
1119
|
+
}
|
|
1120
|
+
});
|
|
1121
|
+
// ---- Tenants --------------------------------------------------------------
|
|
1122
|
+
// GET /admin/api/tenants
|
|
1123
|
+
router.get('/api/tenants', guard, async (_req, res) => {
|
|
1124
|
+
if (!options.tenantStore) {
|
|
1125
|
+
res.status(404).json({ error: 'Tenant store not configured' });
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
try {
|
|
1129
|
+
const tenants = await options.tenantStore.getAllTenants();
|
|
1130
|
+
res.json({ tenants });
|
|
1131
|
+
}
|
|
1132
|
+
catch {
|
|
1133
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
1134
|
+
}
|
|
1135
|
+
});
|
|
1136
|
+
// POST /admin/api/tenants
|
|
1137
|
+
router.post('/api/tenants', guard, async (req, res) => {
|
|
1138
|
+
if (!options.tenantStore) {
|
|
1139
|
+
res.status(404).json({ error: 'Tenant store not configured' });
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
try {
|
|
1143
|
+
const { name, isActive } = req.body;
|
|
1144
|
+
if (!name) {
|
|
1145
|
+
res.status(400).json({ error: 'name is required' });
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
const tenant = await options.tenantStore.createTenant({ name, isActive: isActive ?? true });
|
|
1149
|
+
res.json({ tenant });
|
|
1150
|
+
}
|
|
1151
|
+
catch {
|
|
1152
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
1153
|
+
}
|
|
1154
|
+
});
|
|
1155
|
+
// DELETE /admin/api/tenants/:id
|
|
1156
|
+
router.delete('/api/tenants/:id', guard, async (req, res) => {
|
|
1157
|
+
if (!options.tenantStore) {
|
|
1158
|
+
res.status(404).json({ error: 'Tenant store not configured' });
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
try {
|
|
1162
|
+
await options.tenantStore.deleteTenant(decodeURIComponent(req.params['id']));
|
|
1163
|
+
res.json({ success: true });
|
|
1164
|
+
}
|
|
1165
|
+
catch {
|
|
1166
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
1167
|
+
}
|
|
1168
|
+
});
|
|
1169
|
+
// ---- Tenant ↔ User membership ---------------------------------------------
|
|
1170
|
+
// GET /admin/api/tenants/:id/users
|
|
1171
|
+
router.get('/api/tenants/:id/users', guard, async (req, res) => {
|
|
1172
|
+
if (!options.tenantStore) {
|
|
1173
|
+
res.status(404).json({ error: 'Tenant store not configured' });
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
try {
|
|
1177
|
+
const userIds = await options.tenantStore.getUsersForTenant(decodeURIComponent(req.params['id']));
|
|
1178
|
+
res.json({ userIds });
|
|
1179
|
+
}
|
|
1180
|
+
catch {
|
|
1181
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
1182
|
+
}
|
|
1183
|
+
});
|
|
1184
|
+
// POST /admin/api/tenants/:id/users — add a user to a tenant
|
|
1185
|
+
router.post('/api/tenants/:id/users', guard, async (req, res) => {
|
|
1186
|
+
if (!options.tenantStore) {
|
|
1187
|
+
res.status(404).json({ error: 'Tenant store not configured' });
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
try {
|
|
1191
|
+
const { userId } = req.body;
|
|
1192
|
+
if (!userId) {
|
|
1193
|
+
res.status(400).json({ error: 'userId is required' });
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
await options.tenantStore.associateUserWithTenant(userId, decodeURIComponent(req.params['id']));
|
|
1197
|
+
res.json({ success: true });
|
|
1198
|
+
}
|
|
1199
|
+
catch {
|
|
1200
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
1201
|
+
}
|
|
1202
|
+
});
|
|
1203
|
+
// DELETE /admin/api/tenants/:id/users/:userId — remove a user from a tenant
|
|
1204
|
+
router.delete('/api/tenants/:id/users/:userId', guard, async (req, res) => {
|
|
1205
|
+
if (!options.tenantStore) {
|
|
1206
|
+
res.status(404).json({ error: 'Tenant store not configured' });
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
try {
|
|
1210
|
+
await options.tenantStore.disassociateUserFromTenant(decodeURIComponent(req.params['userId']), decodeURIComponent(req.params['id']));
|
|
1211
|
+
res.json({ success: true });
|
|
1212
|
+
}
|
|
1213
|
+
catch {
|
|
1214
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
1215
|
+
}
|
|
1216
|
+
});
|
|
1217
|
+
// ── API Keys ───────────────────────────────────────────────────────────────
|
|
1218
|
+
// GET /admin/api/api-keys?limit=&offset=&filter=
|
|
1219
|
+
router.get('/api/api-keys', guard, async (req, res) => {
|
|
1220
|
+
if (!options.apiKeyStore) {
|
|
1221
|
+
res.status(404).json({ error: 'API key store not configured' });
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
try {
|
|
1225
|
+
const limit = Math.min(parseInt(req.query['limit'] || '20', 10), 100);
|
|
1226
|
+
const offset = parseInt(req.query['offset'] || '0', 10);
|
|
1227
|
+
const filter = (req.query['filter'] || '').toLowerCase().trim();
|
|
1228
|
+
if (!options.apiKeyStore.listAll) {
|
|
1229
|
+
res.status(501).json({ error: 'IApiKeyStore.listAll is not implemented', keys: [], total: 0 });
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
const batchLimit = filter ? 500 : limit;
|
|
1233
|
+
const batchOffset = filter ? 0 : offset;
|
|
1234
|
+
const keys = await options.apiKeyStore.listAll(batchLimit, batchOffset);
|
|
1235
|
+
const safe = (arr) => arr.map(k => ({
|
|
1236
|
+
id: k.id,
|
|
1237
|
+
name: k.name,
|
|
1238
|
+
keyPrefix: k.keyPrefix,
|
|
1239
|
+
serviceId: k.serviceId,
|
|
1240
|
+
scopes: k.scopes,
|
|
1241
|
+
allowedIps: k.allowedIps,
|
|
1242
|
+
isActive: k.isActive,
|
|
1243
|
+
expiresAt: k.expiresAt,
|
|
1244
|
+
createdAt: k.createdAt,
|
|
1245
|
+
lastUsedAt: k.lastUsedAt,
|
|
1246
|
+
}));
|
|
1247
|
+
if (filter) {
|
|
1248
|
+
const filtered = safe(keys).filter(k => k.name.toLowerCase().includes(filter) ||
|
|
1249
|
+
(k.serviceId ?? '').toLowerCase().includes(filter) ||
|
|
1250
|
+
k.keyPrefix.toLowerCase().includes(filter));
|
|
1251
|
+
const total = filtered.length;
|
|
1252
|
+
res.json({ keys: filtered.slice(offset, offset + limit), total });
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
res.json({ keys: safe(keys), total: keys.length + offset + (keys.length === limit ? 1 : 0) });
|
|
1256
|
+
}
|
|
1257
|
+
catch {
|
|
1258
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
1259
|
+
}
|
|
1260
|
+
});
|
|
1261
|
+
// POST /admin/api/api-keys — create a new key (returns rawKey once)
|
|
1262
|
+
router.post('/api/api-keys', guard, async (req, res) => {
|
|
1263
|
+
if (!options.apiKeyStore) {
|
|
1264
|
+
res.status(404).json({ error: 'API key store not configured' });
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
try {
|
|
1268
|
+
const { name, serviceId, scopes, allowedIps, expiresAt } = req.body;
|
|
1269
|
+
if (!name) {
|
|
1270
|
+
res.status(400).json({ error: 'name is required' });
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
const service = new api_key_service_1.ApiKeyService();
|
|
1274
|
+
const { rawKey, record } = await service.createKey(options.apiKeyStore, {
|
|
1275
|
+
name,
|
|
1276
|
+
serviceId,
|
|
1277
|
+
scopes,
|
|
1278
|
+
allowedIps,
|
|
1279
|
+
expiresAt: expiresAt ? new Date(expiresAt) : undefined,
|
|
1280
|
+
});
|
|
1281
|
+
res.json({
|
|
1282
|
+
rawKey,
|
|
1283
|
+
record: {
|
|
1284
|
+
id: record.id,
|
|
1285
|
+
name: record.name,
|
|
1286
|
+
keyPrefix: record.keyPrefix,
|
|
1287
|
+
serviceId: record.serviceId,
|
|
1288
|
+
scopes: record.scopes,
|
|
1289
|
+
allowedIps: record.allowedIps,
|
|
1290
|
+
isActive: record.isActive,
|
|
1291
|
+
expiresAt: record.expiresAt,
|
|
1292
|
+
createdAt: record.createdAt,
|
|
1293
|
+
},
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
catch {
|
|
1297
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
1298
|
+
}
|
|
1299
|
+
});
|
|
1300
|
+
// DELETE /admin/api/api-keys/:id/revoke — mark inactive (soft revoke)
|
|
1301
|
+
router.delete('/api/api-keys/:id/revoke', guard, async (req, res) => {
|
|
1302
|
+
if (!options.apiKeyStore) {
|
|
1303
|
+
res.status(404).json({ error: 'API key store not configured' });
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1306
|
+
try {
|
|
1307
|
+
await options.apiKeyStore.revoke(req.params['id']);
|
|
1308
|
+
res.json({ success: true });
|
|
1309
|
+
}
|
|
1310
|
+
catch {
|
|
1311
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
1312
|
+
}
|
|
1313
|
+
});
|
|
1314
|
+
// DELETE /admin/api/api-keys/:id — hard delete (falls back to revoke if .delete not implemented)
|
|
1315
|
+
router.delete('/api/api-keys/:id', guard, async (req, res) => {
|
|
1316
|
+
if (!options.apiKeyStore) {
|
|
1317
|
+
res.status(404).json({ error: 'API key store not configured' });
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
try {
|
|
1321
|
+
if (typeof options.apiKeyStore.delete === 'function') {
|
|
1322
|
+
await options.apiKeyStore.delete(req.params['id']);
|
|
1323
|
+
res.json({ success: true });
|
|
1324
|
+
}
|
|
1325
|
+
else {
|
|
1326
|
+
await options.apiKeyStore.revoke(req.params['id']);
|
|
1327
|
+
res.json({ success: true, note: 'IApiKeyStore.delete not implemented; key was revoked instead' });
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
catch {
|
|
1331
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
1332
|
+
}
|
|
1333
|
+
});
|
|
1334
|
+
// ── Webhooks ───────────────────────────────────────────────────────────────
|
|
1335
|
+
// GET /admin/api/webhooks?limit=&offset=
|
|
1336
|
+
router.get('/api/webhooks', guard, async (req, res) => {
|
|
1337
|
+
if (!options.webhookStore) {
|
|
1338
|
+
res.status(404).json({ error: 'Webhook store not configured' });
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
try {
|
|
1342
|
+
const limit = Math.min(parseInt(req.query['limit'] || '20', 10), 100);
|
|
1343
|
+
const offset = parseInt(req.query['offset'] || '0', 10);
|
|
1344
|
+
if (!options.webhookStore.listAll) {
|
|
1345
|
+
res.status(501).json({ error: 'IWebhookStore.listAll is not implemented', webhooks: [], total: 0 });
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
const webhooks = await options.webhookStore.listAll(limit, offset);
|
|
1349
|
+
const safe = webhooks.map(w => ({
|
|
1350
|
+
id: w.id,
|
|
1351
|
+
url: w.url,
|
|
1352
|
+
events: w.events,
|
|
1353
|
+
isActive: w.isActive,
|
|
1354
|
+
tenantId: w.tenantId,
|
|
1355
|
+
maxRetries: w.maxRetries,
|
|
1356
|
+
retryDelayMs: w.retryDelayMs,
|
|
1357
|
+
secret: w.secret ? '***' : undefined,
|
|
1358
|
+
}));
|
|
1359
|
+
res.json({ webhooks: safe, total: safe.length + offset + (safe.length === limit ? 1 : 0) });
|
|
1360
|
+
}
|
|
1361
|
+
catch {
|
|
1362
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
1363
|
+
}
|
|
1364
|
+
});
|
|
1365
|
+
// POST /admin/api/webhooks — register a new webhook
|
|
1366
|
+
router.post('/api/webhooks', guard, async (req, res) => {
|
|
1367
|
+
if (!options.webhookStore) {
|
|
1368
|
+
res.status(404).json({ error: 'Webhook store not configured' });
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
try {
|
|
1372
|
+
const { url, events, secret, tenantId, isActive, maxRetries, retryDelayMs } = req.body;
|
|
1373
|
+
if (!url) {
|
|
1374
|
+
res.status(400).json({ error: 'url is required' });
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
if (!options.webhookStore.add) {
|
|
1378
|
+
res.status(501).json({ error: 'IWebhookStore.add is not implemented' });
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
const webhook = await options.webhookStore.add({
|
|
1382
|
+
url, events: events ?? ['*'], secret, tenantId,
|
|
1383
|
+
isActive: isActive ?? true, maxRetries, retryDelayMs,
|
|
1384
|
+
});
|
|
1385
|
+
res.json({ webhook: { ...webhook, secret: webhook.secret ? '***' : undefined } });
|
|
1386
|
+
}
|
|
1387
|
+
catch {
|
|
1388
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
1389
|
+
}
|
|
1390
|
+
});
|
|
1391
|
+
// PATCH /admin/api/webhooks/:id — partial update (e.g. toggle isActive)
|
|
1392
|
+
router.patch('/api/webhooks/:id', guard, async (req, res) => {
|
|
1393
|
+
if (!options.webhookStore) {
|
|
1394
|
+
res.status(404).json({ error: 'Webhook store not configured' });
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
try {
|
|
1398
|
+
if (!options.webhookStore.update) {
|
|
1399
|
+
res.status(501).json({ error: 'IWebhookStore.update is not implemented' });
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
await options.webhookStore.update(req.params['id'], req.body);
|
|
1403
|
+
res.json({ success: true });
|
|
1404
|
+
}
|
|
1405
|
+
catch {
|
|
1406
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
1407
|
+
}
|
|
1408
|
+
});
|
|
1409
|
+
// DELETE /admin/api/webhooks/:id
|
|
1410
|
+
router.delete('/api/webhooks/:id', guard, async (req, res) => {
|
|
1411
|
+
if (!options.webhookStore) {
|
|
1412
|
+
res.status(404).json({ error: 'Webhook store not configured' });
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
try {
|
|
1416
|
+
if (!options.webhookStore.remove) {
|
|
1417
|
+
res.status(501).json({ error: 'IWebhookStore.remove is not implemented' });
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
await options.webhookStore.remove(req.params['id']);
|
|
1421
|
+
res.json({ success: true });
|
|
1422
|
+
}
|
|
1423
|
+
catch {
|
|
1424
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
1425
|
+
}
|
|
1426
|
+
});
|
|
1427
|
+
// ── Email & UI Templates ──────────────────────────────────────────────────
|
|
1428
|
+
if (featTemplates && options.templateStore) {
|
|
1429
|
+
const store = options.templateStore;
|
|
1430
|
+
// GET /admin/api/templates/mail — list all mail templates
|
|
1431
|
+
router.get('/api/templates/mail', guard, async (_req, res) => {
|
|
1432
|
+
try {
|
|
1433
|
+
const templates = await store.listMailTemplates();
|
|
1434
|
+
res.json({ templates });
|
|
1435
|
+
}
|
|
1436
|
+
catch {
|
|
1437
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
1438
|
+
}
|
|
1439
|
+
});
|
|
1440
|
+
// POST /admin/api/templates/mail — create or update a mail template
|
|
1441
|
+
router.post('/api/templates/mail', guard, async (req, res) => {
|
|
1442
|
+
try {
|
|
1443
|
+
const { id, baseHtml, baseText, translations } = req.body;
|
|
1444
|
+
if (!id) {
|
|
1445
|
+
res.status(400).json({ error: 'id is required' });
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
await store.updateMailTemplate(id, { baseHtml, baseText, translations });
|
|
1449
|
+
res.json({ success: true });
|
|
1450
|
+
}
|
|
1451
|
+
catch {
|
|
1452
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
1453
|
+
}
|
|
1454
|
+
});
|
|
1455
|
+
// GET /admin/api/templates/ui — list all UI translations
|
|
1456
|
+
router.get('/api/templates/ui', guard, async (_req, res) => {
|
|
1457
|
+
try {
|
|
1458
|
+
const translations = await store.listUiTranslations();
|
|
1459
|
+
res.json({ translations });
|
|
1460
|
+
}
|
|
1461
|
+
catch {
|
|
1462
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
1463
|
+
}
|
|
1464
|
+
});
|
|
1465
|
+
// POST /admin/api/templates/ui — update UI translations for a page
|
|
1466
|
+
router.post('/api/templates/ui', guard, async (req, res) => {
|
|
1467
|
+
try {
|
|
1468
|
+
const { page, translations } = req.body;
|
|
1469
|
+
if (!page || !translations) {
|
|
1470
|
+
res.status(400).json({ error: 'page and translations are required' });
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
await store.updateUiTranslations(page, translations);
|
|
1474
|
+
res.json({ success: true });
|
|
1475
|
+
}
|
|
1476
|
+
catch {
|
|
1477
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
1478
|
+
}
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
// ── Swagger / OpenAPI (optional) ───────────────────────────────────────────
|
|
1482
|
+
const swaggerEnabled = options.swagger === true ||
|
|
1483
|
+
(options.swagger !== false && process.env['NODE_ENV'] !== 'production');
|
|
1484
|
+
if (swaggerEnabled) {
|
|
1485
|
+
const specBasePath = options.swaggerBasePath ?? '/admin';
|
|
1486
|
+
router.get('/api/openapi.json', (_req, res) => {
|
|
1487
|
+
const spec = (0, openapi_1.buildAdminOpenApiSpec)({
|
|
1488
|
+
hasSessions: !!options.sessionStore,
|
|
1489
|
+
hasRoles: !!options.rbacStore,
|
|
1490
|
+
hasTenants: !!options.tenantStore,
|
|
1491
|
+
hasMetadata: !!options.userMetadataStore,
|
|
1492
|
+
hasSettings: !!options.settingsStore,
|
|
1493
|
+
hasLinkedAccounts: !!options.linkedAccountsStore,
|
|
1494
|
+
hasApiKeys: !!options.apiKeyStore,
|
|
1495
|
+
hasWebhooks: !!options.webhookStore,
|
|
1496
|
+
}, specBasePath);
|
|
1497
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1498
|
+
res.json(spec);
|
|
1499
|
+
});
|
|
1500
|
+
router.get('/api/docs', (_req, res) => {
|
|
1501
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
1502
|
+
res.send((0, openapi_1.buildSwaggerUiHtml)(`${specBasePath}/api/openapi.json`));
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
return router;
|
|
1506
|
+
}
|
|
1507
|
+
//# sourceMappingURL=admin.router.js.map
|