@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,1927 @@
|
|
|
1
|
+
/* awesome-node-auth admin panel — loaded via buildAdminHtml as a static asset */
|
|
2
|
+
/* jshint esversion: 8 */
|
|
3
|
+
(function () {
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
var cfg = window.__ADMIN_CONFIG__ || {};
|
|
7
|
+
var BASE = cfg.base || '';
|
|
8
|
+
var FEAT_ROLES = !!cfg.featRoles;
|
|
9
|
+
var FEAT_METADATA = !!cfg.featMetadata;
|
|
10
|
+
var FEAT_TENANTS = !!cfg.featTenants;
|
|
11
|
+
var FEAT_2FA_POLICY = !!cfg.feat2faPolicy;
|
|
12
|
+
var FEAT_CONTROL = !!cfg.featControl;
|
|
13
|
+
var FEAT_LINKED_ACCOUNTS = !!cfg.featLinkedAccounts;
|
|
14
|
+
var FEAT_API_KEYS = !!cfg.featApiKeys;
|
|
15
|
+
var FEAT_WEBHOOKS = !!cfg.featWebhooks;
|
|
16
|
+
var FEAT_TEMPLATES = !!cfg.featTemplates;
|
|
17
|
+
var FEAT_UPLOAD = !!cfg.featUpload;
|
|
18
|
+
var UPLOAD_BASE_URL = cfg.uploadBaseUrl || '';
|
|
19
|
+
|
|
20
|
+
var _token = '';
|
|
21
|
+
var _state = {
|
|
22
|
+
tab: 'users',
|
|
23
|
+
users: { page: 0, openId: null, rolesCache: {}, linkedAccountsCache: {}, filter: '', selected: new Set() },
|
|
24
|
+
sessions: { page: 0, filter: '' },
|
|
25
|
+
roles: { filter: '' },
|
|
26
|
+
tenants: { openId: null, filter: '' },
|
|
27
|
+
apiKeys: { page: 0, filter: '', newRaw: null },
|
|
28
|
+
webhooks: { page: 0 },
|
|
29
|
+
templates: { type: 'mail', selectedId: null, mailTemplates: [], uiTranslations: [] }
|
|
30
|
+
};
|
|
31
|
+
var PAGE_SIZE = 20;
|
|
32
|
+
|
|
33
|
+
// ---- Nav tabs built from config ----------------------------------------
|
|
34
|
+
function buildNav() {
|
|
35
|
+
var nav = document.getElementById('nav');
|
|
36
|
+
if (!nav) return;
|
|
37
|
+
var tabs = [{ id: 'users', label: '\uD83D\uDC64 Users' }];
|
|
38
|
+
if (cfg.featSessions) tabs.push({ id: 'sessions', label: '\uD83D\uDCCB Sessions' });
|
|
39
|
+
if (FEAT_ROLES) tabs.push({ id: 'roles', label: '\uD83D\uDEE1\uFE0F Roles & Permissions' });
|
|
40
|
+
if (FEAT_TENANTS) tabs.push({ id: 'tenants', label: '\uD83C\uDFE2 Tenants' });
|
|
41
|
+
if (FEAT_API_KEYS) tabs.push({ id: 'apiKeys', label: '\uD83D\uDD11 API Keys' });
|
|
42
|
+
if (FEAT_WEBHOOKS) tabs.push({ id: 'webhooks', label: '\uD83D\uDD17 Webhooks' });
|
|
43
|
+
if (FEAT_CONTROL) tabs.push({ id: 'control', label: '\u2699\uFE0F Control' });
|
|
44
|
+
if (FEAT_TEMPLATES) tabs.push({ id: 'templates', label: '\uD83D\uDCE7 Email & UI' });
|
|
45
|
+
tabs.forEach(function (t) {
|
|
46
|
+
var btn = document.createElement('button');
|
|
47
|
+
btn.id = 'tab-' + t.id;
|
|
48
|
+
btn.textContent = t.label;
|
|
49
|
+
btn.onclick = function () { showTab(t.id); };
|
|
50
|
+
nav.insertBefore(btn, nav.querySelector('.logout-btn'));
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---- Auth ----------------------------------------------------------------
|
|
55
|
+
async function doLogin() {
|
|
56
|
+
var secret = document.getElementById('secret-input').value.trim();
|
|
57
|
+
if (!secret) return;
|
|
58
|
+
|
|
59
|
+
document.getElementById('login-error').style.display = 'none';
|
|
60
|
+
|
|
61
|
+
if (cfg.sessionBased) {
|
|
62
|
+
// Session-based login (Email + Password)
|
|
63
|
+
var emailEl = document.getElementById('email-input');
|
|
64
|
+
var email = emailEl ? emailEl.value.trim() : '';
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
// Point to the local /login handler within the admin router
|
|
68
|
+
var loginUrl = cfg.base + '/login';
|
|
69
|
+
var res = await fetch(loginUrl, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: { 'Content-Type': 'application/json' },
|
|
72
|
+
credentials: 'include',
|
|
73
|
+
body: JSON.stringify({ email: email, password: secret })
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
if (!res.ok) {
|
|
77
|
+
var err = await res.json().catch(function () { return { error: res.statusText }; });
|
|
78
|
+
throw new Error(err.error || 'Invalid credentials');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Login successful (cookie set by server). Reload to let server-side guard pass.
|
|
82
|
+
location.reload();
|
|
83
|
+
} catch (e) {
|
|
84
|
+
document.getElementById('login-error').textContent = e.message;
|
|
85
|
+
document.getElementById('login-error').style.display = 'block';
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
// Legacy Admin Secret login
|
|
89
|
+
sessionStorage.setItem('admin_token', secret);
|
|
90
|
+
_token = secret;
|
|
91
|
+
api('GET', '/api/ping').then(function () {
|
|
92
|
+
document.getElementById('login').style.display = 'none';
|
|
93
|
+
document.getElementById('app').style.display = 'flex';
|
|
94
|
+
document.getElementById('app').style.flexDirection = 'column';
|
|
95
|
+
showTab('users');
|
|
96
|
+
}).catch(function () {
|
|
97
|
+
sessionStorage.removeItem('admin_token');
|
|
98
|
+
_token = '';
|
|
99
|
+
document.getElementById('login-error').textContent = 'Invalid admin secret';
|
|
100
|
+
document.getElementById('login-error').style.display = 'block';
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function doLogout() {
|
|
106
|
+
if (cfg.sessionBased) {
|
|
107
|
+
// Session-based: call the local admin logout endpoint to clear the HTTP-only cookie.
|
|
108
|
+
try {
|
|
109
|
+
// Use the local /logout handler within the admin router
|
|
110
|
+
await fetch(cfg.base + '/logout', { method: 'POST', credentials: 'include' });
|
|
111
|
+
} catch { /* ignore network errors — still reload */ }
|
|
112
|
+
location.reload();
|
|
113
|
+
} else {
|
|
114
|
+
sessionStorage.removeItem('admin_token');
|
|
115
|
+
location.reload();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
var secretInput = document.getElementById('secret-input');
|
|
120
|
+
if (secretInput) {
|
|
121
|
+
secretInput.addEventListener('keydown', function (e) {
|
|
122
|
+
if (e.key === 'Enter') doLogin();
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Auto-login if token stored (legacy)
|
|
127
|
+
var stored = sessionStorage.getItem('admin_token');
|
|
128
|
+
if (stored) {
|
|
129
|
+
_token = stored;
|
|
130
|
+
api('GET', '/api/ping').then(function () {
|
|
131
|
+
document.getElementById('login').style.display = 'none';
|
|
132
|
+
document.getElementById('app').style.display = 'flex';
|
|
133
|
+
document.getElementById('app').style.flexDirection = 'column';
|
|
134
|
+
showTab('users');
|
|
135
|
+
}).catch(function () { sessionStorage.removeItem('admin_token'); _token = ''; });
|
|
136
|
+
} else if (cfg.sessionBased && !document.getElementById('login')) {
|
|
137
|
+
// Session-based (cookie): if the server didn't render the login form, we are already authenticated.
|
|
138
|
+
document.getElementById('app').style.display = 'flex';
|
|
139
|
+
document.getElementById('app').style.flexDirection = 'column';
|
|
140
|
+
showTab('users');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---- API helper ----------------------------------------------------------
|
|
144
|
+
async function api(method, path, body) {
|
|
145
|
+
var headers = { 'Content-Type': 'application/json' };
|
|
146
|
+
// In token-based (legacy adminSecret) mode, attach the Bearer token.
|
|
147
|
+
// In session-based (cookie) mode, never send an empty Authorization header —
|
|
148
|
+
// an empty "Bearer " string can confuse global fetch interceptors (e.g. auth.js)
|
|
149
|
+
// and cause them to treat any 401 from admin API endpoints as a session expiry
|
|
150
|
+
// event, triggering a redirect to /auth/ui/login.
|
|
151
|
+
if (_token) {
|
|
152
|
+
headers['Authorization'] = 'Bearer ' + _token;
|
|
153
|
+
}
|
|
154
|
+
var res = await fetch(BASE + path, {
|
|
155
|
+
method: method,
|
|
156
|
+
headers: headers,
|
|
157
|
+
// Always include cookies so the admin session cookie is sent even in
|
|
158
|
+
// scenarios where the admin router is mounted on a different sub-path
|
|
159
|
+
// or when a service worker intercepts the request.
|
|
160
|
+
credentials: 'include',
|
|
161
|
+
body: body ? JSON.stringify(body) : undefined
|
|
162
|
+
});
|
|
163
|
+
if (!res.ok) {
|
|
164
|
+
var err = await res.json().catch(function () { return { error: res.statusText }; });
|
|
165
|
+
throw new Error(err.error || res.statusText);
|
|
166
|
+
}
|
|
167
|
+
return res.json();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---- Flash ---------------------------------------------------------------
|
|
171
|
+
function flash(msg, type) {
|
|
172
|
+
var el = document.createElement('div');
|
|
173
|
+
el.className = 'alert alert-' + (type === 'error' ? 'error' : 'success');
|
|
174
|
+
el.textContent = msg;
|
|
175
|
+
el.style.cssText = 'padding:.75rem 1rem;border-radius:6px;font-size:.8125rem;margin-bottom:.5rem;box-shadow:0 2px 8px rgba(0,0,0,.1)';
|
|
176
|
+
document.getElementById('flash').appendChild(el);
|
|
177
|
+
setTimeout(function () { el.remove(); }, 3500);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---- Tab routing ---------------------------------------------------------
|
|
181
|
+
function showTab(tab) {
|
|
182
|
+
_state.tab = tab;
|
|
183
|
+
document.querySelectorAll('nav button').forEach(function (b) { b.classList.remove('active'); });
|
|
184
|
+
var btn = document.getElementById('tab-' + tab);
|
|
185
|
+
if (btn) btn.classList.add('active');
|
|
186
|
+
if (tab === 'users') renderUsers();
|
|
187
|
+
else if (tab === 'sessions') renderSessions();
|
|
188
|
+
else if (tab === 'roles') renderRoles();
|
|
189
|
+
else if (tab === 'tenants') renderTenants();
|
|
190
|
+
else if (tab === 'apiKeys') renderApiKeys();
|
|
191
|
+
else if (tab === 'webhooks') renderWebhooks();
|
|
192
|
+
else if (tab === 'control') renderControl();
|
|
193
|
+
else if (tab === 'templates') renderTemplates();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---- Helpers -------------------------------------------------------------
|
|
197
|
+
function badge(text, cls) {
|
|
198
|
+
return '<span class="badge badge-' + cls + '">' + esc(String(text)) + '</span>';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function esc(s) {
|
|
202
|
+
return String(s)
|
|
203
|
+
.replace(/&/g, '&')
|
|
204
|
+
.replace(/</g, '<')
|
|
205
|
+
.replace(/>/g, '>')
|
|
206
|
+
.replace(/"/g, '"')
|
|
207
|
+
.replace(/'/g, ''');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function ts(d) {
|
|
211
|
+
if (!d) return '\u2014';
|
|
212
|
+
try { return new Date(d).toLocaleString(); } catch (e) { return String(d); }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function pagerHtml(page, hasMore, prev, next) {
|
|
216
|
+
return '<div class="pager"><button ' + (page === 0 ? 'disabled' : '') + ' onclick="' + prev + '">\u2190 Prev</button>'
|
|
217
|
+
+ '<span>Page ' + (page + 1) + '</span>'
|
|
218
|
+
+ '<button ' + (!hasMore ? 'disabled' : '') + ' onclick="' + next + '">Next \u2192</button></div>';
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---- Users ---------------------------------------------------------------
|
|
222
|
+
async function renderUsers() {
|
|
223
|
+
var main = document.getElementById('main');
|
|
224
|
+
main.innerHTML = '<div class="card"><div class="card-header"><h2>Users</h2><span class="meta"><span class="spinner"></span></span></div></div>';
|
|
225
|
+
try {
|
|
226
|
+
var filterParam = _state.users.filter ? '&filter=' + encodeURIComponent(_state.users.filter) : '';
|
|
227
|
+
var res = await api('GET', '/api/users?limit=' + PAGE_SIZE + '&offset=' + (_state.users.page * PAGE_SIZE) + filterParam);
|
|
228
|
+
var users = res.users;
|
|
229
|
+
var total = res.total;
|
|
230
|
+
var hasMore = (_state.users.page + 1) * PAGE_SIZE < total;
|
|
231
|
+
var showManage = FEAT_ROLES || FEAT_METADATA || FEAT_TENANTS || FEAT_LINKED_ACCOUNTS;
|
|
232
|
+
if (FEAT_ROLES && users.length > 0) {
|
|
233
|
+
await Promise.allSettled(users.map(function (u) {
|
|
234
|
+
return api('GET', '/api/users/' + encodeURIComponent(u.id) + '/roles')
|
|
235
|
+
.then(function (d) { _state.users.rolesCache[u.id] = d.roles || []; })
|
|
236
|
+
.catch(function () { });
|
|
237
|
+
}));
|
|
238
|
+
}
|
|
239
|
+
if (FEAT_LINKED_ACCOUNTS && users.length > 0) {
|
|
240
|
+
await Promise.allSettled(users.map(function (u) {
|
|
241
|
+
return api('GET', '/api/users/' + encodeURIComponent(u.id) + '/linked-accounts')
|
|
242
|
+
.then(function (d) { _state.users.linkedAccountsCache[u.id] = d.linkedAccounts || []; })
|
|
243
|
+
.catch(function () { });
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
246
|
+
var colCount = 8 + (FEAT_ROLES ? 1 : 0) + (FEAT_LINKED_ACCOUNTS ? 1 : 0);
|
|
247
|
+
var rows = '';
|
|
248
|
+
if (users.length === 0) {
|
|
249
|
+
rows = '<tr><td colspan="' + colCount + '"><div class="empty">No users found</div></td></tr>';
|
|
250
|
+
} else {
|
|
251
|
+
for (var i = 0; i < users.length; i++) {
|
|
252
|
+
var u = users[i];
|
|
253
|
+
var isOpen = _state.users.openId === u.id;
|
|
254
|
+
var isChecked = _state.users.selected.has(u.id);
|
|
255
|
+
var rbacRoles = FEAT_ROLES ? (_state.users.rolesCache[u.id] || []) : [];
|
|
256
|
+
var rbacCol = FEAT_ROLES
|
|
257
|
+
? '<td>' + (rbacRoles.length === 0 ? '<span style="color:#9ca3af">\u2014</span>' : rbacRoles.map(function (r) { return badge(r, 'indigo'); }).join(' ')) + '</td>'
|
|
258
|
+
: '';
|
|
259
|
+
var linkedAccounts = FEAT_LINKED_ACCOUNTS ? (_state.users.linkedAccountsCache[u.id] || []) : [];
|
|
260
|
+
var linkedCol = FEAT_LINKED_ACCOUNTS
|
|
261
|
+
? '<td>' + (linkedAccounts.length === 0
|
|
262
|
+
? '<span style="color:#9ca3af">\u2014</span>'
|
|
263
|
+
: badge(linkedAccounts[0].provider, 'purple') + (linkedAccounts.length > 1 ? ' <span style="color:#6b7280;font-size:.75rem">+' + (linkedAccounts.length - 1) + '</span>' : ''))
|
|
264
|
+
+ '</td>'
|
|
265
|
+
: '';
|
|
266
|
+
rows += '<tr' + (isOpen ? ' class="tr-open"' : '') + '>'
|
|
267
|
+
+ '<td class="cb-col"><input type="checkbox" ' + (isChecked ? 'checked' : '') + ' onchange="toggleSelectUser(' + esc(JSON.stringify(u.id)) + ')"></td>'
|
|
268
|
+
+ '<td style="font-family:monospace;font-size:.75rem">' + esc(u.id) + '</td>'
|
|
269
|
+
+ '<td>' + esc(u.email) + '</td>'
|
|
270
|
+
+ '<td>' + (u.role ? badge(u.role, 'blue') : badge('\u2014', 'gray')) + '</td>'
|
|
271
|
+
+ rbacCol
|
|
272
|
+
+ linkedCol
|
|
273
|
+
+ '<td>' + (u.isEmailVerified ? badge('\u2713 verified', 'green') : badge('unverified', 'gray')) + '</td>'
|
|
274
|
+
+ '<td>' + (u.isTotpEnabled ? badge('on', 'green') : badge('off', 'gray')) + '</td>'
|
|
275
|
+
+ '<td>' + ts(u.createdAt) + '</td>'
|
|
276
|
+
+ '<td style="display:flex;gap:.25rem">'
|
|
277
|
+
+ (showManage ? '<button class="btn btn-sm" style="background:' + (isOpen ? '#1a1a2e' : '#e0e7ff') + ';color:' + (isOpen ? 'white' : '#3730a3') + '" onclick="toggleUserPanel(' + esc(JSON.stringify(u.id)) + ')"> ' + (isOpen ? 'Close' : 'Manage') + '</button>' : '')
|
|
278
|
+
+ '<button class="btn btn-danger" onclick="deleteUser(' + esc(JSON.stringify(u.id)) + ', ' + esc(JSON.stringify(u.email)) + ')">Delete</button>'
|
|
279
|
+
+ '</td>'
|
|
280
|
+
+ '</tr>';
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
var openUser = _state.users.openId ? users.find(function (u) { return u.id === _state.users.openId; }) : null;
|
|
284
|
+
var panelHtml = openUser
|
|
285
|
+
? '<div class="card" style="border-top:3px solid #1a1a2e">'
|
|
286
|
+
+ '<div class="card-header" style="background:#f0f4ff"><h2 style="font-size:.9375rem">\u2699\uFE0F\u00A0Managing: <span style="color:#3730a3;font-weight:700">' + esc(openUser.email) + '</span></h2></div>'
|
|
287
|
+
+ '<div id="user-panel-' + esc(openUser.id) + '" style="padding:1.25rem 1.5rem"><span class="spinner"></span></div>'
|
|
288
|
+
+ '</div>'
|
|
289
|
+
: '';
|
|
290
|
+
var selCount = _state.users.selected.size;
|
|
291
|
+
var batchBar = '<div class="batch-bar' + (selCount > 0 ? ' visible' : '') + '" id="batch-bar">'
|
|
292
|
+
+ '<span>' + selCount + ' user(s) selected</span>'
|
|
293
|
+
+ '<button class="btn btn-danger btn-sm" onclick="deleteSelected()">Delete selected</button>'
|
|
294
|
+
+ '<button class="btn btn-sm" style="background:#f3f4f6;border:1px solid #e5e7eb" onclick="clearSelection()">Clear</button>'
|
|
295
|
+
+ '</div>';
|
|
296
|
+
var allChecked = users.length > 0 && users.every(function (u) { return _state.users.selected.has(u.id); });
|
|
297
|
+
var thead = '<thead><tr><th class="cb-col"><input type="checkbox" ' + (allChecked ? 'checked' : '') + ' onchange="toggleSelectAll(this, ' + esc(JSON.stringify(users.map(function (u) { return u.id; }))) + ')"></th><th>ID</th><th>Email</th><th>Base Role</th>'
|
|
298
|
+
+ (FEAT_ROLES ? '<th>Assigned Roles</th>' : '')
|
|
299
|
+
+ (FEAT_LINKED_ACCOUNTS ? '<th>Linked Accounts</th>' : '')
|
|
300
|
+
+ '<th>Verified</th><th>2FA</th><th>Created</th><th></th></tr></thead>';
|
|
301
|
+
var policyCard = FEAT_2FA_POLICY
|
|
302
|
+
? '<div class="card" style="border-left:4px solid #f59e0b">'
|
|
303
|
+
+ '<div class="card-header" style="background:#fffbeb"><h2 style="font-size:.9375rem">\uD83D\uDD10 2FA Enforcement Policy</h2><span class="meta">Batch operation</span></div>'
|
|
304
|
+
+ '<div style="padding:1rem 1.5rem;display:flex;align-items:center;gap:1.25rem;flex-wrap:wrap">'
|
|
305
|
+
+ '<p style="font-size:.875rem;color:#6b7280;flex:1;min-width:180px">Force all users to activate Two-Factor Authentication. Users without 2FA configured will be blocked at login and prompted to set it up.</p>'
|
|
306
|
+
+ '<div style="display:flex;gap:.5rem;flex-shrink:0">'
|
|
307
|
+
+ '<button class="btn btn-primary" onclick="setBulk2FA(true)">Require 2FA for all</button>'
|
|
308
|
+
+ '<button class="btn" style="background:#f3f4f6;border:1px solid #e5e7eb" onclick="setBulk2FA(false)">Remove requirement</button>'
|
|
309
|
+
+ '</div>'
|
|
310
|
+
+ '</div></div>'
|
|
311
|
+
: '';
|
|
312
|
+
var filterBar = '<div class="filter-bar">'
|
|
313
|
+
+ '<input type="text" placeholder="Filter by email or ID\u2026" value="' + esc(_state.users.filter) + '" oninput="_state.users.filter=this.value;_state.users.page=0;_state.users.selected=new Set();renderUsers()" style="max-width:300px">'
|
|
314
|
+
+ '<span style="font-size:.8125rem;color:#9ca3af">' + total + ' total</span>'
|
|
315
|
+
+ '</div>';
|
|
316
|
+
main.innerHTML =
|
|
317
|
+
policyCard
|
|
318
|
+
+ '<div class="card">'
|
|
319
|
+
+ '<div class="card-header"><h2>Users</h2></div>'
|
|
320
|
+
+ filterBar
|
|
321
|
+
+ batchBar
|
|
322
|
+
+ '<div class="table-wrap"><table>' + thead + '<tbody>' + rows + '</tbody></table></div>'
|
|
323
|
+
+ pagerHtml(_state.users.page, hasMore, '_state.users.page--;renderUsers()', '_state.users.page++;renderUsers()')
|
|
324
|
+
+ '</div>'
|
|
325
|
+
+ panelHtml;
|
|
326
|
+
if (_state.users.openId) loadUserPanel(_state.users.openId);
|
|
327
|
+
} catch (e) {
|
|
328
|
+
main.innerHTML = '<div class="alert alert-error">' + esc(e.message) + '</div>';
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function toggleSelectUser(id) {
|
|
333
|
+
if (_state.users.selected.has(id)) _state.users.selected.delete(id);
|
|
334
|
+
else _state.users.selected.add(id);
|
|
335
|
+
var bar = document.getElementById('batch-bar');
|
|
336
|
+
if (bar) {
|
|
337
|
+
var n = _state.users.selected.size;
|
|
338
|
+
bar.className = 'batch-bar' + (n > 0 ? ' visible' : '');
|
|
339
|
+
bar.querySelector('span').textContent = n + ' user(s) selected';
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function toggleSelectAll(cb, ids) {
|
|
344
|
+
if (cb.checked) ids.forEach(function (id) { _state.users.selected.add(id); });
|
|
345
|
+
else ids.forEach(function (id) { _state.users.selected.delete(id); });
|
|
346
|
+
renderUsers();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function clearSelection() {
|
|
350
|
+
_state.users.selected = new Set();
|
|
351
|
+
renderUsers();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function deleteSelected() {
|
|
355
|
+
var ids = [..._state.users.selected];
|
|
356
|
+
if (ids.length === 0) return;
|
|
357
|
+
if (!confirm('Delete ' + ids.length + ' user(s)? This cannot be undone.')) return;
|
|
358
|
+
try {
|
|
359
|
+
await Promise.all(ids.map(function (id) { return api('DELETE', '/api/users/' + encodeURIComponent(id)); }));
|
|
360
|
+
flash(ids.length + ' user(s) deleted');
|
|
361
|
+
_state.users.selected = new Set();
|
|
362
|
+
renderUsers();
|
|
363
|
+
} catch (e) { flash(e.message, 'error'); }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function toggleUserPanel(id) {
|
|
367
|
+
_state.users.openId = _state.users.openId === id ? null : id;
|
|
368
|
+
renderUsers();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function loadUserPanel(userId) {
|
|
372
|
+
var el = document.getElementById('user-panel-' + userId);
|
|
373
|
+
if (!el) return;
|
|
374
|
+
try {
|
|
375
|
+
var sections = '';
|
|
376
|
+
if (FEAT_ROLES) {
|
|
377
|
+
var roleResults = await Promise.all([
|
|
378
|
+
api('GET', '/api/roles').catch(function () { return { roles: [] }; }),
|
|
379
|
+
api('GET', '/api/users/' + encodeURIComponent(userId) + '/roles').catch(function () { return { roles: [] }; })
|
|
380
|
+
]);
|
|
381
|
+
var allRoles = roleResults[0].roles || [];
|
|
382
|
+
var userRoles = roleResults[1].roles || [];
|
|
383
|
+
_state.users.rolesCache[userId] = userRoles;
|
|
384
|
+
var unassigned = allRoles.filter(function (r) { return !userRoles.includes(r.name); });
|
|
385
|
+
var chipList = userRoles.length === 0
|
|
386
|
+
? '<span style="color:#9ca3af;font-size:.8125rem">No roles assigned</span>'
|
|
387
|
+
: userRoles.map(function (r) { return '<span class="badge badge-blue role-chip" title="Click to remove" onclick="removeUserRole(' + esc(JSON.stringify(userId)) + ', ' + esc(JSON.stringify(r)) + ')">' + esc(r) + ' \u2715</span>'; }).join(' ');
|
|
388
|
+
var assignRow = unassigned.length > 0
|
|
389
|
+
? '<div class="form-row" style="margin-top:.625rem">'
|
|
390
|
+
+ '<select id="role-sel-' + esc(userId) + '" class="form-select">'
|
|
391
|
+
+ unassigned.map(function (r) { return '<option value="' + esc(r.name) + '">' + esc(r.name) + '</option>'; }).join('')
|
|
392
|
+
+ '</select>'
|
|
393
|
+
+ '<button class="btn btn-primary btn-sm" onclick="addUserRole(' + esc(JSON.stringify(userId)) + ')">Assign</button>'
|
|
394
|
+
+ '</div>'
|
|
395
|
+
: '<p style="font-size:.75rem;color:#6b7280;margin-top:.5rem">'
|
|
396
|
+
+ (allRoles.length === 0 ? 'No roles defined yet. Create roles in the Roles & Permissions tab.' : 'All available roles are already assigned.')
|
|
397
|
+
+ '</p>';
|
|
398
|
+
sections += '<div class="manage-section">'
|
|
399
|
+
+ '<div class="manage-section-title">\uD83D\uDEE1\uFE0F Roles</div>'
|
|
400
|
+
+ '<div class="roles-list">' + chipList + '</div>'
|
|
401
|
+
+ assignRow
|
|
402
|
+
+ '</div>';
|
|
403
|
+
}
|
|
404
|
+
if (FEAT_TENANTS) {
|
|
405
|
+
var tenantResults = await Promise.all([
|
|
406
|
+
api('GET', '/api/tenants').catch(function () { return { tenants: [] }; }),
|
|
407
|
+
api('GET', '/api/users/' + encodeURIComponent(userId) + '/tenants').catch(function () { return { tenantIds: [] }; })
|
|
408
|
+
]);
|
|
409
|
+
var allTenants = tenantResults[0].tenants || [];
|
|
410
|
+
var assignedIds = new Set(tenantResults[1].tenantIds || []);
|
|
411
|
+
var tenantChipList = allTenants.filter(function (t) { return assignedIds.has(t.id); }).length === 0
|
|
412
|
+
? '<span style="color:#9ca3af;font-size:.8125rem">No tenants assigned</span>'
|
|
413
|
+
: allTenants.filter(function (t) { return assignedIds.has(t.id); }).map(function (t) {
|
|
414
|
+
return '<span class="badge badge-green role-chip" title="Click to remove" onclick="removeUserTenant(' + esc(JSON.stringify(userId)) + ',' + esc(JSON.stringify(t.id)) + ')">' + esc(t.name) + ' \u2715</span>';
|
|
415
|
+
}).join(' ');
|
|
416
|
+
var unassignedTenants = allTenants.filter(function (t) { return !assignedIds.has(t.id); });
|
|
417
|
+
var tenantAssignRow = unassignedTenants.length > 0
|
|
418
|
+
? '<div class="form-row" style="margin-top:.625rem">'
|
|
419
|
+
+ '<select id="tenant-sel-' + esc(userId) + '" class="form-select">'
|
|
420
|
+
+ unassignedTenants.map(function (t) { return '<option value="' + esc(t.id) + '">' + esc(t.name) + '</option>'; }).join('')
|
|
421
|
+
+ '</select>'
|
|
422
|
+
+ '<button class="btn btn-primary btn-sm" onclick="addUserTenant(' + esc(JSON.stringify(userId)) + ')">Assign</button>'
|
|
423
|
+
+ '</div>'
|
|
424
|
+
: '<p style="font-size:.75rem;color:#6b7280;margin-top:.5rem">All available tenants are already assigned.</p>';
|
|
425
|
+
sections += '<div class="manage-section">'
|
|
426
|
+
+ '<div class="manage-section-title">\uD83C\uDFE2 Tenants</div>'
|
|
427
|
+
+ '<div class="roles-list">' + tenantChipList + '</div>'
|
|
428
|
+
+ tenantAssignRow
|
|
429
|
+
+ '</div>';
|
|
430
|
+
}
|
|
431
|
+
if (FEAT_METADATA) {
|
|
432
|
+
var meta = await api('GET', '/api/users/' + encodeURIComponent(userId) + '/metadata').catch(function () { return {}; });
|
|
433
|
+
sections += '<div class="manage-section">'
|
|
434
|
+
+ '<div class="manage-section-title">\uD83D\uDDC2\uFE0F Metadata</div>'
|
|
435
|
+
+ '<textarea id="meta-' + esc(userId) + '" class="meta-editor">' + esc(JSON.stringify(meta, null, 2)) + '</textarea>'
|
|
436
|
+
+ '<div style="margin-top:.5rem"><button class="btn btn-primary btn-sm" onclick="saveUserMeta(' + esc(JSON.stringify(userId)) + ')">Save</button></div>'
|
|
437
|
+
+ '</div>';
|
|
438
|
+
}
|
|
439
|
+
if (FEAT_LINKED_ACCOUNTS) {
|
|
440
|
+
var laRes = await api('GET', '/api/users/' + encodeURIComponent(userId) + '/linked-accounts').catch(function () { return { linkedAccounts: [] }; });
|
|
441
|
+
var accts = laRes.linkedAccounts;
|
|
442
|
+
var items = (accts || []).map(function (a) {
|
|
443
|
+
return '<li style="display:flex;align-items:center;gap:.5rem;padding:.375rem 0;border-bottom:1px solid #f3f4f6">'
|
|
444
|
+
+ badge(esc(a.provider), 'purple')
|
|
445
|
+
+ '<span style="font-size:.8125rem;color:#374151;flex:1">' + esc(a.name || a.email || a.providerAccountId) + '</span>'
|
|
446
|
+
+ (a.email ? '<span style="font-size:.75rem;color:#9ca3af">' + esc(a.email) + '</span>' : '')
|
|
447
|
+
+ (a.linkedAt ? '<span style="font-size:.75rem;color:#9ca3af">' + ts(a.linkedAt) + '</span>' : '')
|
|
448
|
+
+ '</li>';
|
|
449
|
+
}).join('');
|
|
450
|
+
sections += '<div class="manage-section">'
|
|
451
|
+
+ '<div class="manage-section-title">\uD83D\uDD17 Linked Accounts</div>'
|
|
452
|
+
+ (accts && accts.length > 0
|
|
453
|
+
? '<ul style="list-style:none;padding:0;margin:0">' + items + '</ul>'
|
|
454
|
+
: '<span style="color:#9ca3af;font-size:.8125rem">No linked accounts</span>')
|
|
455
|
+
+ '</div>';
|
|
456
|
+
}
|
|
457
|
+
el.innerHTML = sections
|
|
458
|
+
? '<div class="manage-grid">' + sections + '</div>'
|
|
459
|
+
: '<span style="color:#9ca3af;font-size:.8125rem">No management features available.</span>';
|
|
460
|
+
} catch (e) {
|
|
461
|
+
el.innerHTML = '<span style="color:#991b1b;font-size:.8125rem">' + esc(e.message) + '</span>';
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function addUserTenant(userId) {
|
|
466
|
+
var sel = document.getElementById('tenant-sel-' + userId);
|
|
467
|
+
if (!sel || !sel.value) return;
|
|
468
|
+
try {
|
|
469
|
+
await api('POST', '/api/tenants/' + encodeURIComponent(sel.value) + '/users', { userId: userId });
|
|
470
|
+
flash('Tenant assigned');
|
|
471
|
+
loadUserPanel(userId);
|
|
472
|
+
} catch (e) { flash(e.message, 'error'); }
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function removeUserTenant(userId, tenantId) {
|
|
476
|
+
try {
|
|
477
|
+
await api('DELETE', '/api/tenants/' + encodeURIComponent(tenantId) + '/users/' + encodeURIComponent(userId));
|
|
478
|
+
flash('Tenant removed');
|
|
479
|
+
loadUserPanel(userId);
|
|
480
|
+
} catch (e) { flash(e.message, 'error'); }
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function addUserRole(userId) {
|
|
484
|
+
var sel = document.getElementById('role-sel-' + userId);
|
|
485
|
+
if (!sel || !sel.value) return;
|
|
486
|
+
try {
|
|
487
|
+
await api('POST', '/api/users/' + encodeURIComponent(userId) + '/roles', { role: sel.value });
|
|
488
|
+
flash('Role assigned');
|
|
489
|
+
renderUsers();
|
|
490
|
+
} catch (e) { flash(e.message, 'error'); }
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function removeUserRole(userId, role) {
|
|
494
|
+
try {
|
|
495
|
+
await api('DELETE', '/api/users/' + encodeURIComponent(userId) + '/roles/' + encodeURIComponent(role));
|
|
496
|
+
flash('Role removed');
|
|
497
|
+
renderUsers();
|
|
498
|
+
} catch (e) { flash(e.message, 'error'); }
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async function saveUserMeta(userId) {
|
|
502
|
+
var ta = document.getElementById('meta-' + userId);
|
|
503
|
+
if (!ta) return;
|
|
504
|
+
var parsed;
|
|
505
|
+
try { parsed = JSON.parse(ta.value); } catch (e) { flash('Invalid JSON', 'error'); return; }
|
|
506
|
+
try {
|
|
507
|
+
await api('PUT', '/api/users/' + encodeURIComponent(userId) + '/metadata', parsed);
|
|
508
|
+
flash('Metadata saved');
|
|
509
|
+
} catch (e) { flash(e.message, 'error'); }
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function deleteUser(id, email) {
|
|
513
|
+
if (!confirm('Delete user ' + JSON.stringify(email) + '? This cannot be undone.')) return;
|
|
514
|
+
try {
|
|
515
|
+
await api('DELETE', '/api/users/' + encodeURIComponent(id));
|
|
516
|
+
flash('User deleted');
|
|
517
|
+
renderUsers();
|
|
518
|
+
} catch (e) { flash(e.message, 'error'); }
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function setBulk2FA(required) {
|
|
522
|
+
var action = required ? 'require 2FA for ALL users' : 'remove the 2FA requirement from ALL users';
|
|
523
|
+
if (!confirm('Are you sure you want to ' + action + '?')) return;
|
|
524
|
+
try {
|
|
525
|
+
var res = await api('POST', '/api/2fa-policy', { required: required });
|
|
526
|
+
flash(res.updated + ' user(s) updated \u2014 2FA ' + (required ? 'now required' : 'requirement removed'));
|
|
527
|
+
renderUsers();
|
|
528
|
+
} catch (e) { flash(e.message, 'error'); }
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ---- Sessions ------------------------------------------------------------
|
|
532
|
+
async function renderSessions() {
|
|
533
|
+
var main = document.getElementById('main');
|
|
534
|
+
main.innerHTML = '<div class="card"><div class="card-header"><h2>Sessions</h2><span class="meta"><span class="spinner"></span></span></div></div>';
|
|
535
|
+
try {
|
|
536
|
+
var filterParam = _state.sessions.filter ? '&filter=' + encodeURIComponent(_state.sessions.filter) : '';
|
|
537
|
+
var res = await api('GET', '/api/sessions?limit=' + PAGE_SIZE + '&offset=' + (_state.sessions.page * PAGE_SIZE) + filterParam);
|
|
538
|
+
var sessions = res.sessions;
|
|
539
|
+
var total = res.total;
|
|
540
|
+
var hasMore = (_state.sessions.page + 1) * PAGE_SIZE < total;
|
|
541
|
+
var rows = sessions.length === 0
|
|
542
|
+
? '<tr><td colspan="8"><div class="empty">No sessions</div></td></tr>'
|
|
543
|
+
: sessions.map(function (s) {
|
|
544
|
+
return '<tr>'
|
|
545
|
+
+ '<td style="font-family:monospace;font-size:.75rem">' + esc(s.sessionHandle.slice(0, 12)) + '\u2026</td>'
|
|
546
|
+
+ '<td style="font-family:monospace;font-size:.75rem">' + esc(s.userId) + '</td>'
|
|
547
|
+
+ '<td>' + esc(s.ipAddress || '\u2014') + '</td>'
|
|
548
|
+
+ '<td title="' + esc(s.userAgent || '') + '" style="max-width:160px">' + esc((s.userAgent || '\u2014').slice(0, 40)) + '</td>'
|
|
549
|
+
+ '<td>' + ts(s.createdAt) + '</td>'
|
|
550
|
+
+ '<td>' + ts(s.lastActiveAt) + '</td>'
|
|
551
|
+
+ '<td>' + ts(s.expiresAt) + '</td>'
|
|
552
|
+
+ '<td><button class="btn btn-danger" onclick="revokeSession(' + esc(JSON.stringify(s.sessionHandle)) + ')">Revoke</button></td>'
|
|
553
|
+
+ '</tr>';
|
|
554
|
+
}).join('');
|
|
555
|
+
main.innerHTML = '<div class="card">'
|
|
556
|
+
+ '<div class="card-header"><h2>Active Sessions</h2></div>'
|
|
557
|
+
+ '<div class="filter-bar"><input type="text" placeholder="Filter by User ID or IP\u2026" value="' + esc(_state.sessions.filter) + '" oninput="_state.sessions.filter=this.value;_state.sessions.page=0;renderSessions()" style="max-width:300px"><span style="font-size:.8125rem;color:#9ca3af">' + total + ' total</span></div>'
|
|
558
|
+
+ '<div class="table-wrap"><table>'
|
|
559
|
+
+ '<thead><tr><th>Handle</th><th>User ID</th><th>IP</th><th>User Agent</th><th>Created</th><th>Last Active</th><th>Expires</th><th></th></tr></thead>'
|
|
560
|
+
+ '<tbody>' + rows + '</tbody></table></div>'
|
|
561
|
+
+ pagerHtml(_state.sessions.page, hasMore, '_state.sessions.page--;renderSessions()', '_state.sessions.page++;renderSessions()')
|
|
562
|
+
+ '</div>';
|
|
563
|
+
} catch (e) {
|
|
564
|
+
main.innerHTML = '<div class="alert alert-error">' + esc(e.message) + '</div>';
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function revokeSession(handle) {
|
|
569
|
+
try {
|
|
570
|
+
await api('DELETE', '/api/sessions/' + encodeURIComponent(handle));
|
|
571
|
+
flash('Session revoked');
|
|
572
|
+
renderSessions();
|
|
573
|
+
} catch (e) { flash(e.message, 'error'); }
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ---- Roles ---------------------------------------------------------------
|
|
577
|
+
async function renderRoles() {
|
|
578
|
+
var main = document.getElementById('main');
|
|
579
|
+
main.innerHTML = '<div class="card"><div class="card-header"><h2>Roles</h2><span class="meta"><span class="spinner"></span></span></div></div>';
|
|
580
|
+
try {
|
|
581
|
+
var res = await api('GET', '/api/roles');
|
|
582
|
+
var allRoles = res.roles;
|
|
583
|
+
var roles = _state.roles.filter
|
|
584
|
+
? allRoles.filter(function (r) { return r.name.toLowerCase().includes(_state.roles.filter.toLowerCase()); })
|
|
585
|
+
: allRoles;
|
|
586
|
+
var html = '<div class="card"><div class="card-header"><h2>Roles & Permissions</h2></div>'
|
|
587
|
+
+ '<div class="filter-bar"><input type="text" placeholder="Filter by role name\u2026" value="' + esc(_state.roles.filter) + '" oninput="_state.roles.filter=this.value;renderRoles()" style="max-width:300px"><span style="font-size:.8125rem;color:#9ca3af">' + allRoles.length + ' total</span></div>';
|
|
588
|
+
if (roles.length === 0) {
|
|
589
|
+
html += '<div class="empty">No roles found</div>';
|
|
590
|
+
} else {
|
|
591
|
+
html += '<div class="table-wrap"><table><thead><tr><th>Role</th><th>Permissions</th><th></th></tr></thead><tbody>';
|
|
592
|
+
for (var i = 0; i < roles.length; i++) {
|
|
593
|
+
var r = roles[i];
|
|
594
|
+
html += '<tr>'
|
|
595
|
+
+ '<td><strong>' + esc(r.name) + '</strong></td>'
|
|
596
|
+
+ '<td>' + (r.permissions.length === 0 ? '<span style="color:#9ca3af">none</span>' : r.permissions.map(function (p) { return badge(p, 'blue'); }).join(' ')) + '</td>'
|
|
597
|
+
+ '<td><button class="btn btn-danger" onclick="deleteRole(' + esc(JSON.stringify(r.name)) + ')">Delete</button></td>'
|
|
598
|
+
+ '</tr>';
|
|
599
|
+
}
|
|
600
|
+
html += '</tbody></table></div>';
|
|
601
|
+
}
|
|
602
|
+
html += '<div style="padding:1rem 1.5rem;border-top:1px solid #f3f4f6">'
|
|
603
|
+
+ '<div class="form-row">'
|
|
604
|
+
+ '<input type="text" id="new-role-name" placeholder="Role name" style="width:180px">'
|
|
605
|
+
+ '<input type="text" id="new-role-perms" placeholder="Permissions (comma-separated)">'
|
|
606
|
+
+ '<button class="btn btn-primary btn-sm" onclick="createRole()">Add Role</button>'
|
|
607
|
+
+ '</div></div></div>';
|
|
608
|
+
main.innerHTML = html;
|
|
609
|
+
} catch (e) {
|
|
610
|
+
main.innerHTML = '<div class="alert alert-error">' + esc(e.message) + '</div>';
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async function createRole() {
|
|
615
|
+
var name = document.getElementById('new-role-name').value.trim();
|
|
616
|
+
var perms = document.getElementById('new-role-perms').value.split(',').map(function (s) { return s.trim(); }).filter(Boolean);
|
|
617
|
+
if (!name) return;
|
|
618
|
+
try {
|
|
619
|
+
await api('POST', '/api/roles', { name: name, permissions: perms });
|
|
620
|
+
flash('Role created');
|
|
621
|
+
renderRoles();
|
|
622
|
+
} catch (e) { flash(e.message, 'error'); }
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
async function deleteRole(name) {
|
|
626
|
+
if (!confirm('Delete role "' + name + '"?')) return;
|
|
627
|
+
try {
|
|
628
|
+
await api('DELETE', '/api/roles/' + encodeURIComponent(name));
|
|
629
|
+
flash('Role deleted');
|
|
630
|
+
renderRoles();
|
|
631
|
+
} catch (e) { flash(e.message, 'error'); }
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// ---- Tenants -------------------------------------------------------------
|
|
635
|
+
async function renderTenants() {
|
|
636
|
+
var main = document.getElementById('main');
|
|
637
|
+
main.innerHTML = '<div class="card"><div class="card-header"><h2>Tenants</h2><span class="meta"><span class="spinner"></span></span></div></div>';
|
|
638
|
+
try {
|
|
639
|
+
var res = await api('GET', '/api/tenants');
|
|
640
|
+
var allTenants = res.tenants;
|
|
641
|
+
var tenants = _state.tenants.filter
|
|
642
|
+
? allTenants.filter(function (t) { return t.name.toLowerCase().includes(_state.tenants.filter.toLowerCase()) || t.id.toLowerCase().includes(_state.tenants.filter.toLowerCase()); })
|
|
643
|
+
: allTenants;
|
|
644
|
+
var html = '<div class="card"><div class="card-header"><h2>Tenants</h2></div>'
|
|
645
|
+
+ '<div class="filter-bar"><input type="text" placeholder="Filter by name or ID\u2026" value="' + esc(_state.tenants.filter) + '" oninput="_state.tenants.filter=this.value;renderTenants()" style="max-width:300px"><span style="font-size:.8125rem;color:#9ca3af">' + allTenants.length + ' total</span></div>';
|
|
646
|
+
html += '<div class="table-wrap"><table><thead><tr><th>ID</th><th>Name</th><th>Status</th><th>Created</th><th></th></tr></thead><tbody>';
|
|
647
|
+
if (tenants.length === 0) {
|
|
648
|
+
html += '<tr><td colspan="5"><div class="empty">No tenants found</div></td></tr>';
|
|
649
|
+
} else {
|
|
650
|
+
for (var i = 0; i < tenants.length; i++) {
|
|
651
|
+
var t = tenants[i];
|
|
652
|
+
html += '<tr>'
|
|
653
|
+
+ '<td style="font-family:monospace;font-size:.75rem">' + esc(t.id) + '</td>'
|
|
654
|
+
+ '<td><strong>' + esc(t.name) + '</strong></td>'
|
|
655
|
+
+ '<td>' + (t.isActive !== false ? badge('active', 'green') : badge('inactive', 'red')) + '</td>'
|
|
656
|
+
+ '<td>' + ts(t.createdAt) + '</td>'
|
|
657
|
+
+ '<td style="display:flex;gap:.25rem">'
|
|
658
|
+
+ '<button class="btn btn-sm" style="background:#d1fae5;color:#065f46" onclick="toggleTenantPanel(' + esc(JSON.stringify(t.id)) + ')">Members</button>'
|
|
659
|
+
+ '<button class="btn btn-danger" onclick="deleteTenant(' + esc(JSON.stringify(t.id)) + ')">Delete</button>'
|
|
660
|
+
+ '</td>'
|
|
661
|
+
+ '</tr>'
|
|
662
|
+
+ (_state.tenants.openId === t.id ? '<tr><td colspan="5" style="padding:0"><div id="tenant-panel-' + esc(t.id) + '" style="padding:1rem 1.5rem;background:#f0fdf4;border-top:1px solid #e5e7eb"></div></td></tr>' : '');
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
html += '</tbody></table></div>';
|
|
666
|
+
html += '<div style="padding:1rem 1.5rem;border-top:1px solid #f3f4f6">'
|
|
667
|
+
+ '<div class="form-row">'
|
|
668
|
+
+ '<input type="text" id="new-tenant-name" placeholder="Tenant name" style="width:240px">'
|
|
669
|
+
+ '<button class="btn btn-primary btn-sm" onclick="createTenant()">Add Tenant</button>'
|
|
670
|
+
+ '</div></div></div>';
|
|
671
|
+
main.innerHTML = html;
|
|
672
|
+
if (_state.tenants.openId) loadTenantPanel(_state.tenants.openId);
|
|
673
|
+
} catch (e) {
|
|
674
|
+
main.innerHTML = '<div class="alert alert-error">' + esc(e.message) + '</div>';
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function toggleTenantPanel(id) {
|
|
679
|
+
_state.tenants.openId = _state.tenants.openId === id ? null : id;
|
|
680
|
+
renderTenants();
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
async function loadTenantPanel(tenantId) {
|
|
684
|
+
var el = document.getElementById('tenant-panel-' + tenantId);
|
|
685
|
+
if (!el) return;
|
|
686
|
+
el.innerHTML = '<span class="spinner"></span>';
|
|
687
|
+
try {
|
|
688
|
+
var res = await api('GET', '/api/tenants/' + encodeURIComponent(tenantId) + '/users').catch(function () { return { userIds: [] }; });
|
|
689
|
+
var userIds = res.userIds;
|
|
690
|
+
var html = '<strong style="font-size:.8125rem">Members (' + userIds.length + ')</strong>';
|
|
691
|
+
html += '<div style="display:flex;flex-wrap:wrap;gap:.25rem;margin:.5rem 0">'
|
|
692
|
+
+ (userIds.length === 0
|
|
693
|
+
? '<span style="color:#9ca3af;font-size:.8125rem">No members</span>'
|
|
694
|
+
: userIds.map(function (uid) { return '<span class="badge badge-green" style="cursor:pointer" title="Click to remove" onclick="removeTenantUser(' + esc(JSON.stringify(tenantId)) + ', ' + esc(JSON.stringify(uid)) + ')">' + esc(uid) + ' \u2715</span>'; }).join(' '))
|
|
695
|
+
+ '</div>';
|
|
696
|
+
html += '<div class="form-row" style="margin-top:.5rem">'
|
|
697
|
+
+ '<input type="text" id="tenant-uid-' + esc(tenantId) + '" placeholder="User ID" style="width:220px">'
|
|
698
|
+
+ '<button class="btn btn-primary btn-sm" onclick="addTenantUser(' + esc(JSON.stringify(tenantId)) + ')">Add Member</button>'
|
|
699
|
+
+ '</div>';
|
|
700
|
+
el.innerHTML = html;
|
|
701
|
+
} catch (e) {
|
|
702
|
+
el.innerHTML = '<span style="color:#991b1b;font-size:.8125rem">' + esc(e.message) + '</span>';
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
async function addTenantUser(tenantId) {
|
|
707
|
+
var inp = document.getElementById('tenant-uid-' + tenantId);
|
|
708
|
+
var userId = inp ? inp.value.trim() : '';
|
|
709
|
+
if (!userId) return;
|
|
710
|
+
try {
|
|
711
|
+
await api('POST', '/api/tenants/' + encodeURIComponent(tenantId) + '/users', { userId: userId });
|
|
712
|
+
flash('User added to tenant');
|
|
713
|
+
loadTenantPanel(tenantId);
|
|
714
|
+
} catch (e) { flash(e.message, 'error'); }
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
async function removeTenantUser(tenantId, userId) {
|
|
718
|
+
try {
|
|
719
|
+
await api('DELETE', '/api/tenants/' + encodeURIComponent(tenantId) + '/users/' + encodeURIComponent(userId));
|
|
720
|
+
flash('User removed from tenant');
|
|
721
|
+
loadTenantPanel(tenantId);
|
|
722
|
+
} catch (e) { flash(e.message, 'error'); }
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
async function createTenant() {
|
|
726
|
+
var name = document.getElementById('new-tenant-name').value.trim();
|
|
727
|
+
if (!name) return;
|
|
728
|
+
try {
|
|
729
|
+
await api('POST', '/api/tenants', { name: name, isActive: true });
|
|
730
|
+
flash('Tenant created');
|
|
731
|
+
renderTenants();
|
|
732
|
+
} catch (e) { flash(e.message, 'error'); }
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
async function deleteTenant(id) {
|
|
736
|
+
if (!confirm('Delete tenant ' + id + '?')) return;
|
|
737
|
+
try {
|
|
738
|
+
await api('DELETE', '/api/tenants/' + encodeURIComponent(id));
|
|
739
|
+
flash('Tenant deleted');
|
|
740
|
+
renderTenants();
|
|
741
|
+
} catch (e) { flash(e.message, 'error'); }
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// ---- Control -------------------------------------------------------------
|
|
745
|
+
async function renderControl() {
|
|
746
|
+
if (!FEAT_CONTROL) {
|
|
747
|
+
document.getElementById('main').innerHTML = '<div class="alert alert-error">Control store not configured.</div>';
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
var main = document.getElementById('main');
|
|
751
|
+
main.innerHTML = '<div class="card"><div class="card-header"><h2>\u2699\uFE0F Control</h2><span class="meta"><span class="spinner"></span></span></div></div>';
|
|
752
|
+
try {
|
|
753
|
+
var results = await Promise.all([
|
|
754
|
+
api('GET', '/api/settings'),
|
|
755
|
+
api('GET', '/api/actions').catch(function () { return { actions: [] }; })
|
|
756
|
+
]);
|
|
757
|
+
var settings = results[0];
|
|
758
|
+
var registeredActions = results[1].actions || [];
|
|
759
|
+
|
|
760
|
+
function toggleHtml(id, label, desc, checked) {
|
|
761
|
+
return '<div class="toggle-row">'
|
|
762
|
+
+ '<div class="toggle-label">' + label + '<small>' + desc + '</small></div>'
|
|
763
|
+
+ '<label class="toggle"><input type="checkbox" id="ctrl-' + id + '" ' + (checked ? 'checked' : '') + ' onchange="updateSetting(' + esc(JSON.stringify(id)) + ', this.checked)"><span class="toggle-slider"></span></label>'
|
|
764
|
+
+ '</div>';
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
var evMode = settings.emailVerificationMode || (settings.requireEmailVerification ? 'strict' : 'none');
|
|
768
|
+
var graceDays = settings.lazyEmailVerificationGracePeriodDays != null ? settings.lazyEmailVerificationGracePeriodDays : 7;
|
|
769
|
+
var evModeHtml = '<div class="toggle-row" style="flex-direction:column;align-items:flex-start;gap:.5rem">'
|
|
770
|
+
+ '<div class="toggle-label">Email Verification Policy<small>Controls when users must verify their email address before logging in.</small></div>'
|
|
771
|
+
+ '<div style="display:flex;gap:.75rem;flex-wrap:wrap;margin-top:.25rem">'
|
|
772
|
+
+ ['none', 'lazy', 'strict'].map(function (m) {
|
|
773
|
+
return '<label style="display:flex;align-items:center;gap:.35rem;font-size:.875rem;cursor:pointer">'
|
|
774
|
+
+ '<input type="radio" name="evMode" value="' + m + '"' + (evMode === m ? ' checked' : '') + ' onchange="updateEmailVerificationMode(this.value)">'
|
|
775
|
+
+ (m === 'none' ? 'None \u2014 not required' : m === 'lazy' ? 'Lazy \u2014 required after grace period' : 'Strict \u2014 required immediately')
|
|
776
|
+
+ '</label>';
|
|
777
|
+
}).join('')
|
|
778
|
+
+ '</div>'
|
|
779
|
+
+ '<div id="ev-grace-row" style="display:' + (evMode === 'lazy' ? 'flex' : 'none') + ';align-items:center;gap:.5rem;margin-top:.25rem">'
|
|
780
|
+
+ '<label style="font-size:.8125rem">Grace period (days):</label>'
|
|
781
|
+
+ '<input type="number" id="ev-grace-days" min="1" max="365" value="' + graceDays + '" style="width:80px;padding:.25rem .5rem;border:1px solid #d1d5db;border-radius:6px;font-size:.875rem">'
|
|
782
|
+
+ '<button class="btn btn-primary" style="padding:.25rem .75rem;font-size:.8125rem" onclick="saveGracePeriod()">Save</button>'
|
|
783
|
+
+ '</div>'
|
|
784
|
+
+ '</div>';
|
|
785
|
+
|
|
786
|
+
var enabledActions = settings.enabledWebhookActions || [];
|
|
787
|
+
var actionsHtml = registeredActions.length === 0 ? '' :
|
|
788
|
+
'<div class="card" style="margin-top:1.5rem">'
|
|
789
|
+
+ '<div class="card-header"><h2>\uD83E\uDDE9 Webhook Actions</h2><span class="meta">Globally enable or disable injectable actions for inbound webhook scripts</span></div>'
|
|
790
|
+
+ '<div style="padding:.75rem 1.5rem;max-width:640px">'
|
|
791
|
+
+ (function () {
|
|
792
|
+
var groups = {};
|
|
793
|
+
for (var i = 0; i < registeredActions.length; i++) {
|
|
794
|
+
var a = registeredActions[i];
|
|
795
|
+
if (!groups[a.category]) groups[a.category] = [];
|
|
796
|
+
groups[a.category].push(a);
|
|
797
|
+
}
|
|
798
|
+
var html = '';
|
|
799
|
+
var cats = Object.keys(groups);
|
|
800
|
+
for (var ci = 0; ci < cats.length; ci++) {
|
|
801
|
+
var cat = cats[ci];
|
|
802
|
+
var acts = groups[cat];
|
|
803
|
+
html += '<div style="margin-bottom:1rem"><h3 style="font-size:.8125rem;text-transform:uppercase;letter-spacing:.05em;color:#9ca3af;margin-bottom:.5rem">' + esc(cat) + '</h3>';
|
|
804
|
+
for (var ai = 0; ai < acts.length; ai++) {
|
|
805
|
+
var a2 = acts[ai];
|
|
806
|
+
var checked = enabledActions.includes(a2.id);
|
|
807
|
+
var depsUnmet = (a2.dependsOn || []).some(function (dep) { return !enabledActions.includes(dep); });
|
|
808
|
+
html += '<div class="toggle-row" style="' + (depsUnmet ? 'opacity:.5;' : '') + '">'
|
|
809
|
+
+ '<div class="toggle-label">' + esc(a2.label)
|
|
810
|
+
+ (a2.dependsOn && a2.dependsOn.length ? '<small style="color:#f59e0b">Requires: ' + a2.dependsOn.map(function (d) { return esc(d); }).join(', ') + '</small>' : '')
|
|
811
|
+
+ '<small>' + esc(a2.description) + '</small>'
|
|
812
|
+
+ '<code style="font-size:.7rem;color:#6b7280">' + esc(a2.id) + '</code></div>'
|
|
813
|
+
+ '<label class="toggle"><input type="checkbox" ' + (checked ? 'checked' : '') + ' ' + (depsUnmet ? 'disabled' : '') + ' onchange="toggleWebhookAction(' + esc(JSON.stringify(a2.id)) + ',this.checked)"><span class="toggle-slider"></span></label>'
|
|
814
|
+
+ '</div>';
|
|
815
|
+
}
|
|
816
|
+
html += '</div>';
|
|
817
|
+
}
|
|
818
|
+
return html;
|
|
819
|
+
})()
|
|
820
|
+
+ '</div></div>';
|
|
821
|
+
|
|
822
|
+
var ui = settings.ui || {};
|
|
823
|
+
var uiHtml = '<div class="card" style="margin-top:1.5rem">'
|
|
824
|
+
+ '<div class="card-header"><h2>\uD83C\uDFA8 UI Customization</h2><span class="meta">Configure colors, logo, and site name for the Auth UI</span></div>'
|
|
825
|
+
+ '<div style="padding:1rem 1.5rem;display:flex;gap:2rem;flex-wrap:wrap">'
|
|
826
|
+
+ '<div style="flex:1;min-width:280px;max-width:420px">'
|
|
827
|
+
+ '<div class="form-row" style="margin-bottom:1rem"><div style="flex:1"><label style="display:block;font-size:.8125rem;color:#374151;margin-bottom:.25rem">Site Name</label>'
|
|
828
|
+
+ '<input type="text" id="ui-site-name" value="' + esc(ui.siteName || '') + '" placeholder="Awesome Node Auth" oninput="uiPreview()"></div></div>'
|
|
829
|
+
+ '<div class="form-row" style="margin-bottom:1rem;gap:1.5rem">'
|
|
830
|
+
+ '<div><label style="display:block;font-size:.8125rem;color:#374151;margin-bottom:.25rem">Primary Color <small style="color:#9ca3af">(buttons, links, headings)</small></label>'
|
|
831
|
+
+ '<input type="color" id="ui-primary-color" value="' + esc(ui.primaryColor || '#4a90d9') + '" style="cursor:pointer;width:60px;height:36px;padding:2px;border:1px solid #d1d5db;border-radius:4px" oninput="uiPreview()"></div>'
|
|
832
|
+
+ '<div><label style="display:block;font-size:.8125rem;color:#374151;margin-bottom:.25rem">Secondary Color <small style="color:#9ca3af">(social buttons, borders)</small></label>'
|
|
833
|
+
+ '<input type="color" id="ui-secondary-color" value="' + esc(ui.secondaryColor || '#6c757d') + '" style="cursor:pointer;width:60px;height:36px;padding:2px;border:1px solid #d1d5db;border-radius:4px" oninput="uiPreview()"></div>'
|
|
834
|
+
+ '</div>'
|
|
835
|
+
+ '<div class="form-row" style="margin-bottom:1rem"><div style="flex:1"><label style="display:block;font-size:.8125rem;color:#374151;margin-bottom:.25rem">Logo URL <small style="color:#9ca3af">(or upload below)</small></label>'
|
|
836
|
+
+ '<input type="text" id="ui-logo-url" value="' + esc(ui.logoUrl || '') + '" placeholder="https://example.com/logo.png" oninput="uiPreview()"></div></div>'
|
|
837
|
+
+ (FEAT_UPLOAD ? '<div class="form-row" style="margin-bottom:1rem"><div style="flex:1"><label style="display:block;font-size:.8125rem;color:#374151;margin-bottom:.25rem">Upload Logo <small style="color:#9ca3af">(.png, .jpg, .svg \u2014 saved to uploadDir)</small></label><div style="display:flex;gap:.5rem"><input type="file" id="ui-logo-file" accept="image/*" style="flex:1;font-size:.8125rem" onchange="uploadAsset(this,\'logo\')"><button class="btn btn-sm" onclick="listUploads()">Manage files</button></div></div></div>' : '')
|
|
838
|
+
+ '<div style="border-top:1px solid #f3f4f6;margin:1rem 0;padding-top:1rem">'
|
|
839
|
+
+ '<div style="font-size:.75rem;font-weight:600;color:#374151;margin-bottom:.75rem;text-transform:uppercase;letter-spacing:.05em">Page Background</div>'
|
|
840
|
+
+ '<div class="form-row" style="margin-bottom:1rem;gap:1.5rem"><div style="flex:1"><label style="display:block;font-size:.8125rem;color:#374151;margin-bottom:.25rem">Background Color <small style="color:#9ca3af">(entire page)</small></label>'
|
|
841
|
+
+ '<div style="display:flex;align-items:center;gap:.5rem"><input type="color" id="ui-bg-color" value="' + esc(ui.bgColor || '#f8fafc') + '" style="cursor:pointer;width:60px;height:36px;padding:2px;border:1px solid #d1d5db;border-radius:4px" oninput="syncBgColor(this)">'
|
|
842
|
+
+ '<input type="text" id="ui-bg-color-text" value="' + esc(ui.bgColor || '') + '" placeholder="#f8fafc (default)" style="flex:1" oninput="syncBgColorText(this)"></div></div></div>'
|
|
843
|
+
+ '<div class="form-row" style="margin-bottom:1rem"><div style="flex:1"><label style="display:block;font-size:.8125rem;color:#374151;margin-bottom:.25rem">Background Image URL <small style="color:#9ca3af">(cover, centered, fixed)</small></label>'
|
|
844
|
+
+ '<input type="text" id="ui-bg-image" value="' + esc(ui.bgImage || '') + '" placeholder="https://example.com/bg.jpg" oninput="uiPreview()"></div></div>'
|
|
845
|
+
+ (FEAT_UPLOAD ? '<div class="form-row" style="margin-bottom:1rem"><div style="flex:1"><label style="display:block;font-size:.8125rem;color:#374151;margin-bottom:.25rem">Upload Background Image <small style="color:#9ca3af">(.jpg, .png, .webp)</small></label><input type="file" id="ui-bg-file" accept="image/*" style="font-size:.8125rem;width:100%" onchange="uploadAsset(this,\'bg-image\')"></div></div>' : '')
|
|
846
|
+
+ '</div>'
|
|
847
|
+
+ '<div style="border-top:1px solid #f3f4f6;margin:1rem 0;padding-top:1rem">'
|
|
848
|
+
+ '<div style="font-size:.75rem;font-weight:600;color:#374151;margin-bottom:.75rem;text-transform:uppercase;letter-spacing:.05em">Card / Form Background</div>'
|
|
849
|
+
+ '<div class="form-row" style="margin-bottom:1rem;gap:1.5rem"><div style="flex:1"><label style="display:block;font-size:.8125rem;color:#374151;margin-bottom:.25rem">Card Background Color <small style="color:#9ca3af">(form card, default #ffffff)</small></label>'
|
|
850
|
+
+ '<div style="display:flex;align-items:center;gap:.5rem"><input type="color" id="ui-card-bg" value="' + esc(ui.cardBg || '#ffffff') + '" style="cursor:pointer;width:60px;height:36px;padding:2px;border:1px solid #d1d5db;border-radius:4px" oninput="syncCardBg(this)">'
|
|
851
|
+
+ '<input type="text" id="ui-card-bg-text" value="' + esc(ui.cardBg || '') + '" placeholder="#ffffff (default)" style="flex:1" oninput="syncCardBgText(this)"></div></div></div>'
|
|
852
|
+
+ '</div>'
|
|
853
|
+
+ '<button class="btn btn-primary" onclick="saveUiSettings()">Save UI Settings</button>'
|
|
854
|
+
+ '</div>'
|
|
855
|
+
+ '<div id="ui-preview-wrap" style="flex:0 0 220px;min-width:180px">'
|
|
856
|
+
+ '<div style="font-size:.75rem;font-weight:600;color:#374151;margin-bottom:.5rem;text-transform:uppercase;letter-spacing:.05em">Live Preview</div>'
|
|
857
|
+
+ '<div id="ui-preview" style="border-radius:8px;overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,.12);width:220px;height:320px;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#f8fafc;padding:16px;font-family:system-ui,-apple-system,sans-serif;transition:background .2s">'
|
|
858
|
+
+ '<div id="prv-card" style="width:100%;background:#fff;border-radius:8px;padding:14px;display:flex;flex-direction:column;align-items:center;box-shadow:0 1px 6px rgba(0,0,0,.08);transition:background .2s">'
|
|
859
|
+
+ '<div id="prv-logo" style="display:none;margin-bottom:8px"><img id="prv-logo-img" src="" style="height:28px;width:auto;display:block"></div>'
|
|
860
|
+
+ '<div id="prv-title" style="font-size:13px;font-weight:700;color:#4a90d9;text-align:center;margin-bottom:2px">Awesome Node Auth</div>'
|
|
861
|
+
+ '<div style="font-size:10px;color:#64748b;text-align:center;margin-bottom:10px">Login</div>'
|
|
862
|
+
+ '<div style="width:100%;background:#f9fafb;border:1px solid #e2e8f0;border-radius:4px;padding:6px 8px;font-size:10px;color:#374151;margin-bottom:5px">Email</div>'
|
|
863
|
+
+ '<div style="width:100%;background:#f9fafb;border:1px solid #e2e8f0;border-radius:4px;padding:6px 8px;font-size:10px;color:#374151;margin-bottom:8px">Password</div>'
|
|
864
|
+
+ '<div id="prv-btn" style="width:100%;background:#4a90d9;color:#fff;border-radius:4px;padding:6px;font-size:10px;font-weight:700;text-align:center;cursor:default;transition:background .2s">Login</div>'
|
|
865
|
+
+ '<div style="display:flex;align-items:center;gap:6px;margin:8px 0 6px;width:100%"><div style="flex:1;height:1px;background:#e2e8f0"></div><div style="font-size:9px;color:#9ca3af">or</div><div style="flex:1;height:1px;background:#e2e8f0"></div></div>'
|
|
866
|
+
+ '<div id="prv-social" style="width:100%;border:1px solid #6c757d;border-radius:4px;padding:5px;font-size:9px;color:#6c757d;text-align:center;transition:border-color .2s,color .2s">Continue with Google</div>'
|
|
867
|
+
+ '<div id="prv-link" style="margin-top:7px;font-size:9px;color:#4a90d9;text-align:center">Forgot password?</div>'
|
|
868
|
+
+ '</div>'
|
|
869
|
+
+ '</div>'
|
|
870
|
+
+ '<div style="font-size:.6875rem;color:#9ca3af;margin-top:.375rem">Changes update live; save to persist.</div>'
|
|
871
|
+
+ '<div id="ui-files-panel" style="display:none;margin-top:.75rem;background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px;padding:.75rem;max-height:200px;overflow-y:auto">'
|
|
872
|
+
+ '<div style="font-size:.75rem;font-weight:600;color:#374151;margin-bottom:.5rem">Uploaded Files</div>'
|
|
873
|
+
+ '<div id="ui-files-list" style="font-size:.8125rem"></div>'
|
|
874
|
+
+ '</div>'
|
|
875
|
+
+ '</div>'
|
|
876
|
+
+ '</div></div>';
|
|
877
|
+
|
|
878
|
+
main.innerHTML = '<div class="card">'
|
|
879
|
+
+ '<div class="card-header"><h2>\u2699\uFE0F Control Panel</h2><span class="meta">Global authentication settings</span></div>'
|
|
880
|
+
+ '<div style="padding:1rem 1.5rem;max-width:640px">'
|
|
881
|
+
+ evModeHtml
|
|
882
|
+
+ toggleHtml('require2FA', 'Mandatory Two-Factor Authentication', 'All users must have 2FA enabled. Users without 2FA configured will be blocked at login.', !!settings.require2FA)
|
|
883
|
+
+ '</div></div>'
|
|
884
|
+
+ uiHtml
|
|
885
|
+
+ actionsHtml;
|
|
886
|
+
setTimeout(uiPreview, 0);
|
|
887
|
+
} catch (e) {
|
|
888
|
+
main.innerHTML = '<div class="alert alert-error">' + esc(e.message) + '</div>';
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
async function toggleWebhookAction(id, enabled) {
|
|
893
|
+
try {
|
|
894
|
+
var settings = await api('GET', '/api/settings');
|
|
895
|
+
var current = settings.enabledWebhookActions || [];
|
|
896
|
+
var next = enabled ? [...new Set([...current, id])] : current.filter(function (x) { return x !== id; });
|
|
897
|
+
await api('PUT', '/api/settings', { enabledWebhookActions: next });
|
|
898
|
+
flash('Webhook action ' + (enabled ? 'enabled' : 'disabled'));
|
|
899
|
+
renderControl();
|
|
900
|
+
} catch (e) {
|
|
901
|
+
flash(e.message, 'error');
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
async function updateEmailVerificationMode(mode) {
|
|
906
|
+
try {
|
|
907
|
+
await api('PUT', '/api/settings', { emailVerificationMode: mode });
|
|
908
|
+
flash('Email verification policy updated');
|
|
909
|
+
var graceRow = document.getElementById('ev-grace-row');
|
|
910
|
+
if (graceRow) graceRow.style.display = mode === 'lazy' ? 'flex' : 'none';
|
|
911
|
+
} catch (e) {
|
|
912
|
+
flash(e.message, 'error');
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
async function saveGracePeriod() {
|
|
917
|
+
var inp = document.getElementById('ev-grace-days');
|
|
918
|
+
var days = parseInt(inp ? inp.value : '7', 10);
|
|
919
|
+
if (isNaN(days) || days < 1) { flash('Enter a valid number of days', 'error'); return; }
|
|
920
|
+
try {
|
|
921
|
+
await api('PUT', '/api/settings', { lazyEmailVerificationGracePeriodDays: days });
|
|
922
|
+
flash('Grace period saved');
|
|
923
|
+
} catch (e) {
|
|
924
|
+
flash(e.message, 'error');
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
async function updateSetting(key, value) {
|
|
929
|
+
try {
|
|
930
|
+
await api('PUT', '/api/settings', { [key]: value });
|
|
931
|
+
flash('Setting updated');
|
|
932
|
+
if (key === 'require2FA' && FEAT_2FA_POLICY) {
|
|
933
|
+
await api('POST', '/api/2fa-policy', { required: value }).catch(function () { });
|
|
934
|
+
}
|
|
935
|
+
} catch (e) {
|
|
936
|
+
flash(e.message, 'error');
|
|
937
|
+
renderControl();
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
async function saveUiSettings() {
|
|
942
|
+
var primaryColor = document.getElementById('ui-primary-color').value;
|
|
943
|
+
var secondaryColor = document.getElementById('ui-secondary-color').value;
|
|
944
|
+
var logoUrl = document.getElementById('ui-logo-url').value.trim();
|
|
945
|
+
var siteName = document.getElementById('ui-site-name').value.trim();
|
|
946
|
+
var bgColorText = document.getElementById('ui-bg-color-text').value.trim();
|
|
947
|
+
var bgColor = bgColorText || document.getElementById('ui-bg-color').value;
|
|
948
|
+
var bgImage = document.getElementById('ui-bg-image').value.trim();
|
|
949
|
+
var cardBgText = document.getElementById('ui-card-bg-text').value.trim();
|
|
950
|
+
var cardBg = cardBgText || document.getElementById('ui-card-bg').value;
|
|
951
|
+
var ui = { primaryColor: primaryColor, secondaryColor: secondaryColor, logoUrl: logoUrl, siteName: siteName, bgColor: bgColor, bgImage: bgImage, cardBg: cardBg };
|
|
952
|
+
try {
|
|
953
|
+
await api('PATCH', '/api/settings/ui', ui);
|
|
954
|
+
flash('UI settings saved');
|
|
955
|
+
} catch (e) { flash(e.message, 'error'); }
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
async function uploadAsset(input, type) {
|
|
959
|
+
if (!input.files || !input.files[0]) return;
|
|
960
|
+
var file = input.files[0];
|
|
961
|
+
var formData = new FormData();
|
|
962
|
+
formData.append('file', file);
|
|
963
|
+
try {
|
|
964
|
+
var res = await fetch(BASE + '/api/upload/' + type, {
|
|
965
|
+
method: 'POST',
|
|
966
|
+
headers: { 'Authorization': 'Bearer ' + _token },
|
|
967
|
+
body: formData
|
|
968
|
+
});
|
|
969
|
+
if (!res.ok) {
|
|
970
|
+
var e = await res.json().catch(function () { return { error: res.statusText }; });
|
|
971
|
+
flash(e.error || res.statusText, 'error');
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
var data = await res.json();
|
|
975
|
+
var fileUrl = data.url || data.filename;
|
|
976
|
+
if (type === 'logo') {
|
|
977
|
+
var urlInput = document.getElementById('ui-logo-url');
|
|
978
|
+
if (urlInput) { urlInput.value = fileUrl; uiPreview(); }
|
|
979
|
+
} else if (type === 'bg-image') {
|
|
980
|
+
var urlInput2 = document.getElementById('ui-bg-image');
|
|
981
|
+
if (urlInput2) { urlInput2.value = fileUrl; uiPreview(); }
|
|
982
|
+
}
|
|
983
|
+
flash('File uploaded: ' + data.filename);
|
|
984
|
+
input.value = '';
|
|
985
|
+
} catch (e) { flash(e.message, 'error'); }
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
async function listUploads() {
|
|
989
|
+
var panel = document.getElementById('ui-files-panel');
|
|
990
|
+
var list = document.getElementById('ui-files-list');
|
|
991
|
+
if (!panel || !list) return;
|
|
992
|
+
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
|
|
993
|
+
if (panel.style.display === 'none') return;
|
|
994
|
+
list.textContent = 'Loading...';
|
|
995
|
+
try {
|
|
996
|
+
var data = await api('GET', '/api/upload/files');
|
|
997
|
+
if (!data.files || data.files.length === 0) { list.textContent = 'No uploaded files.'; return; }
|
|
998
|
+
list.innerHTML = data.files.map(function (f) {
|
|
999
|
+
return '<div style="display:flex;align-items:center;gap:.5rem;margin-bottom:.375rem">'
|
|
1000
|
+
+ '<span style="flex:1;word-break:break-all;font-size:.75rem">' + esc(f.name) + '</span>'
|
|
1001
|
+
+ '<button class="btn btn-sm btn-danger" style="padding:2px 8px;font-size:.75rem" onclick="deleteUpload(' + esc(JSON.stringify(f.name)) + ')">Delete</button>'
|
|
1002
|
+
+ '</div>';
|
|
1003
|
+
}).join('');
|
|
1004
|
+
} catch (e) { list.textContent = 'Error: ' + e.message; }
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
async function deleteUpload(filename) {
|
|
1008
|
+
try {
|
|
1009
|
+
await api('DELETE', '/api/upload/' + encodeURIComponent(filename));
|
|
1010
|
+
flash('File deleted');
|
|
1011
|
+
listUploads();
|
|
1012
|
+
['ui-logo-url', 'ui-bg-image'].forEach(function (id) {
|
|
1013
|
+
var inp = document.getElementById(id);
|
|
1014
|
+
if (inp && inp.value.includes(filename)) { inp.value = ''; uiPreview(); }
|
|
1015
|
+
});
|
|
1016
|
+
} catch (e) { flash(e.message, 'error'); }
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function syncBgColor(picker) {
|
|
1020
|
+
var txt = document.getElementById('ui-bg-color-text');
|
|
1021
|
+
if (txt) txt.value = picker.value;
|
|
1022
|
+
uiPreview();
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function syncBgColorText(input) {
|
|
1026
|
+
var picker = document.getElementById('ui-bg-color');
|
|
1027
|
+
if (picker && input.value) picker.value = input.value;
|
|
1028
|
+
uiPreview();
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function syncCardBg(picker) {
|
|
1032
|
+
var txt = document.getElementById('ui-card-bg-text');
|
|
1033
|
+
if (txt) txt.value = picker.value;
|
|
1034
|
+
uiPreview();
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function syncCardBgText(input) {
|
|
1038
|
+
var picker = document.getElementById('ui-card-bg');
|
|
1039
|
+
if (picker && input.value) picker.value = input.value;
|
|
1040
|
+
uiPreview();
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function uiPreview() {
|
|
1044
|
+
var primary = document.getElementById('ui-primary-color').value || '#4a90d9';
|
|
1045
|
+
var secondary = document.getElementById('ui-secondary-color').value || '#6c757d';
|
|
1046
|
+
var siteName = document.getElementById('ui-site-name').value.trim() || 'Awesome Node Auth';
|
|
1047
|
+
var logoUrl = document.getElementById('ui-logo-url').value.trim();
|
|
1048
|
+
var bgColorText = document.getElementById('ui-bg-color-text').value.trim();
|
|
1049
|
+
var bgColor = bgColorText || document.getElementById('ui-bg-color').value || '#f8fafc';
|
|
1050
|
+
var bgImage = document.getElementById('ui-bg-image').value.trim();
|
|
1051
|
+
var cardBgText = document.getElementById('ui-card-bg-text').value.trim();
|
|
1052
|
+
var cardBg = cardBgText || document.getElementById('ui-card-bg').value || '#ffffff';
|
|
1053
|
+
var prv = document.getElementById('ui-preview');
|
|
1054
|
+
if (!prv) return;
|
|
1055
|
+
prv.style.background = bgColor;
|
|
1056
|
+
if (bgImage) {
|
|
1057
|
+
var safeUrl = bgImage.replace(/['"\\]/g, function (m) { return encodeURIComponent(m); });
|
|
1058
|
+
prv.style.backgroundImage = 'url("' + safeUrl + '")';
|
|
1059
|
+
prv.style.backgroundSize = 'cover';
|
|
1060
|
+
prv.style.backgroundPosition = 'center';
|
|
1061
|
+
} else {
|
|
1062
|
+
prv.style.backgroundImage = 'none';
|
|
1063
|
+
}
|
|
1064
|
+
var card = document.getElementById('prv-card');
|
|
1065
|
+
if (card) card.style.background = cardBg;
|
|
1066
|
+
var title = document.getElementById('prv-title');
|
|
1067
|
+
if (title) { title.textContent = siteName; title.style.color = primary; }
|
|
1068
|
+
var btn = document.getElementById('prv-btn');
|
|
1069
|
+
if (btn) btn.style.background = primary;
|
|
1070
|
+
var link = document.getElementById('prv-link');
|
|
1071
|
+
if (link) link.style.color = primary;
|
|
1072
|
+
var social = document.getElementById('prv-social');
|
|
1073
|
+
if (social) { social.style.borderColor = secondary; social.style.color = secondary; }
|
|
1074
|
+
var logoWrap = document.getElementById('prv-logo');
|
|
1075
|
+
var logoImg = document.getElementById('prv-logo-img');
|
|
1076
|
+
if (logoWrap && logoImg) {
|
|
1077
|
+
if (logoUrl) { logoImg.src = logoUrl; logoWrap.style.display = 'block'; }
|
|
1078
|
+
else { logoWrap.style.display = 'none'; }
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// ---- API Keys ------------------------------------------------------------
|
|
1083
|
+
async function renderApiKeys() {
|
|
1084
|
+
var main = document.getElementById('main');
|
|
1085
|
+
main.innerHTML = '<div class="card"><div class="card-header"><h2>\uD83D\uDD11 API Keys</h2><span class="meta"><span class="spinner"></span></span></div></div>';
|
|
1086
|
+
try {
|
|
1087
|
+
var filterParam = _state.apiKeys.filter ? '&filter=' + encodeURIComponent(_state.apiKeys.filter) : '';
|
|
1088
|
+
var res = await api('GET', '/api/api-keys?limit=' + PAGE_SIZE + '&offset=' + (_state.apiKeys.page * PAGE_SIZE) + filterParam);
|
|
1089
|
+
var keys = res.keys;
|
|
1090
|
+
var total = res.total;
|
|
1091
|
+
var hasMore = (_state.apiKeys.page + 1) * PAGE_SIZE < total;
|
|
1092
|
+
var rawKeyBanner = '';
|
|
1093
|
+
if (_state.apiKeys.newRaw) {
|
|
1094
|
+
rawKeyBanner = '<div class="alert alert-success" style="font-family:monospace;word-break:break-all;display:flex;justify-content:space-between;align-items:flex-start;gap:1rem">'
|
|
1095
|
+
+ '<span>\u26A0\uFE0F Copy this key now \u2014 it will not be shown again:<br><strong>' + esc(_state.apiKeys.newRaw) + '</strong></span>'
|
|
1096
|
+
+ '<button class="btn btn-sm" style="flex-shrink:0;background:#f3f4f6;border:1px solid #e5e7eb" onclick="_state.apiKeys.newRaw=null;renderApiKeys()">Dismiss</button>'
|
|
1097
|
+
+ '</div>';
|
|
1098
|
+
}
|
|
1099
|
+
var rows = '';
|
|
1100
|
+
if (keys.length === 0) {
|
|
1101
|
+
rows = '<tr><td colspan="7"><div class="empty">No API keys found</div></td></tr>';
|
|
1102
|
+
} else {
|
|
1103
|
+
for (var i = 0; i < keys.length; i++) {
|
|
1104
|
+
var k = keys[i];
|
|
1105
|
+
rows += '<tr>'
|
|
1106
|
+
+ '<td style="font-family:monospace;font-size:.75rem">' + esc(k.keyPrefix) + '\u2026</td>'
|
|
1107
|
+
+ '<td><strong>' + esc(k.name) + '</strong></td>'
|
|
1108
|
+
+ '<td>' + (k.serviceId ? badge(k.serviceId, 'blue') : '<span style="color:#9ca3af">\u2014</span>') + '</td>'
|
|
1109
|
+
+ '<td>' + ((k.scopes || []).length === 0 ? '<span style="color:#9ca3af">none</span>' : (k.scopes || []).map(function (s) { return badge(s, 'indigo'); }).join(' ')) + '</td>'
|
|
1110
|
+
+ '<td>' + (k.isActive ? badge('active', 'green') : badge('revoked', 'red')) + '</td>'
|
|
1111
|
+
+ '<td>' + ts(k.expiresAt) + '</td>'
|
|
1112
|
+
+ '<td style="display:flex;gap:.25rem;flex-wrap:wrap">'
|
|
1113
|
+
+ (k.isActive ? '<button class="btn btn-sm" style="background:#fef3c7;color:#92400e" onclick="revokeApiKey(' + esc(JSON.stringify(k.id)) + ')">Revoke</button>' : '')
|
|
1114
|
+
+ '<button class="btn btn-danger" onclick="deleteApiKey(' + esc(JSON.stringify(k.id)) + ')">Delete</button>'
|
|
1115
|
+
+ '</td>'
|
|
1116
|
+
+ '</tr>';
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
var createForm = '<div style="padding:1rem 1.5rem;border-top:1px solid #f3f4f6">'
|
|
1120
|
+
+ '<strong style="font-size:.8125rem;display:block;margin-bottom:.625rem">Create new API key</strong>'
|
|
1121
|
+
+ '<div class="form-row" style="flex-wrap:wrap;gap:.5rem">'
|
|
1122
|
+
+ '<input type="text" id="ak-name" placeholder="Name (e.g. stripe-webhook)" style="width:200px">'
|
|
1123
|
+
+ '<input type="text" id="ak-service" placeholder="Service ID (optional)" style="width:160px">'
|
|
1124
|
+
+ '<input type="text" id="ak-scopes" placeholder="Scopes (comma-separated)" style="width:220px">'
|
|
1125
|
+
+ '<input type="text" id="ak-ips" placeholder="Allowed IPs / CIDRs (optional)" style="width:220px">'
|
|
1126
|
+
+ '<input type="date" id="ak-expires" style="width:150px" title="Expiry date (optional)">'
|
|
1127
|
+
+ '<button class="btn btn-primary btn-sm" onclick="createApiKey()">Create Key</button>'
|
|
1128
|
+
+ '</div></div>';
|
|
1129
|
+
main.innerHTML = rawKeyBanner
|
|
1130
|
+
+ '<div class="card"><div class="card-header"><h2>\uD83D\uDD11 API Keys</h2><span class="meta">' + total + ' total</span></div>'
|
|
1131
|
+
+ '<div class="filter-bar"><input type="text" placeholder="Filter by name or service\u2026" value="' + esc(_state.apiKeys.filter) + '" oninput="_state.apiKeys.filter=this.value;_state.apiKeys.page=0;renderApiKeys()" style="max-width:300px"></div>'
|
|
1132
|
+
+ '<div class="table-wrap"><table><thead><tr><th>Prefix</th><th>Name</th><th>Service ID</th><th>Scopes</th><th>Status</th><th>Expires</th><th></th></tr></thead><tbody>' + rows + '</tbody></table></div>'
|
|
1133
|
+
+ pagerHtml(_state.apiKeys.page, hasMore, '_state.apiKeys.page--;renderApiKeys()', '_state.apiKeys.page++;renderApiKeys()')
|
|
1134
|
+
+ createForm + '</div>';
|
|
1135
|
+
} catch (e) {
|
|
1136
|
+
main.innerHTML = '<div class="alert alert-error">' + esc(e.message) + '</div>';
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
async function revokeApiKey(id) {
|
|
1141
|
+
if (!confirm('Revoke this API key? It will no longer be usable.')) return;
|
|
1142
|
+
try {
|
|
1143
|
+
await api('DELETE', '/api/api-keys/' + encodeURIComponent(id) + '/revoke');
|
|
1144
|
+
flash('API key revoked');
|
|
1145
|
+
renderApiKeys();
|
|
1146
|
+
} catch (e) { flash(e.message, 'error'); }
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
async function deleteApiKey(id) {
|
|
1150
|
+
if (!confirm('Permanently delete this API key record?')) return;
|
|
1151
|
+
try {
|
|
1152
|
+
await api('DELETE', '/api/api-keys/' + encodeURIComponent(id));
|
|
1153
|
+
flash('API key deleted');
|
|
1154
|
+
renderApiKeys();
|
|
1155
|
+
} catch (e) { flash(e.message, 'error'); }
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
async function createApiKey() {
|
|
1159
|
+
var name = document.getElementById('ak-name').value.trim();
|
|
1160
|
+
if (!name) { flash('Name is required', 'error'); return; }
|
|
1161
|
+
var serviceId = document.getElementById('ak-service').value.trim() || undefined;
|
|
1162
|
+
var scopes = document.getElementById('ak-scopes').value.split(',').map(function (s) { return s.trim(); }).filter(Boolean);
|
|
1163
|
+
var ips = document.getElementById('ak-ips').value.split(',').map(function (s) { return s.trim(); }).filter(Boolean);
|
|
1164
|
+
var expInput = document.getElementById('ak-expires').value;
|
|
1165
|
+
var expiresAt = expInput ? new Date(expInput).toISOString() : undefined;
|
|
1166
|
+
try {
|
|
1167
|
+
var res = await api('POST', '/api/api-keys', { name: name, serviceId: serviceId, scopes: scopes.length ? scopes : undefined, allowedIps: ips.length ? ips : undefined, expiresAt: expiresAt });
|
|
1168
|
+
_state.apiKeys.newRaw = res.rawKey;
|
|
1169
|
+
flash('API key created \u2014 copy it now!');
|
|
1170
|
+
renderApiKeys();
|
|
1171
|
+
} catch (e) { flash(e.message, 'error'); }
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// ---- Webhooks ------------------------------------------------------------
|
|
1175
|
+
async function renderWebhooks() {
|
|
1176
|
+
var main = document.getElementById('main');
|
|
1177
|
+
main.innerHTML = '<div class="card"><div class="card-header"><h2>\uD83D\uDD17 Webhooks</h2><span class="meta"><span class="spinner"></span></span></div></div>';
|
|
1178
|
+
|
|
1179
|
+
function fmtEndpoint(w) {
|
|
1180
|
+
if (w.provider) return badge('inbound', 'purple') + ' <code style="font-size:.75rem">' + esc(w.provider) + '</code>';
|
|
1181
|
+
var url = w.url || '';
|
|
1182
|
+
return esc(url.length > 40 ? url.slice(0, 40) + '\u2026' : url);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
try {
|
|
1186
|
+
var results = await Promise.all([
|
|
1187
|
+
api('GET', '/api/webhooks?limit=' + PAGE_SIZE + '&offset=' + (_state.webhooks.page * PAGE_SIZE)),
|
|
1188
|
+
api('GET', '/api/settings').catch(function () { return { enabledWebhookActions: [] }; }),
|
|
1189
|
+
api('GET', '/api/actions').catch(function () { return { actions: [] }; })
|
|
1190
|
+
]);
|
|
1191
|
+
var webhooks = results[0].webhooks;
|
|
1192
|
+
var total = results[0].total;
|
|
1193
|
+
var hasMore = (_state.webhooks.page + 1) * PAGE_SIZE < total;
|
|
1194
|
+
var enabledActions = results[1].enabledWebhookActions || [];
|
|
1195
|
+
var allActions = results[2].actions || [];
|
|
1196
|
+
|
|
1197
|
+
var rows = '';
|
|
1198
|
+
if (webhooks.length === 0) {
|
|
1199
|
+
rows = '<tr><td colspan="7"><div class="empty">No webhooks registered</div></td></tr>';
|
|
1200
|
+
} else {
|
|
1201
|
+
for (var i = 0; i < webhooks.length; i++) {
|
|
1202
|
+
var w = webhooks[i];
|
|
1203
|
+
var scriptIcon = w.jsScript ? badge('\u2699 script', 'purple') : '';
|
|
1204
|
+
var actionsCount = (w.allowedActions || []).length;
|
|
1205
|
+
var scriptCell = scriptIcon + (actionsCount > 0 ? badge(actionsCount + ' actions', 'orange') : '')
|
|
1206
|
+
+ (!w.jsScript && actionsCount === 0 ? '<span style="color:#9ca3af">\u2014</span>' : '');
|
|
1207
|
+
rows += '<tr>'
|
|
1208
|
+
+ '<td style="font-family:monospace;font-size:.75rem;max-width:220px" title="' + esc(w.url || '') + '">' + fmtEndpoint(w) + '</td>'
|
|
1209
|
+
+ '<td>' + (w.events || []).map(function (e) { return badge(e, 'blue'); }).join(' ') + '</td>'
|
|
1210
|
+
+ '<td>' + (w.tenantId ? badge(w.tenantId, 'indigo') : '<span style="color:#9ca3af">global</span>') + '</td>'
|
|
1211
|
+
+ '<td>' + (w.isActive !== false ? badge('active', 'green') : badge('inactive', 'gray')) + '</td>'
|
|
1212
|
+
+ '<td>' + scriptCell + '</td>'
|
|
1213
|
+
+ '<td>' + (w.secret ? badge('\u2713 signed', 'green') : '<span style="color:#9ca3af">unsigned</span>') + '</td>'
|
|
1214
|
+
+ '<td style="display:flex;gap:.25rem">'
|
|
1215
|
+
+ '<button class="btn btn-sm" onclick="openWebhookDrawer(' + esc(JSON.stringify(w.id)) + ')">Edit</button>'
|
|
1216
|
+
+ '<button class="btn btn-sm" style="background:' + (w.isActive !== false ? '#fee2e2' : '#dcfce7') + ';color:' + (w.isActive !== false ? '#991b1b' : '#166534') + '" onclick="toggleWebhook(' + esc(JSON.stringify(w.id)) + ',' + !(w.isActive !== false) + ')">' + (w.isActive !== false ? 'Disable' : 'Enable') + '</button>'
|
|
1217
|
+
+ '<button class="btn btn-danger" onclick="deleteWebhook(' + esc(JSON.stringify(w.id)) + ')">Delete</button>'
|
|
1218
|
+
+ '</td>'
|
|
1219
|
+
+ '</tr>';
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
var actionCheckboxes = enabledActions.length === 0
|
|
1224
|
+
? '<p style="font-size:.8125rem;color:#9ca3af">No actions are globally enabled. Enable them in the Control tab first.</p>'
|
|
1225
|
+
: enabledActions.map(function (id) {
|
|
1226
|
+
var meta = allActions.find(function (a) { return a.id === id; });
|
|
1227
|
+
var label = meta ? esc(meta.label) : esc(id);
|
|
1228
|
+
return '<label style="display:flex;align-items:center;gap:.5rem;font-size:.8125rem;padding:.25rem 0;cursor:pointer">'
|
|
1229
|
+
+ '<input type="checkbox" class="wh-action-cb" value="' + esc(id) + '">'
|
|
1230
|
+
+ label + ' <code style="font-size:.7rem;color:#6b7280">(' + esc(id) + ')</code>'
|
|
1231
|
+
+ '</label>';
|
|
1232
|
+
}).join('');
|
|
1233
|
+
|
|
1234
|
+
var drawer = '<div id="wh-drawer" style="display:none;position:fixed;right:0;top:0;height:100%;width:480px;background:white;box-shadow:-4px 0 24px rgba(0,0,0,.15);z-index:100;overflow-y:auto;padding:1.5rem">'
|
|
1235
|
+
+ '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem">'
|
|
1236
|
+
+ '<h2 style="font-size:1rem;font-weight:700" id="wh-drawer-title">Webhook</h2>'
|
|
1237
|
+
+ '<button onclick="closeWebhookDrawer()" style="background:none;border:none;font-size:1.25rem;cursor:pointer">\u2715</button>'
|
|
1238
|
+
+ '</div>'
|
|
1239
|
+
+ '<input type="hidden" id="wh-edit-id">'
|
|
1240
|
+
+ '<div style="display:flex;flex-direction:column;gap:.75rem">'
|
|
1241
|
+
+ '<label style="font-size:.8125rem;font-weight:500">Type</label>'
|
|
1242
|
+
+ '<div style="display:flex;gap:1rem">'
|
|
1243
|
+
+ '<label style="display:flex;align-items:center;gap:.35rem;font-size:.875rem;cursor:pointer"><input type="radio" name="wh-type" value="outgoing" checked onchange="toggleWebhookType(this.value)"> Outgoing</label>'
|
|
1244
|
+
+ '<label style="display:flex;align-items:center;gap:.35rem;font-size:.875rem;cursor:pointer"><input type="radio" name="wh-type" value="inbound" onchange="toggleWebhookType(this.value)"> Inbound (dynamic)</label>'
|
|
1245
|
+
+ '</div>'
|
|
1246
|
+
+ '<div id="wh-url-row"><label style="font-size:.8125rem;font-weight:500">Endpoint URL</label><input type="text" id="wh-url" placeholder="https://example.com/webhook" style="width:100%;margin-top:.25rem"></div>'
|
|
1247
|
+
+ '<div id="wh-provider-row" style="display:none"><label style="font-size:.8125rem;font-weight:500">Provider name</label><input type="text" id="wh-provider" placeholder="stripe" style="width:100%;margin-top:.25rem"></div>'
|
|
1248
|
+
+ '<div><label style="font-size:.8125rem;font-weight:500">Events</label><input type="text" id="wh-events" placeholder="* or identity.auth.login.success,\u2026" style="width:100%;margin-top:.25rem"></div>'
|
|
1249
|
+
+ '<div><label style="font-size:.8125rem;font-weight:500">HMAC secret <span style="font-weight:400;color:#9ca3af">(optional)</span></label><input type="text" id="wh-secret" placeholder="shared-secret" style="width:100%;margin-top:.25rem"></div>'
|
|
1250
|
+
+ '<div><label style="font-size:.8125rem;font-weight:500">Tenant ID <span style="font-weight:400;color:#9ca3af">(optional)</span></label><input type="text" id="wh-tenant" placeholder="tenant-id" style="width:100%;margin-top:.25rem"></div>'
|
|
1251
|
+
+ '<div id="wh-actions-row" style="display:none"><label style="font-size:.8125rem;font-weight:500">Allowed actions <span style="font-weight:400;color:#9ca3af">(from globally enabled)</span></label><div style="margin-top:.25rem;padding:.75rem;border:1px solid #e5e7eb;border-radius:8px;max-height:160px;overflow-y:auto">' + actionCheckboxes + '</div></div>'
|
|
1252
|
+
+ '<div id="wh-script-row" style="display:none">'
|
|
1253
|
+
+ '<label style="font-size:.8125rem;font-weight:500">JavaScript (vm sandbox)</label>'
|
|
1254
|
+
+ '<textarea id="wh-script" rows="10" style="width:100%;margin-top:.25rem;font-family:monospace;font-size:.8125rem;padding:.5rem;border:1px solid #e5e7eb;border-radius:8px;resize:vertical" placeholder="// body: inbound request payload\n// actions: enabled action functions\n// set result = { event, data } to emit an event\n\nif (body.type === \'invoice.payment_failed\') {\n result = { event: \'identity.tenant.user.removed\', data: body.data };\n}"></textarea>'
|
|
1255
|
+
+ '</div>'
|
|
1256
|
+
+ '</div>'
|
|
1257
|
+
+ '<div style="display:flex;gap:.75rem;margin-top:1.5rem">'
|
|
1258
|
+
+ '<button class="btn btn-primary" onclick="saveWebhook()">Save</button>'
|
|
1259
|
+
+ '<button class="btn" onclick="closeWebhookDrawer()">Cancel</button>'
|
|
1260
|
+
+ '</div></div>';
|
|
1261
|
+
|
|
1262
|
+
main.innerHTML = drawer
|
|
1263
|
+
+ '<div class="card"><div class="card-header"><h2>\uD83D\uDD17 Webhooks</h2><span class="meta">' + total + ' total</span></div>'
|
|
1264
|
+
+ '<div class="table-wrap"><table><thead><tr><th>Endpoint / Provider</th><th>Events</th><th>Scope</th><th>Status</th><th>Script</th><th>Signing</th><th></th></tr></thead><tbody>' + rows + '</tbody></table></div>'
|
|
1265
|
+
+ pagerHtml(_state.webhooks.page, hasMore, '_state.webhooks.page--;renderWebhooks()', '_state.webhooks.page++;renderWebhooks()')
|
|
1266
|
+
+ '<div style="padding:1rem 1.5rem;border-top:1px solid #f3f4f6"><button class="btn btn-primary btn-sm" onclick="openWebhookDrawer(null)">+ Register webhook</button></div></div>';
|
|
1267
|
+
} catch (e) {
|
|
1268
|
+
main.innerHTML = '<div class="alert alert-error">' + esc(e.message) + '</div>';
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
function toggleWebhookType(type) {
|
|
1273
|
+
var isInbound = type === 'inbound';
|
|
1274
|
+
document.getElementById('wh-url-row').style.display = isInbound ? 'none' : 'block';
|
|
1275
|
+
document.getElementById('wh-provider-row').style.display = isInbound ? 'block' : 'none';
|
|
1276
|
+
document.getElementById('wh-actions-row').style.display = isInbound ? 'block' : 'none';
|
|
1277
|
+
document.getElementById('wh-script-row').style.display = isInbound ? 'block' : 'none';
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function openWebhookDrawer(id) {
|
|
1281
|
+
document.getElementById('wh-edit-id').value = id || '';
|
|
1282
|
+
document.getElementById('wh-url').value = '';
|
|
1283
|
+
document.getElementById('wh-provider').value = '';
|
|
1284
|
+
document.getElementById('wh-events').value = '*';
|
|
1285
|
+
document.getElementById('wh-secret').value = '';
|
|
1286
|
+
document.getElementById('wh-tenant').value = '';
|
|
1287
|
+
document.getElementById('wh-script').value = '';
|
|
1288
|
+
document.querySelectorAll('.wh-action-cb').forEach(function (cb) { cb.checked = false; });
|
|
1289
|
+
document.querySelectorAll('input[name="wh-type"]').forEach(function (r) { r.checked = r.value === 'outgoing'; });
|
|
1290
|
+
toggleWebhookType('outgoing');
|
|
1291
|
+
document.getElementById('wh-drawer-title').textContent = id ? 'Edit webhook' : 'Register webhook';
|
|
1292
|
+
document.getElementById('wh-drawer').style.display = 'block';
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
function closeWebhookDrawer() {
|
|
1296
|
+
document.getElementById('wh-drawer').style.display = 'none';
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
async function saveWebhook() {
|
|
1300
|
+
var editId = document.getElementById('wh-edit-id').value;
|
|
1301
|
+
var isInbound = document.querySelector('input[name="wh-type"]:checked').value === 'inbound';
|
|
1302
|
+
var eventsRaw = document.getElementById('wh-events').value.trim();
|
|
1303
|
+
var events = eventsRaw ? eventsRaw.split(',').map(function (s) { return s.trim(); }).filter(Boolean) : ['*'];
|
|
1304
|
+
var secret = document.getElementById('wh-secret').value.trim() || undefined;
|
|
1305
|
+
var tenantId = document.getElementById('wh-tenant').value.trim() || undefined;
|
|
1306
|
+
var body = { events: events, secret: secret, tenantId: tenantId, isActive: true };
|
|
1307
|
+
if (isInbound) {
|
|
1308
|
+
body.provider = document.getElementById('wh-provider').value.trim() || undefined;
|
|
1309
|
+
body.jsScript = document.getElementById('wh-script').value.trim() || undefined;
|
|
1310
|
+
body.allowedActions = [...document.querySelectorAll('.wh-action-cb:checked')].map(function (cb) { return cb.value; });
|
|
1311
|
+
body.url = '';
|
|
1312
|
+
} else {
|
|
1313
|
+
body.url = document.getElementById('wh-url').value.trim();
|
|
1314
|
+
if (!body.url) { flash('URL is required', 'error'); return; }
|
|
1315
|
+
}
|
|
1316
|
+
try {
|
|
1317
|
+
if (editId) {
|
|
1318
|
+
await api('PATCH', '/api/webhooks/' + encodeURIComponent(editId), body);
|
|
1319
|
+
flash('Webhook updated');
|
|
1320
|
+
} else {
|
|
1321
|
+
await api('POST', '/api/webhooks', body);
|
|
1322
|
+
flash('Webhook registered');
|
|
1323
|
+
}
|
|
1324
|
+
closeWebhookDrawer();
|
|
1325
|
+
renderWebhooks();
|
|
1326
|
+
} catch (e) { flash(e.message, 'error'); }
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
async function createWebhook() { await saveWebhook(); }
|
|
1330
|
+
|
|
1331
|
+
async function toggleWebhook(id, active) {
|
|
1332
|
+
try {
|
|
1333
|
+
await api('PATCH', '/api/webhooks/' + encodeURIComponent(id), { isActive: active });
|
|
1334
|
+
flash('Webhook ' + (active ? 'enabled' : 'disabled'));
|
|
1335
|
+
renderWebhooks();
|
|
1336
|
+
} catch (e) { flash(e.message, 'error'); }
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
async function deleteWebhook(id) {
|
|
1340
|
+
if (!confirm('Delete this webhook registration?')) return;
|
|
1341
|
+
try {
|
|
1342
|
+
await api('DELETE', '/api/webhooks/' + encodeURIComponent(id));
|
|
1343
|
+
flash('Webhook deleted');
|
|
1344
|
+
renderWebhooks();
|
|
1345
|
+
} catch (e) { flash(e.message, 'error'); }
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// ---- Expose functions called from inline HTML event handlers -------------
|
|
1349
|
+
window.doLogin = doLogin;
|
|
1350
|
+
window.doLogout = doLogout;
|
|
1351
|
+
window.showTab = showTab;
|
|
1352
|
+
window.renderUsers = renderUsers;
|
|
1353
|
+
window.toggleSelectUser = toggleSelectUser;
|
|
1354
|
+
window.toggleSelectAll = toggleSelectAll;
|
|
1355
|
+
window.clearSelection = clearSelection;
|
|
1356
|
+
window.deleteSelected = deleteSelected;
|
|
1357
|
+
window.toggleUserPanel = toggleUserPanel;
|
|
1358
|
+
window.addUserTenant = addUserTenant;
|
|
1359
|
+
window.removeUserTenant = removeUserTenant;
|
|
1360
|
+
window.addUserRole = addUserRole;
|
|
1361
|
+
window.removeUserRole = removeUserRole;
|
|
1362
|
+
window.saveUserMeta = saveUserMeta;
|
|
1363
|
+
// ---- Email & UI Templates ───────────────────────────────────────────────
|
|
1364
|
+
|
|
1365
|
+
// Source-of-truth catalogue: IDs MUST match MailerService.render() call sites
|
|
1366
|
+
var MAIL_TEMPLATES = [
|
|
1367
|
+
{
|
|
1368
|
+
id: 'welcome',
|
|
1369
|
+
label: 'Welcome',
|
|
1370
|
+
description: 'Sent after successful registration.',
|
|
1371
|
+
variables: [
|
|
1372
|
+
{ name: 'loginUrl', description: 'URL to the login page' },
|
|
1373
|
+
{ name: 'tempPassword', description: 'Temporary password (only if admin-created)' },
|
|
1374
|
+
],
|
|
1375
|
+
translationKeys: ['subject', 'title', 'body', 'cta'],
|
|
1376
|
+
},
|
|
1377
|
+
{
|
|
1378
|
+
id: 'magic-link',
|
|
1379
|
+
label: 'Magic Link',
|
|
1380
|
+
description: 'Passwordless sign-in link.',
|
|
1381
|
+
variables: [
|
|
1382
|
+
{ name: 'link', description: 'One-time sign-in URL' },
|
|
1383
|
+
{ name: 'token', description: 'Raw token (rarely needed in template)' },
|
|
1384
|
+
],
|
|
1385
|
+
translationKeys: ['subject', 'title', 'body', 'cta', 'disclaimer'],
|
|
1386
|
+
},
|
|
1387
|
+
{
|
|
1388
|
+
id: 'password-reset',
|
|
1389
|
+
label: 'Password Reset',
|
|
1390
|
+
description: 'Sent when the user requests a password reset.',
|
|
1391
|
+
variables: [
|
|
1392
|
+
{ name: 'link', description: 'Password-reset URL' },
|
|
1393
|
+
{ name: 'token', description: 'Raw token' },
|
|
1394
|
+
],
|
|
1395
|
+
translationKeys: ['subject', 'title', 'body', 'cta', 'disclaimer'],
|
|
1396
|
+
},
|
|
1397
|
+
{
|
|
1398
|
+
id: 'verify-email',
|
|
1399
|
+
label: 'Verify Email',
|
|
1400
|
+
description: 'Sent to verify a new email address.',
|
|
1401
|
+
variables: [
|
|
1402
|
+
{ name: 'link', description: 'Email-verification URL' },
|
|
1403
|
+
{ name: 'token', description: 'Raw token' },
|
|
1404
|
+
],
|
|
1405
|
+
translationKeys: ['subject', 'title', 'body', 'cta', 'disclaimer'],
|
|
1406
|
+
},
|
|
1407
|
+
{
|
|
1408
|
+
id: 'email-changed',
|
|
1409
|
+
label: 'Email Changed',
|
|
1410
|
+
description: 'Sent to the old address when the email is updated.',
|
|
1411
|
+
variables: [
|
|
1412
|
+
{ name: 'newEmail', description: 'The new email address' },
|
|
1413
|
+
],
|
|
1414
|
+
translationKeys: ['subject', 'title', 'body', 'support'],
|
|
1415
|
+
},
|
|
1416
|
+
{
|
|
1417
|
+
id: 'invitation',
|
|
1418
|
+
label: 'Invitation',
|
|
1419
|
+
description: 'Sent when an admin invites a new user.',
|
|
1420
|
+
variables: [
|
|
1421
|
+
{ name: 'link', description: 'Accept-invitation URL' },
|
|
1422
|
+
],
|
|
1423
|
+
translationKeys: ['subject', 'title', 'body', 'cta'],
|
|
1424
|
+
},
|
|
1425
|
+
];
|
|
1426
|
+
|
|
1427
|
+
// UI pages catalogue derived from actual *.html files in src/ui/assets/
|
|
1428
|
+
var UI_PAGES = [
|
|
1429
|
+
{ page: 'login', label: 'Login', keys: ['site_name','login_title','email_label','email_placeholder','password_label','password_placeholder','forgot_password_link','login_button','no_account_text','signup_link','social_divider'] },
|
|
1430
|
+
{ page: 'register', label: 'Register', keys: ['site_name','register_title','email_label','email_placeholder'] },
|
|
1431
|
+
{ page: 'forgot-password', label: 'Forgot Password', keys: ['site_name','forgot_password_title','forgot_password_instruction','forgot_password_success','email_label','email_placeholder','send_reset_link_button','remember_password_text','login_link','return_to_login_link'] },
|
|
1432
|
+
{ page: 'reset-password', label: 'Reset Password', keys: ['site_name','reset_password_title','reset_password_success','new_password_label','new_password_placeholder','confirm_password_label','confirm_password_placeholder','save_password_button','proceed_to_login_link'] },
|
|
1433
|
+
{ page: 'verify-email', label: 'Verify Email', keys: ['site_name','verify_email_title','verify_email_loading','go_to_login_link'] },
|
|
1434
|
+
{ page: '2fa', label: 'Two-Factor Auth', keys: ['site_name','two_fa_title','two_fa_setup_instruction','two_fa_code_label','two_fa_code_placeholder','verify_code_button'] },
|
|
1435
|
+
{ page: 'magic-link', label: 'Magic Link landing', keys: ['site_name','magic_link_title','magic_link_instruction','email_label','email_placeholder','send_magic_link_button'] },
|
|
1436
|
+
{ page: 'link-verify', label: 'Link Verify landing',keys: ['site_name','account_linking_title','account_linking_loading','go_to_login_link'] },
|
|
1437
|
+
{ page: 'account-conflict', label: 'Account Conflict', keys: ['site_name','account_linking_title','account_linking_instruction','send_verification_email_button'] },
|
|
1438
|
+
];
|
|
1439
|
+
|
|
1440
|
+
async function renderTemplates() {
|
|
1441
|
+
var main = document.getElementById('main');
|
|
1442
|
+
main.innerHTML = '<div class="card"><div class="card-header"><h2>Email & UI Templates</h2><span class="meta"><span class="spinner"></span></span></div></div>';
|
|
1443
|
+
try {
|
|
1444
|
+
if (_state.templates.type === 'mail') {
|
|
1445
|
+
var res = await api('GET', '/api/templates/mail');
|
|
1446
|
+
_state.templates.mailTemplates = res.templates || [];
|
|
1447
|
+
} else {
|
|
1448
|
+
var resUi = await api('GET', '/api/templates/ui');
|
|
1449
|
+
_state.templates.uiTranslations = resUi.translations || [];
|
|
1450
|
+
}
|
|
1451
|
+
renderTemplatesUi();
|
|
1452
|
+
} catch (e) {
|
|
1453
|
+
main.innerHTML = '<div class="alert alert-error">' + esc(e.message) + '</div>';
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
function renderTemplatesUi() {
|
|
1458
|
+
var main = document.getElementById('main');
|
|
1459
|
+
var isMail = _state.templates.type === 'mail';
|
|
1460
|
+
|
|
1461
|
+
var html = '<div class="card">'
|
|
1462
|
+
+ '<div class="card-header">'
|
|
1463
|
+
+ '<h2>Email & UI Templates</h2>'
|
|
1464
|
+
+ '<div style="display:flex;gap:.5rem">'
|
|
1465
|
+
+ '<button class="btn btn-sm ' + (isMail ? 'btn-primary' : '') + '" onclick="setTemplateType(\'mail\')">Email Templates</button>'
|
|
1466
|
+
+ '<button class="btn btn-sm ' + (!isMail ? 'btn-primary' : '') + '" onclick="setTemplateType(\'ui\')">UI Translations</button>'
|
|
1467
|
+
+ '</div>'
|
|
1468
|
+
+ '</div>'
|
|
1469
|
+
+ '<div style="padding:1.5rem" class="template-grid">'
|
|
1470
|
+
+ '<div class="template-list">'
|
|
1471
|
+
+ (isMail ? renderMailTemplateList() : renderUiTranslationList())
|
|
1472
|
+
+ '</div>'
|
|
1473
|
+
+ '<div class="template-editor-container" id="template-editor">'
|
|
1474
|
+
+ '<div class="empty">Select an item to edit</div>'
|
|
1475
|
+
+ '</div>'
|
|
1476
|
+
+ '<div class="template-preview-pane" id="template-editor-preview"></div>'
|
|
1477
|
+
+ '</div>'
|
|
1478
|
+
+ '</div>';
|
|
1479
|
+
|
|
1480
|
+
main.innerHTML = html;
|
|
1481
|
+
if (_state.templates.selectedId) {
|
|
1482
|
+
if (isMail) {
|
|
1483
|
+
editMailTemplate(_state.templates.selectedId);
|
|
1484
|
+
} else {
|
|
1485
|
+
editUiTranslation(_state.templates.selectedId);
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
function renderMailTemplateList() {
|
|
1491
|
+
return MAIL_TEMPLATES.map(function (t) {
|
|
1492
|
+
var isCustom = _state.templates.mailTemplates.some(function (x) { return x.id === t.id; });
|
|
1493
|
+
var active = _state.templates.selectedId === t.id ? ' active' : '';
|
|
1494
|
+
return '<div class="template-item' + active + '" data-tpl-id="' + esc(t.id) + '" onclick="editMailTemplate(\'' + esc(t.id) + '\')">'
|
|
1495
|
+
+ '<strong>' + esc(t.label) + '</strong>'
|
|
1496
|
+
+ '<small>' + (isCustom ? '\u2728 customised' : 'using default') + '</small>'
|
|
1497
|
+
+ '</div>';
|
|
1498
|
+
}).join('');
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
function renderUiTranslationList() {
|
|
1502
|
+
return UI_PAGES.map(function (p) {
|
|
1503
|
+
var isCustom = _state.templates.uiTranslations.some(function (u) { return u.page === p.page; });
|
|
1504
|
+
var active = _state.templates.selectedId === p.page ? ' active' : '';
|
|
1505
|
+
return '<div class="template-item' + active + '" data-tpl-id="' + esc(p.page) + '" onclick="editUiTranslation(\'' + esc(p.page) + '\')">'
|
|
1506
|
+
+ '<strong>' + esc(p.label) + '</strong>'
|
|
1507
|
+
+ '<small>' + (isCustom ? '\u2728 customised' : 'using default') + '</small>'
|
|
1508
|
+
+ '</div>';
|
|
1509
|
+
}).join('');
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
// Insert text at the caret position of a textarea
|
|
1513
|
+
function insertAtCaret(textarea, text) {
|
|
1514
|
+
if (!textarea) return;
|
|
1515
|
+
var start = textarea.selectionStart;
|
|
1516
|
+
var end = textarea.selectionEnd;
|
|
1517
|
+
var val = textarea.value;
|
|
1518
|
+
textarea.value = val.slice(0, start) + text + val.slice(end);
|
|
1519
|
+
textarea.selectionStart = textarea.selectionEnd = start + text.length;
|
|
1520
|
+
textarea.focus();
|
|
1521
|
+
textarea.dispatchEvent(new Event('input'));
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
function editMailTemplate(id) {
|
|
1525
|
+
_state.templates.selectedId = id;
|
|
1526
|
+
_state.templates.previewLang = _state.templates.previewLang || 'en';
|
|
1527
|
+
var meta = MAIL_TEMPLATES.find(function (t) { return t.id === id; });
|
|
1528
|
+
var existing = _state.templates.mailTemplates.find(function (x) { return x.id === id; })
|
|
1529
|
+
|| { id: id, baseHtml: '', baseText: '', translations: {} };
|
|
1530
|
+
|
|
1531
|
+
var container = document.getElementById('template-editor');
|
|
1532
|
+
|
|
1533
|
+
var varHtml = meta.variables.map(function (v) {
|
|
1534
|
+
return '<span class="template-var-chip" data-insert="{{' + v.name + '}}" title="' + esc(v.description) + '">{{' + v.name + '}}</span>';
|
|
1535
|
+
}).join(' ');
|
|
1536
|
+
|
|
1537
|
+
var tKeysHtml = meta.translationKeys.map(function (k) {
|
|
1538
|
+
return '<span class="template-var-chip" data-insert="{{T.' + k + '}}" title="Translation key">{{T.' + k + '}}</span>';
|
|
1539
|
+
}).join(' ');
|
|
1540
|
+
|
|
1541
|
+
container.innerHTML = ''
|
|
1542
|
+
+ '<div class="template-editor-header">'
|
|
1543
|
+
+ '<div><h3>' + esc(meta.label) + '</h3>'
|
|
1544
|
+
+ '<small style="color:#6b7280">' + esc(meta.description) + '</small></div>'
|
|
1545
|
+
+ '<div style="display:flex;gap:.5rem">'
|
|
1546
|
+
+ '<button class="btn btn-sm" onclick="resetMailTemplate(\'' + esc(id) + '\')">Reset to default</button>'
|
|
1547
|
+
+ '<button class="btn btn-primary btn-sm" onclick="saveMailTemplate(\'' + esc(id) + '\')">Save</button>'
|
|
1548
|
+
+ '</div>'
|
|
1549
|
+
+ '</div>'
|
|
1550
|
+
+ '<div class="template-editor-body">'
|
|
1551
|
+
+ '<div class="template-vars">'
|
|
1552
|
+
+ '<h4>Template variables</h4>' + varHtml
|
|
1553
|
+
+ '<h4 style="margin-top:.5rem">Translation keys</h4>' + tKeysHtml
|
|
1554
|
+
+ '<div style="color:#6b7280;margin-top:.25rem">Click any chip to insert into the focused textarea.</div>'
|
|
1555
|
+
+ '</div>'
|
|
1556
|
+
+ '<label><strong>HTML body</strong></label>'
|
|
1557
|
+
+ '<textarea id="tpl-html" class="template-editor-textarea">' + esc(existing.baseHtml || '') + '</textarea>'
|
|
1558
|
+
+ '<label><strong>Plain-text body</strong></label>'
|
|
1559
|
+
+ '<textarea id="tpl-text" class="template-editor-textarea" style="min-height:80px">' + esc(existing.baseText || '') + '</textarea>'
|
|
1560
|
+
+ '<label><strong>Translations</strong></label>'
|
|
1561
|
+
+ '<div id="tpl-translations-ui"></div>'
|
|
1562
|
+
+ '</div>';
|
|
1563
|
+
|
|
1564
|
+
_state.templates.editingTranslations = JSON.parse(JSON.stringify(existing.translations || {}));
|
|
1565
|
+
|
|
1566
|
+
renderTranslationsEditor('tpl-translations-ui', existing.translations || {}, meta.translationKeys,
|
|
1567
|
+
function (newTranslations) {
|
|
1568
|
+
_state.templates.editingTranslations = newTranslations;
|
|
1569
|
+
schedulePreview();
|
|
1570
|
+
});
|
|
1571
|
+
|
|
1572
|
+
_state.templates.lastFocus = document.getElementById('tpl-html');
|
|
1573
|
+
['tpl-html', 'tpl-text'].forEach(function (tid) {
|
|
1574
|
+
var el = document.getElementById(tid);
|
|
1575
|
+
if (!el) return;
|
|
1576
|
+
el.addEventListener('focus', function () { _state.templates.lastFocus = el; });
|
|
1577
|
+
el.addEventListener('input', schedulePreview);
|
|
1578
|
+
});
|
|
1579
|
+
container.querySelectorAll('.template-var-chip').forEach(function (chip) {
|
|
1580
|
+
chip.addEventListener('click', function () {
|
|
1581
|
+
insertAtCaret(_state.templates.lastFocus, chip.getAttribute('data-insert'));
|
|
1582
|
+
schedulePreview();
|
|
1583
|
+
});
|
|
1584
|
+
});
|
|
1585
|
+
|
|
1586
|
+
document.querySelectorAll('.template-list .template-item').forEach(function (el) {
|
|
1587
|
+
el.classList.toggle('active', el.getAttribute('data-tpl-id') === id);
|
|
1588
|
+
});
|
|
1589
|
+
|
|
1590
|
+
mountPreviewPane(meta);
|
|
1591
|
+
schedulePreview();
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
function editUiTranslation(page) {
|
|
1595
|
+
_state.templates.selectedId = page;
|
|
1596
|
+
var pageInfo = UI_PAGES.find(function (p) { return p.page === page; });
|
|
1597
|
+
var u = _state.templates.uiTranslations.find(function (x) { return x.page === page; }) || { page: page, translations: {} };
|
|
1598
|
+
var container = document.getElementById('template-editor');
|
|
1599
|
+
var suggestedKeys = pageInfo ? pageInfo.keys : [];
|
|
1600
|
+
|
|
1601
|
+
container.innerHTML = ''
|
|
1602
|
+
+ '<div class="template-editor-header">'
|
|
1603
|
+
+ '<div><h3>' + esc(pageInfo ? pageInfo.label : page) + '</h3>'
|
|
1604
|
+
+ '<small style="color:#6b7280">UI translation overrides for the <code>' + esc(page) + '</code> page.</small></div>'
|
|
1605
|
+
+ '<button class="btn btn-primary btn-sm" onclick="saveUiTranslation(\'' + esc(page) + '\')">Save</button>'
|
|
1606
|
+
+ '</div>'
|
|
1607
|
+
+ '<div class="template-editor-body">'
|
|
1608
|
+
+ '<div style="background:#fef3c7;padding:.75rem;border-radius:6px;font-size:12px;color:#92400e;margin-bottom:.5rem">'
|
|
1609
|
+
+ '\u2139\uFE0F Keys are the <code>data-i18n</code> attribute values used in <strong>' + esc(page) + '.html</strong>.'
|
|
1610
|
+
+ '</div>'
|
|
1611
|
+
+ '<div id="ui-translations-editor"></div>'
|
|
1612
|
+
+ '</div>';
|
|
1613
|
+
|
|
1614
|
+
_state.templates.editingTranslations = JSON.parse(JSON.stringify(u.translations || {}));
|
|
1615
|
+
|
|
1616
|
+
renderTranslationsEditor('ui-translations-editor', u.translations || {}, suggestedKeys,
|
|
1617
|
+
function (newTranslations) {
|
|
1618
|
+
_state.templates.editingTranslations = newTranslations;
|
|
1619
|
+
});
|
|
1620
|
+
|
|
1621
|
+
document.querySelectorAll('.template-list .template-item').forEach(function (el) {
|
|
1622
|
+
el.classList.toggle('active', el.getAttribute('data-tpl-id') === page);
|
|
1623
|
+
});
|
|
1624
|
+
|
|
1625
|
+
// Clear preview pane for UI translations (no preview needed)
|
|
1626
|
+
var prev = document.getElementById('template-editor-preview');
|
|
1627
|
+
if (prev) prev.innerHTML = '';
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
// Reusable per-language tab + key/value grid translations editor
|
|
1631
|
+
function renderTranslationsEditor(mountId, translations, suggestedKeys, onChange) {
|
|
1632
|
+
var mount = document.getElementById(mountId);
|
|
1633
|
+
if (!mount) return;
|
|
1634
|
+
var state = {
|
|
1635
|
+
data: JSON.parse(JSON.stringify(translations || {})),
|
|
1636
|
+
activeLang: Object.keys(translations || {})[0] || 'en',
|
|
1637
|
+
};
|
|
1638
|
+
|
|
1639
|
+
function render() {
|
|
1640
|
+
var langs = Object.keys(state.data);
|
|
1641
|
+
if (langs.length === 0) { state.data[state.activeLang] = {}; langs = [state.activeLang]; }
|
|
1642
|
+
if (!state.data[state.activeLang]) { state.activeLang = langs[0]; }
|
|
1643
|
+
|
|
1644
|
+
var tabs = langs.map(function (l) {
|
|
1645
|
+
return '<button class="translation-lang-tab' + (l === state.activeLang ? ' active' : '') + '" data-lang="' + esc(l) + '">'
|
|
1646
|
+
+ esc(l.toUpperCase())
|
|
1647
|
+
+ ' <span onclick="event.stopPropagation()" style="margin-left:.25rem;color:#9ca3af" data-rm-lang="' + esc(l) + '" title="Remove language">\u2715</span></button>';
|
|
1648
|
+
}).join('');
|
|
1649
|
+
|
|
1650
|
+
var activeData = state.data[state.activeLang] || {};
|
|
1651
|
+
var knownKeys = Object.keys(activeData).slice();
|
|
1652
|
+
suggestedKeys.forEach(function (k) { if (knownKeys.indexOf(k) === -1) knownKeys.push(k); });
|
|
1653
|
+
|
|
1654
|
+
var rows = knownKeys.map(function (k) {
|
|
1655
|
+
var val = activeData[k] !== undefined ? activeData[k] : '';
|
|
1656
|
+
return '<input type="text" value="' + esc(k) + '" data-key="' + esc(k) + '" data-role="key">'
|
|
1657
|
+
+ '<input type="text" value="' + esc(val) + '" data-key="' + esc(k) + '" data-role="value" placeholder="(not set)">'
|
|
1658
|
+
+ '<button class="btn btn-sm btn-danger" data-rm-key="' + esc(k) + '" title="Remove key">\u2715</button>';
|
|
1659
|
+
}).join('');
|
|
1660
|
+
|
|
1661
|
+
mount.innerHTML = ''
|
|
1662
|
+
+ '<div class="translation-lang-tabs">'
|
|
1663
|
+
+ tabs
|
|
1664
|
+
+ '<button class="translation-lang-tab" data-add-lang="1" title="Add language">+ Add lang</button>'
|
|
1665
|
+
+ '</div>'
|
|
1666
|
+
+ '<div class="translation-grid">'
|
|
1667
|
+
+ '<strong style="font-size:.75rem;text-transform:uppercase;color:#6b7280">Key</strong>'
|
|
1668
|
+
+ '<strong style="font-size:.75rem;text-transform:uppercase;color:#6b7280">Value</strong>'
|
|
1669
|
+
+ '<span></span>'
|
|
1670
|
+
+ rows
|
|
1671
|
+
+ '</div>'
|
|
1672
|
+
+ '<div style="display:flex;gap:.375rem;margin-top:.5rem">'
|
|
1673
|
+
+ '<input type="text" id="' + mountId + '-newkey" placeholder="new key" style="flex:1">'
|
|
1674
|
+
+ '<button class="btn btn-sm" data-add-key="1">+ Add key</button>'
|
|
1675
|
+
+ '</div>';
|
|
1676
|
+
|
|
1677
|
+
mount.querySelectorAll('.translation-lang-tab[data-lang]').forEach(function (btn) {
|
|
1678
|
+
btn.addEventListener('click', function () {
|
|
1679
|
+
state.activeLang = btn.getAttribute('data-lang');
|
|
1680
|
+
render();
|
|
1681
|
+
});
|
|
1682
|
+
});
|
|
1683
|
+
mount.querySelectorAll('[data-rm-lang]').forEach(function (x) {
|
|
1684
|
+
x.addEventListener('click', function () {
|
|
1685
|
+
var l = x.getAttribute('data-rm-lang');
|
|
1686
|
+
if (!confirm('Remove all "' + l + '" translations?')) return;
|
|
1687
|
+
delete state.data[l];
|
|
1688
|
+
state.activeLang = Object.keys(state.data)[0] || 'en';
|
|
1689
|
+
onChange(state.data);
|
|
1690
|
+
render();
|
|
1691
|
+
});
|
|
1692
|
+
});
|
|
1693
|
+
var addLangBtn = mount.querySelector('[data-add-lang]');
|
|
1694
|
+
if (addLangBtn) {
|
|
1695
|
+
addLangBtn.addEventListener('click', function () {
|
|
1696
|
+
var l = prompt('Language code (e.g. "fr", "de", "es"):');
|
|
1697
|
+
if (!l) return;
|
|
1698
|
+
l = l.trim().toLowerCase();
|
|
1699
|
+
if (!/^[a-z]{2,3}(-[a-z0-9]+)*$/.test(l)) { alert('Invalid language code'); return; }
|
|
1700
|
+
if (state.data[l]) { state.activeLang = l; render(); return; }
|
|
1701
|
+
state.data[l] = {};
|
|
1702
|
+
state.activeLang = l;
|
|
1703
|
+
onChange(state.data);
|
|
1704
|
+
render();
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1707
|
+
mount.querySelectorAll('input[data-role="value"]').forEach(function (inp) {
|
|
1708
|
+
inp.addEventListener('input', function () {
|
|
1709
|
+
var k = inp.getAttribute('data-key');
|
|
1710
|
+
if (!state.data[state.activeLang]) state.data[state.activeLang] = {};
|
|
1711
|
+
state.data[state.activeLang][k] = inp.value;
|
|
1712
|
+
onChange(state.data);
|
|
1713
|
+
});
|
|
1714
|
+
});
|
|
1715
|
+
mount.querySelectorAll('input[data-role="key"]').forEach(function (inp) {
|
|
1716
|
+
inp.addEventListener('change', function () {
|
|
1717
|
+
var oldK = inp.getAttribute('data-key');
|
|
1718
|
+
var newK = inp.value.trim();
|
|
1719
|
+
if (!newK || newK === oldK) { inp.value = oldK; return; }
|
|
1720
|
+
if (state.data[state.activeLang][newK] !== undefined) { alert('Key already exists'); inp.value = oldK; return; }
|
|
1721
|
+
state.data[state.activeLang][newK] = state.data[state.activeLang][oldK];
|
|
1722
|
+
delete state.data[state.activeLang][oldK];
|
|
1723
|
+
onChange(state.data);
|
|
1724
|
+
render();
|
|
1725
|
+
});
|
|
1726
|
+
});
|
|
1727
|
+
mount.querySelectorAll('[data-rm-key]').forEach(function (btn) {
|
|
1728
|
+
btn.addEventListener('click', function () {
|
|
1729
|
+
var k = btn.getAttribute('data-rm-key');
|
|
1730
|
+
delete state.data[state.activeLang][k];
|
|
1731
|
+
onChange(state.data);
|
|
1732
|
+
render();
|
|
1733
|
+
});
|
|
1734
|
+
});
|
|
1735
|
+
var addKeyBtn = mount.querySelector('[data-add-key]');
|
|
1736
|
+
if (addKeyBtn) {
|
|
1737
|
+
addKeyBtn.addEventListener('click', function () {
|
|
1738
|
+
var ip = document.getElementById(mountId + '-newkey');
|
|
1739
|
+
var k = ip ? ip.value.trim() : '';
|
|
1740
|
+
if (!k) return;
|
|
1741
|
+
if (!state.data[state.activeLang]) state.data[state.activeLang] = {};
|
|
1742
|
+
if (state.data[state.activeLang][k] !== undefined) { alert('Key already exists'); return; }
|
|
1743
|
+
state.data[state.activeLang][k] = '';
|
|
1744
|
+
if (ip) ip.value = '';
|
|
1745
|
+
onChange(state.data);
|
|
1746
|
+
render();
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
render();
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
// ---- Live preview helpers -------------------------------------------------
|
|
1755
|
+
|
|
1756
|
+
function schedulePreview() {
|
|
1757
|
+
clearTimeout(_state.templates.previewTimer);
|
|
1758
|
+
_state.templates.previewTimer = setTimeout(renderPreviewNow, 100);
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
function renderPreviewNow() {
|
|
1762
|
+
var id = _state.templates.selectedId;
|
|
1763
|
+
if (!id) return;
|
|
1764
|
+
var meta = MAIL_TEMPLATES.find(function (t) { return t.id === id; });
|
|
1765
|
+
if (!meta) return;
|
|
1766
|
+
var htmlEl = document.getElementById('tpl-html');
|
|
1767
|
+
var textEl = document.getElementById('tpl-text');
|
|
1768
|
+
var html = htmlEl ? htmlEl.value : '';
|
|
1769
|
+
var text = textEl ? textEl.value : '';
|
|
1770
|
+
var translations = _state.templates.editingTranslations || {};
|
|
1771
|
+
var lang = _state.templates.previewLang || 'en';
|
|
1772
|
+
var trans = translations[lang] || translations['en'] || {};
|
|
1773
|
+
|
|
1774
|
+
var sample = {};
|
|
1775
|
+
meta.variables.forEach(function (v) { sample[v.name] = '\xAB' + v.name + '\xBB'; });
|
|
1776
|
+
if (sample['link'] !== undefined) sample['link'] = 'https://example.com/action?token=XXXX';
|
|
1777
|
+
if (sample['loginUrl'] !== undefined) sample['loginUrl'] = 'https://example.com/login';
|
|
1778
|
+
if (sample['tempPassword'] !== undefined) sample['tempPassword'] = 'TempPw123!';
|
|
1779
|
+
if (sample['newEmail'] !== undefined) sample['newEmail'] = 'new.address@example.com';
|
|
1780
|
+
if (sample['token'] !== undefined) sample['token'] = 'abc123\u2026';
|
|
1781
|
+
|
|
1782
|
+
function interpolate(str) {
|
|
1783
|
+
var out = String(str || '').replace(/\{\{T\.([^}]+)\}\}/g, function (_, k) {
|
|
1784
|
+
return trans[k] !== undefined ? trans[k] : ('[' + k + ']');
|
|
1785
|
+
});
|
|
1786
|
+
out = out.replace(/\{\{([^}]+)\}\}/g, function (m, k) {
|
|
1787
|
+
if (k.indexOf('T.') === 0) return m;
|
|
1788
|
+
return sample[k] !== undefined ? sample[k] : ('[' + k + ']');
|
|
1789
|
+
});
|
|
1790
|
+
return out;
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
var subject = interpolate(trans['subject'] || '(no subject key set)');
|
|
1794
|
+
var rendered = interpolate(html);
|
|
1795
|
+
var renderedText = interpolate(text);
|
|
1796
|
+
|
|
1797
|
+
var subjEl = document.getElementById('preview-subject');
|
|
1798
|
+
if (subjEl) subjEl.textContent = 'Subject: ' + subject;
|
|
1799
|
+
|
|
1800
|
+
var iframe = document.getElementById('preview-iframe');
|
|
1801
|
+
if (iframe) {
|
|
1802
|
+
// Use srcdoc to render admin-authored HTML inside a fully sandboxed iframe.
|
|
1803
|
+
// sandbox="" prevents scripts, forms, top-navigation, plugins, and all other
|
|
1804
|
+
// active content — the admin can only see a styled preview of their own HTML.
|
|
1805
|
+
var previewDoc = '<!DOCTYPE html><html><head><meta charset="utf-8">'
|
|
1806
|
+
+ '<style>body{font:14px system-ui;padding:12px;margin:0;color:#111}a{color:#3b82f6}</style>'
|
|
1807
|
+
+ '</head><body>' + rendered + '</body></html>';
|
|
1808
|
+
iframe.srcdoc = previewDoc;
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
var textPrev = document.getElementById('preview-text');
|
|
1812
|
+
if (textPrev) textPrev.textContent = renderedText || '(empty)';
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
function mountPreviewPane(meta) {
|
|
1816
|
+
var main = document.getElementById('template-editor-preview');
|
|
1817
|
+
if (!main) return;
|
|
1818
|
+
var langs = Object.keys(_state.templates.editingTranslations || {});
|
|
1819
|
+
if (langs.length === 0) langs = ['en'];
|
|
1820
|
+
var langBtns = langs.map(function (l) {
|
|
1821
|
+
return '<button class="' + (l === (_state.templates.previewLang || 'en') ? 'active' : '') + '" data-preview-lang="' + esc(l) + '">' + esc(l.toUpperCase()) + '</button>';
|
|
1822
|
+
}).join('');
|
|
1823
|
+
|
|
1824
|
+
main.innerHTML = ''
|
|
1825
|
+
+ '<strong style="font-size:.75rem;text-transform:uppercase;color:#6b7280">Live preview</strong>'
|
|
1826
|
+
+ '<div class="template-preview-lang">' + langBtns + '</div>'
|
|
1827
|
+
+ '<div class="template-preview-subject" id="preview-subject">Subject: \u2026</div>'
|
|
1828
|
+
+ '<iframe class="template-preview-iframe" id="preview-iframe" sandbox=""></iframe>'
|
|
1829
|
+
+ '<details><summary style="cursor:pointer;font-size:.75rem;color:#6b7280">Plain-text</summary>'
|
|
1830
|
+
+ '<pre id="preview-text" style="background:white;padding:.5rem;border-radius:4px;white-space:pre-wrap;font-size:.75rem"></pre>'
|
|
1831
|
+
+ '</details>';
|
|
1832
|
+
|
|
1833
|
+
main.querySelectorAll('[data-preview-lang]').forEach(function (b) {
|
|
1834
|
+
b.addEventListener('click', function () {
|
|
1835
|
+
_state.templates.previewLang = b.getAttribute('data-preview-lang');
|
|
1836
|
+
mountPreviewPane(meta);
|
|
1837
|
+
schedulePreview();
|
|
1838
|
+
});
|
|
1839
|
+
});
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
async function saveMailTemplate(id) {
|
|
1843
|
+
var baseHtml = document.getElementById('tpl-html').value;
|
|
1844
|
+
var baseText = document.getElementById('tpl-text').value;
|
|
1845
|
+
var translations = _state.templates.editingTranslations || {};
|
|
1846
|
+
try {
|
|
1847
|
+
await api('POST', '/api/templates/mail', { id: id, baseHtml: baseHtml, baseText: baseText, translations: translations });
|
|
1848
|
+
flash('Template saved');
|
|
1849
|
+
renderTemplates();
|
|
1850
|
+
} catch (e) { flash(e.message, 'error'); }
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
async function resetMailTemplate(id) {
|
|
1854
|
+
if (!confirm('Reset "' + id + '" to the built-in default? Custom content will be lost.')) return;
|
|
1855
|
+
try {
|
|
1856
|
+
await api('POST', '/api/templates/mail', { id: id, baseHtml: '', baseText: '', translations: {} });
|
|
1857
|
+
flash('Template reset to default');
|
|
1858
|
+
renderTemplates();
|
|
1859
|
+
} catch (e) { flash(e.message, 'error'); }
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
async function saveUiTranslation(page) {
|
|
1863
|
+
var translations = _state.templates.editingTranslations || {};
|
|
1864
|
+
try {
|
|
1865
|
+
await api('POST', '/api/templates/ui', { page: page, translations: translations });
|
|
1866
|
+
flash('UI translations saved');
|
|
1867
|
+
renderTemplates();
|
|
1868
|
+
} catch (e) { flash(e.message, 'error'); }
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
function setTemplateType(type) {
|
|
1872
|
+
_state.templates.type = type;
|
|
1873
|
+
_state.templates.selectedId = null;
|
|
1874
|
+
renderTemplates();
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
window.setTemplateType = setTemplateType;
|
|
1878
|
+
window.editMailTemplate = editMailTemplate;
|
|
1879
|
+
window.editUiTranslation = editUiTranslation;
|
|
1880
|
+
window.saveMailTemplate = saveMailTemplate;
|
|
1881
|
+
window.saveUiTranslation = saveUiTranslation;
|
|
1882
|
+
window.resetMailTemplate = resetMailTemplate;
|
|
1883
|
+
window.deleteUser = deleteUser;
|
|
1884
|
+
window.setBulk2FA = setBulk2FA;
|
|
1885
|
+
window.renderSessions = renderSessions;
|
|
1886
|
+
window.revokeSession = revokeSession;
|
|
1887
|
+
window.renderRoles = renderRoles;
|
|
1888
|
+
window.createRole = createRole;
|
|
1889
|
+
window.deleteRole = deleteRole;
|
|
1890
|
+
window.renderTenants = renderTenants;
|
|
1891
|
+
window.toggleTenantPanel = toggleTenantPanel;
|
|
1892
|
+
window.addTenantUser = addTenantUser;
|
|
1893
|
+
window.removeTenantUser = removeTenantUser;
|
|
1894
|
+
window.createTenant = createTenant;
|
|
1895
|
+
window.deleteTenant = deleteTenant;
|
|
1896
|
+
window.renderControl = renderControl;
|
|
1897
|
+
window.toggleWebhookAction = toggleWebhookAction;
|
|
1898
|
+
window.updateEmailVerificationMode = updateEmailVerificationMode;
|
|
1899
|
+
window.saveGracePeriod = saveGracePeriod;
|
|
1900
|
+
window.updateSetting = updateSetting;
|
|
1901
|
+
window.saveUiSettings = saveUiSettings;
|
|
1902
|
+
window.uploadAsset = uploadAsset;
|
|
1903
|
+
window.listUploads = listUploads;
|
|
1904
|
+
window.deleteUpload = deleteUpload;
|
|
1905
|
+
window.syncBgColor = syncBgColor;
|
|
1906
|
+
window.syncBgColorText = syncBgColorText;
|
|
1907
|
+
window.syncCardBg = syncCardBg;
|
|
1908
|
+
window.syncCardBgText = syncCardBgText;
|
|
1909
|
+
window.uiPreview = uiPreview;
|
|
1910
|
+
window.renderApiKeys = renderApiKeys;
|
|
1911
|
+
window.revokeApiKey = revokeApiKey;
|
|
1912
|
+
window.deleteApiKey = deleteApiKey;
|
|
1913
|
+
window.createApiKey = createApiKey;
|
|
1914
|
+
window.renderWebhooks = renderWebhooks;
|
|
1915
|
+
window.toggleWebhookType = toggleWebhookType;
|
|
1916
|
+
window.openWebhookDrawer = openWebhookDrawer;
|
|
1917
|
+
window.closeWebhookDrawer = closeWebhookDrawer;
|
|
1918
|
+
window.saveWebhook = saveWebhook;
|
|
1919
|
+
window.createWebhook = createWebhook;
|
|
1920
|
+
window.toggleWebhook = toggleWebhook;
|
|
1921
|
+
window.deleteWebhook = deleteWebhook;
|
|
1922
|
+
window._state = _state;
|
|
1923
|
+
|
|
1924
|
+
// ---- Init ----------------------------------------------------------------
|
|
1925
|
+
buildNav();
|
|
1926
|
+
|
|
1927
|
+
}());
|