feat(opsserver-admin): add persisted admin bootstrap flow with optional idp.global authentication
This commit is contained in:
+1
-1
@@ -77,7 +77,7 @@
|
|||||||
"accessLevel": "public"
|
"accessLevel": "public"
|
||||||
},
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"engine": "tsdocker"
|
"engine": "tsdocker"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,15 @@
|
|||||||
- Validate vpnConfig.serverEndpoint, require persisted config managers for VPN startup, and normalize WireGuard AllowedIPs during client creation, export, and key rotation
|
- Validate vpnConfig.serverEndpoint, require persisted config managers for VPN startup, and normalize WireGuard AllowedIPs during client creation, export, and key rotation
|
||||||
- Switch smartvpn server setup to wireguard transport with a localhost-only listener and await async server stop operations consistently
|
- Switch smartvpn server setup to wireguard transport with a localhost-only listener and await async server stop operations consistently
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- add persisted admin bootstrap flow with optional idp.global authentication (opsserver-admin)
|
||||||
|
- introduces bootstrap status and initial admin creation endpoints for OpsServer
|
||||||
|
- switches admin authentication from ephemeral-only users to database-backed accounts when a persistent admin exists
|
||||||
|
- adds optional idp.global login support for admin accounts and exposes auth source metadata in user listings
|
||||||
|
- updates the web dashboard to prompt creation of the first persisted admin account
|
||||||
|
- adds integration coverage for bootstrap, persisted login, identity invalidation, and user listing behavior
|
||||||
|
|
||||||
## 2026-05-09 - 13.28.0 - feat(gateway-clients)
|
## 2026-05-09 - 13.28.0 - feat(gateway-clients)
|
||||||
add managed gateway client administration and token-bound route ownership
|
add managed gateway client administration and token-bound route ownership
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||||
"@design.estate/dees-catalog": "^3.81.0",
|
"@design.estate/dees-catalog": "^3.81.0",
|
||||||
"@design.estate/dees-element": "^2.2.4",
|
"@design.estate/dees-element": "^2.2.4",
|
||||||
|
"@idp.global/sdk": "^1.2.0",
|
||||||
"@push.rocks/lik": "^6.4.1",
|
"@push.rocks/lik": "^6.4.1",
|
||||||
"@push.rocks/projectinfo": "^5.1.0",
|
"@push.rocks/projectinfo": "^5.1.0",
|
||||||
"@push.rocks/qenv": "^6.1.4",
|
"@push.rocks/qenv": "^6.1.4",
|
||||||
|
|||||||
Generated
+49
@@ -29,6 +29,9 @@ importers:
|
|||||||
'@design.estate/dees-element':
|
'@design.estate/dees-element':
|
||||||
specifier: ^2.2.4
|
specifier: ^2.2.4
|
||||||
version: 2.2.4
|
version: 2.2.4
|
||||||
|
'@idp.global/sdk':
|
||||||
|
specifier: ^1.2.0
|
||||||
|
version: 1.2.0(@push.rocks/smartserve@2.0.4)(socks@2.8.8)
|
||||||
'@push.rocks/lik':
|
'@push.rocks/lik':
|
||||||
specifier: ^6.4.1
|
specifier: ^6.4.1
|
||||||
version: 6.4.1
|
version: 6.4.1
|
||||||
@@ -591,6 +594,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-lBW6/m5BIFl3pMuWPNN0lIOYw9LMCmPfix53ExS3FBi4E+NELEljQ3xH6aAV9IYiQRfn9YIIgzzMrD0vIcD7tw==}
|
resolution: {integrity: sha512-lBW6/m5BIFl3pMuWPNN0lIOYw9LMCmPfix53ExS3FBi4E+NELEljQ3xH6aAV9IYiQRfn9YIIgzzMrD0vIcD7tw==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@idp.global/sdk@1.2.0':
|
||||||
|
resolution: {integrity: sha512-L1SUh+wt9dKZ9DzX97M0wrJ080PF3sj1sEtmAOM7A67ZYs0RecCciFB3D5qspOBBVlsw+L4lPmOWv3j720lVTQ==}
|
||||||
|
|
||||||
'@img/colour@1.1.0':
|
'@img/colour@1.1.0':
|
||||||
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1372,6 +1378,9 @@ packages:
|
|||||||
'@push.rocks/taskbuffer@8.0.2':
|
'@push.rocks/taskbuffer@8.0.2':
|
||||||
resolution: {integrity: sha512-SRCAzrSHysW5XEjwZ494V60ybdpOo/s96jDD3sn7SkYolzg2Pboh+SW5Q7SVNcdkP4b9wCEizOYe9CB3vj3W6w==}
|
resolution: {integrity: sha512-SRCAzrSHysW5XEjwZ494V60ybdpOo/s96jDD3sn7SkYolzg2Pboh+SW5Q7SVNcdkP4b9wCEizOYe9CB3vj3W6w==}
|
||||||
|
|
||||||
|
'@push.rocks/webjwt@1.0.10':
|
||||||
|
resolution: {integrity: sha512-+KzM6/v3Y/8uXBE8JMNBRcYRtXdRywpbX0CrJVfqS00/x/2ZnLvWy0ZZtrvwkQZvrQvfNFF7xgt4+m91xmVKhQ==}
|
||||||
|
|
||||||
'@push.rocks/webrequest@4.0.5':
|
'@push.rocks/webrequest@4.0.5':
|
||||||
resolution: {integrity: sha512-wVSCaXqJ9Vh+rbwVz0wDl46dYz4rnwwSrm5vbVXKbuH6oKTPF0YRoujeJPqRltIn64RVGdLeY9/6ix+ZCrzhsg==}
|
resolution: {integrity: sha512-wVSCaXqJ9Vh+rbwVz0wDl46dYz4rnwwSrm5vbVXKbuH6oKTPF0YRoujeJPqRltIn64RVGdLeY9/6ix+ZCrzhsg==}
|
||||||
|
|
||||||
@@ -1593,6 +1602,9 @@ packages:
|
|||||||
'@serve.zone/interfaces@5.5.0':
|
'@serve.zone/interfaces@5.5.0':
|
||||||
resolution: {integrity: sha512-SZH4sKxBhfX+xF7zPFcHtyWdXMz7XINP5X9tqtLKPa3rJd5XkoeOFsbgDxWfeuBkCGJglvY2FI24oCPexy5acg==}
|
resolution: {integrity: sha512-SZH4sKxBhfX+xF7zPFcHtyWdXMz7XINP5X9tqtLKPa3rJd5XkoeOFsbgDxWfeuBkCGJglvY2FI24oCPexy5acg==}
|
||||||
|
|
||||||
|
'@serve.zone/interfaces@file:../interfaces':
|
||||||
|
resolution: {directory: ../interfaces, type: directory}
|
||||||
|
|
||||||
'@serve.zone/remoteingress@4.17.1':
|
'@serve.zone/remoteingress@4.17.1':
|
||||||
resolution: {integrity: sha512-k3n+AF1rNybiKPlHHyhwCVEF0/T7eZD46kNn7JlEJPCxfUy09mjkpwDQ2CzaUkppqNgFOAYXgAKqjDqpJ27RvA==}
|
resolution: {integrity: sha512-k3n+AF1rNybiKPlHHyhwCVEF0/T7eZD46kNn7JlEJPCxfUy09mjkpwDQ2CzaUkppqNgFOAYXgAKqjDqpJ27RvA==}
|
||||||
|
|
||||||
@@ -5182,6 +5194,33 @@ snapshots:
|
|||||||
- bufferutil
|
- bufferutil
|
||||||
- utf-8-validate
|
- utf-8-validate
|
||||||
|
|
||||||
|
'@idp.global/sdk@1.2.0(@push.rocks/smartserve@2.0.4)(socks@2.8.8)':
|
||||||
|
dependencies:
|
||||||
|
'@api.global/typedrequest': 3.3.0
|
||||||
|
'@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.4)
|
||||||
|
'@idp.global/interfaces': '@serve.zone/interfaces@file:../interfaces'
|
||||||
|
'@push.rocks/smartdata': 7.1.7(socks@2.8.8)
|
||||||
|
'@push.rocks/smartjson': 6.0.1
|
||||||
|
'@push.rocks/smartpromise': 4.2.4
|
||||||
|
'@push.rocks/smartrx': 3.0.10
|
||||||
|
'@push.rocks/smarttime': 4.2.3
|
||||||
|
'@push.rocks/smarturl': 3.1.0
|
||||||
|
'@push.rocks/webjwt': 1.0.10
|
||||||
|
'@push.rocks/webstore': 2.0.22
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@aws-sdk/credential-providers'
|
||||||
|
- '@mongodb-js/zstd'
|
||||||
|
- '@nuxt/kit'
|
||||||
|
- '@push.rocks/smartserve'
|
||||||
|
- gcp-metadata
|
||||||
|
- kerberos
|
||||||
|
- mongodb-client-encryption
|
||||||
|
- react
|
||||||
|
- snappy
|
||||||
|
- socks
|
||||||
|
- supports-color
|
||||||
|
- vue
|
||||||
|
|
||||||
'@img/colour@1.1.0': {}
|
'@img/colour@1.1.0': {}
|
||||||
|
|
||||||
'@img/sharp-darwin-arm64@0.34.5':
|
'@img/sharp-darwin-arm64@0.34.5':
|
||||||
@@ -6645,6 +6684,10 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- vue
|
- vue
|
||||||
|
|
||||||
|
'@push.rocks/webjwt@1.0.10':
|
||||||
|
dependencies:
|
||||||
|
'@push.rocks/smartstring': 4.1.1
|
||||||
|
|
||||||
'@push.rocks/webrequest@4.0.5':
|
'@push.rocks/webrequest@4.0.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/smartdelay': 3.1.0
|
'@push.rocks/smartdelay': 3.1.0
|
||||||
@@ -6846,6 +6889,12 @@ snapshots:
|
|||||||
'@push.rocks/smartlog-interfaces': 3.0.2
|
'@push.rocks/smartlog-interfaces': 3.0.2
|
||||||
'@tsclass/tsclass': 9.5.1
|
'@tsclass/tsclass': 9.5.1
|
||||||
|
|
||||||
|
'@serve.zone/interfaces@file:../interfaces':
|
||||||
|
dependencies:
|
||||||
|
'@api.global/typedrequest-interfaces': 3.0.19
|
||||||
|
'@push.rocks/smartlog-interfaces': 3.0.2
|
||||||
|
'@tsclass/tsclass': 9.5.1
|
||||||
|
|
||||||
'@serve.zone/remoteingress@4.17.1':
|
'@serve.zone/remoteingress@4.17.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/qenv': 6.1.4
|
'@push.rocks/qenv': 6.1.4
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { TypedRequest } from '@api.global/typedrequest';
|
||||||
|
import { OpsServer } from '../ts/opsserver/index.js';
|
||||||
|
import { DcRouterDb } from '../ts/db/index.js';
|
||||||
|
import * as plugins from '../ts/plugins.js';
|
||||||
|
import * as interfaces from '../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
const testPort = 3110;
|
||||||
|
const baseUrl = `http://localhost:${testPort}/typedrequest`;
|
||||||
|
const bootstrapPassword = 'temporary-bootstrap-password';
|
||||||
|
const persistedPassword = 'persisted-admin-password';
|
||||||
|
|
||||||
|
let previousAdminPassword: string | undefined;
|
||||||
|
let opsServer: OpsServer;
|
||||||
|
let testDb: DcRouterDb;
|
||||||
|
let storagePath: string;
|
||||||
|
let bootstrapIdentity: interfaces.data.IIdentity;
|
||||||
|
let persistedIdentity: interfaces.data.IIdentity;
|
||||||
|
|
||||||
|
const createStatusRequest = () => new TypedRequest<interfaces.requests.IReq_GetAdminBootstrapStatus>(
|
||||||
|
baseUrl,
|
||||||
|
'getAdminBootstrapStatus',
|
||||||
|
);
|
||||||
|
|
||||||
|
const createLoginRequest = () => new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||||
|
baseUrl,
|
||||||
|
'adminLoginWithUsernameAndPassword',
|
||||||
|
);
|
||||||
|
|
||||||
|
tap.test('setup db-backed OpsServer admin bootstrap test', async () => {
|
||||||
|
previousAdminPassword = process.env.DCROUTER_ADMIN_PASSWORD;
|
||||||
|
process.env.DCROUTER_ADMIN_PASSWORD = bootstrapPassword;
|
||||||
|
|
||||||
|
storagePath = plugins.path.join(
|
||||||
|
plugins.os.tmpdir(),
|
||||||
|
`dcrouter-admin-bootstrap-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
DcRouterDb.resetInstance();
|
||||||
|
testDb = DcRouterDb.getInstance({
|
||||||
|
storagePath,
|
||||||
|
dbName: `dcrouter-admin-bootstrap-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
|
});
|
||||||
|
await testDb.start();
|
||||||
|
await testDb.getDb().mongoDb.createCollection('__test_init');
|
||||||
|
|
||||||
|
const fakeDcRouter = {
|
||||||
|
options: {
|
||||||
|
opsServerPort: testPort,
|
||||||
|
dbConfig: { enabled: true },
|
||||||
|
adminAuth: {
|
||||||
|
idpClient: {
|
||||||
|
loginWithEmailAndPassword: async () => ({
|
||||||
|
jwt: 'idp-jwt',
|
||||||
|
refreshToken: 'idp-refresh-token',
|
||||||
|
user: {
|
||||||
|
id: 'idp-user-1',
|
||||||
|
data: {
|
||||||
|
name: 'Wrong IdP User',
|
||||||
|
username: 'wrong@example.com',
|
||||||
|
email: 'wrong@example.com',
|
||||||
|
status: 'active',
|
||||||
|
connectedOrgs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
stop: async () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
typedrouter: new plugins.typedrequest.TypedRouter(),
|
||||||
|
dcRouterDb: testDb,
|
||||||
|
};
|
||||||
|
|
||||||
|
opsServer = new OpsServer(fakeDcRouter as any);
|
||||||
|
await opsServer.start();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('reports bootstrap required without auto-persisting an admin', async () => {
|
||||||
|
const status = await createStatusRequest().fire({});
|
||||||
|
|
||||||
|
expect(status.dbEnabled).toEqual(true);
|
||||||
|
expect(status.dbReady).toEqual(true);
|
||||||
|
expect(status.hasPersistentAdmin).toEqual(false);
|
||||||
|
expect(status.needsBootstrap).toEqual(true);
|
||||||
|
expect(status.ephemeralAdminAvailable).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('allows temporary bootstrap admin login before persisted admin exists', async () => {
|
||||||
|
const response = await createLoginRequest().fire({
|
||||||
|
username: 'admin',
|
||||||
|
password: bootstrapPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.identity) {
|
||||||
|
throw new Error('Expected bootstrap login identity');
|
||||||
|
}
|
||||||
|
bootstrapIdentity = response.identity;
|
||||||
|
expect(bootstrapIdentity.role).toEqual('admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('creates the initial persisted admin explicitly', async () => {
|
||||||
|
const request = new TypedRequest<interfaces.requests.IReq_CreateInitialAdminUser>(
|
||||||
|
baseUrl,
|
||||||
|
'createInitialAdminUser',
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: bootstrapIdentity,
|
||||||
|
email: 'Admin@Example.com',
|
||||||
|
name: 'Persisted Admin',
|
||||||
|
password: persistedPassword,
|
||||||
|
enableIdpGlobalAuth: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.success).toEqual(true);
|
||||||
|
expect(response.user?.role).toEqual('admin');
|
||||||
|
expect(response.user?.authSources).toContain('local');
|
||||||
|
expect(response.user?.authSources).toContain('idp.global');
|
||||||
|
if (!response.identity) {
|
||||||
|
throw new Error('Expected persisted admin identity');
|
||||||
|
}
|
||||||
|
persistedIdentity = response.identity;
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('disables bootstrap mode after persisted admin exists', async () => {
|
||||||
|
const status = await createStatusRequest().fire({});
|
||||||
|
|
||||||
|
expect(status.hasPersistentAdmin).toEqual(true);
|
||||||
|
expect(status.needsBootstrap).toEqual(false);
|
||||||
|
expect(status.ephemeralAdminAvailable).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('rejects the old temporary admin after persisted admin creation', async () => {
|
||||||
|
let rejected = false;
|
||||||
|
try {
|
||||||
|
await createLoginRequest().fire({
|
||||||
|
username: 'admin',
|
||||||
|
password: bootstrapPassword,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
rejected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(rejected).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('rejects the old temporary admin identity after persisted admin creation', async () => {
|
||||||
|
const request = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
|
baseUrl,
|
||||||
|
'verifyIdentity',
|
||||||
|
);
|
||||||
|
const response = await request.fire({ identity: bootstrapIdentity });
|
||||||
|
|
||||||
|
expect(response.valid).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('authenticates the persisted admin locally by normalized email', async () => {
|
||||||
|
const response = await createLoginRequest().fire({
|
||||||
|
username: 'admin@example.com',
|
||||||
|
password: persistedPassword,
|
||||||
|
authSource: 'local',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.identity) {
|
||||||
|
throw new Error('Expected persisted admin login identity');
|
||||||
|
}
|
||||||
|
expect(response.identity.userId).toEqual(persistedIdentity.userId);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('rejects idp.global login when IdP email does not match local account', async () => {
|
||||||
|
let rejected = false;
|
||||||
|
try {
|
||||||
|
await createLoginRequest().fire({
|
||||||
|
username: 'admin@example.com',
|
||||||
|
password: 'idp-password',
|
||||||
|
authSource: 'idp.global',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
rejected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(rejected).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('lists persisted users without password material', async () => {
|
||||||
|
const request = new TypedRequest<interfaces.requests.IReq_ListUsers>(baseUrl, 'listUsers');
|
||||||
|
const response = await request.fire({ identity: persistedIdentity });
|
||||||
|
|
||||||
|
expect(response.users.length).toEqual(1);
|
||||||
|
expect(response.users[0].email).toEqual('Admin@Example.com');
|
||||||
|
expect((response.users[0] as any).password).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('cleanup db-backed OpsServer admin bootstrap test', async () => {
|
||||||
|
await opsServer.stop();
|
||||||
|
await testDb.stop();
|
||||||
|
DcRouterDb.resetInstance();
|
||||||
|
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
|
||||||
|
|
||||||
|
if (previousAdminPassword === undefined) {
|
||||||
|
delete process.env.DCROUTER_ADMIN_PASSWORD;
|
||||||
|
} else {
|
||||||
|
process.env.DCROUTER_ADMIN_PASSWORD = previousAdminPassword;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -167,6 +167,14 @@ export interface IDcRouterOptions {
|
|||||||
/** Port for the OpsServer web UI (default: 3000) */
|
/** Port for the OpsServer web UI (default: 3000) */
|
||||||
opsServerPort?: number;
|
opsServerPort?: number;
|
||||||
|
|
||||||
|
/** Optional OpsServer account authentication settings. */
|
||||||
|
adminAuth?: {
|
||||||
|
/** Base URL for idp.global password authentication. Can also be set through DCROUTER_IDP_GLOBAL_URL. */
|
||||||
|
idpGlobalUrl?: string;
|
||||||
|
/** Test/integration hook for injecting an idp.global-compatible password client. */
|
||||||
|
idpClient?: Pick<plugins.idpSdkServer.IdpGlobalServerClient, 'loginWithEmailAndPassword' | 'stop'>;
|
||||||
|
};
|
||||||
|
|
||||||
remoteIngressConfig?: {
|
remoteIngressConfig?: {
|
||||||
/** Enable remote ingress hub (default: false) */
|
/** Enable remote ingress hub (default: false) */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
|||||||
@@ -113,6 +113,9 @@ export class OpsServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async stop() {
|
public async stop() {
|
||||||
|
if (this.adminHandler) {
|
||||||
|
await this.adminHandler.stop();
|
||||||
|
}
|
||||||
// Clean up log handler streams and push destination before stopping the server
|
// Clean up log handler streams and push destination before stopping the server
|
||||||
if (this.logsHandler) {
|
if (this.logsHandler) {
|
||||||
this.logsHandler.cleanup();
|
this.logsHandler.cleanup();
|
||||||
|
|||||||
@@ -8,19 +8,33 @@ export interface IJwtData {
|
|||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TAdminUser = {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
role: string;
|
||||||
|
status?: 'active' | 'disabled';
|
||||||
|
authSources?: Array<'local' | 'idp.global'>;
|
||||||
|
};
|
||||||
|
|
||||||
export class AdminHandler {
|
export class AdminHandler {
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
// JWT instance
|
// JWT instance
|
||||||
public smartjwtInstance!: plugins.smartjwt.SmartJwt<IJwtData>;
|
public smartjwtInstance!: plugins.smartjwt.SmartJwt<IJwtData>;
|
||||||
|
|
||||||
// Simple in-memory user storage (in production, use proper database)
|
// Ephemeral bootstrap users. Persisted accounts take over once an active admin exists.
|
||||||
private users = new Map<string, {
|
private users = new Map<string, {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
role: string;
|
role: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
private accountStore?: plugins.idpSdkServer.SmartdataAccountStore;
|
||||||
|
private idpClient?: plugins.idpSdkServer.IdpGlobalServerClient;
|
||||||
|
private ownsIdpClient = false;
|
||||||
|
|
||||||
constructor(private opsServerRef: OpsServer) {
|
constructor(private opsServerRef: OpsServer) {
|
||||||
// Add this handler's router to the parent
|
// Add this handler's router to the parent
|
||||||
@@ -32,6 +46,14 @@ export class AdminHandler {
|
|||||||
this.initializeDefaultUsers();
|
this.initializeDefaultUsers();
|
||||||
this.registerHandlers();
|
this.registerHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
if (this.ownsIdpClient) {
|
||||||
|
await this.idpClient?.stop();
|
||||||
|
}
|
||||||
|
this.idpClient = undefined;
|
||||||
|
this.ownsIdpClient = false;
|
||||||
|
}
|
||||||
|
|
||||||
private async initializeJwt(): Promise<void> {
|
private async initializeJwt(): Promise<void> {
|
||||||
this.smartjwtInstance = new plugins.smartjwt.SmartJwt();
|
this.smartjwtInstance = new plugins.smartjwt.SmartJwt();
|
||||||
@@ -61,54 +83,120 @@ export class AdminHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return a safe projection of the users Map — excludes password fields.
|
* Return a safe projection of the active user source — excludes password fields.
|
||||||
* Used by UsersHandler to serve the admin-only listUsers endpoint.
|
* Used by UsersHandler to serve the admin-only listUsers endpoint.
|
||||||
*/
|
*/
|
||||||
public listUsers(): Array<{ id: string; username: string; role: string }> {
|
public async listUsers(): Promise<interfaces.requests.IAdminUserProjection[]> {
|
||||||
|
if (await this.hasPersistentAdminAccount()) {
|
||||||
|
const store = this.getAccountStore();
|
||||||
|
const accounts = await store!.listAccounts();
|
||||||
|
return accounts.map((accountArg) => this.accountToUser(accountArg));
|
||||||
|
}
|
||||||
|
|
||||||
return Array.from(this.users.values()).map((user) => ({
|
return Array.from(this.users.values()).map((user) => ({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getBootstrapStatus(): Promise<interfaces.requests.IReq_GetAdminBootstrapStatus['response']> {
|
||||||
|
const dbEnabled = this.opsServerRef.dcRouterRef.options.dbConfig?.enabled !== false;
|
||||||
|
const store = this.getAccountStore();
|
||||||
|
const dbReady = !!store;
|
||||||
|
const hasPersistentAdmin = dbReady ? await store.hasActiveAdminAccount() : false;
|
||||||
|
return {
|
||||||
|
dbEnabled,
|
||||||
|
dbReady,
|
||||||
|
hasPersistentAdmin,
|
||||||
|
needsBootstrap: dbEnabled && dbReady && !hasPersistentAdmin,
|
||||||
|
ephemeralAdminAvailable: !hasPersistentAdmin,
|
||||||
|
idpGlobalConfigured: this.isIdpGlobalConfigured(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createInitialAdminUser(optionsArg: {
|
||||||
|
email: string;
|
||||||
|
name?: string;
|
||||||
|
password: string;
|
||||||
|
enableIdpGlobalAuth?: boolean;
|
||||||
|
}): Promise<interfaces.requests.IReq_CreateInitialAdminUser['response']> {
|
||||||
|
const store = this.getAccountStore();
|
||||||
|
if (!store) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('database is not ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await store.hasActiveAdminAccount()) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('initial admin already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = String(optionsArg.password || '');
|
||||||
|
if (!password) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('password is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = String(optionsArg.email || '').trim();
|
||||||
|
const authSources: Array<'local' | 'idp.global'> = ['local'];
|
||||||
|
if (optionsArg.enableIdpGlobalAuth) {
|
||||||
|
authSources.push('idp.global');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const account = await store.createAccount({
|
||||||
|
email,
|
||||||
|
name: String(optionsArg.name || '').trim() || email,
|
||||||
|
role: 'admin',
|
||||||
|
authSources,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
const user = this.accountToUser(account);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
identity: await this.createIdentityForUser(user),
|
||||||
|
user,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError((error as Error).message || 'failed to create initial admin');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private registerHandlers(): void {
|
private registerHandlers(): void {
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAdminBootstrapStatus>(
|
||||||
|
'getAdminBootstrapStatus',
|
||||||
|
async (_dataArg) => this.getBootstrapStatus()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.opsServerRef.adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateInitialAdminUser>(
|
||||||
|
'createInitialAdminUser',
|
||||||
|
async (dataArg) => this.createInitialAdminUser({
|
||||||
|
email: dataArg.email,
|
||||||
|
name: dataArg.name,
|
||||||
|
password: dataArg.password,
|
||||||
|
enableIdpGlobalAuth: dataArg.enableIdpGlobalAuth,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
// Admin Login Handler
|
// Admin Login Handler
|
||||||
this.typedrouter.addTypedHandler(
|
this.typedrouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||||
'adminLoginWithUsernameAndPassword',
|
'adminLoginWithUsernameAndPassword',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
try {
|
try {
|
||||||
// Find user by username and password
|
const user = await this.authenticateUser({
|
||||||
let user: { id: string; username: string; password: string; role: string } | null = null;
|
username: dataArg.username,
|
||||||
for (const [_, userData] of this.users) {
|
password: dataArg.password,
|
||||||
if (userData.username === dataArg.username && userData.password === dataArg.password) {
|
authSource: dataArg.authSource,
|
||||||
user = userData;
|
});
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new plugins.typedrequest.TypedResponseError('login failed');
|
throw new plugins.typedrequest.TypedResponseError('login failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
const expiresAtTimestamp = Date.now() + 3600 * 1000 * 24; // 24 hours
|
|
||||||
|
|
||||||
const jwt = await this.smartjwtInstance.createJWT({
|
|
||||||
userId: user.id,
|
|
||||||
status: 'loggedIn',
|
|
||||||
expiresAt: expiresAtTimestamp,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
identity: {
|
identity: await this.createIdentityForUser(user),
|
||||||
jwt,
|
|
||||||
userId: user.id,
|
|
||||||
name: user.username,
|
|
||||||
expiresAt: expiresAtTimestamp,
|
|
||||||
role: user.role,
|
|
||||||
type: 'user',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof plugins.typedrequest.TypedResponseError) {
|
if (error instanceof plugins.typedrequest.TypedResponseError) {
|
||||||
@@ -162,8 +250,7 @@ export class AdminHandler {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find user
|
const user = await this.resolveUser(jwtData.userId);
|
||||||
const user = this.users.get(jwtData.userId);
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
@@ -175,7 +262,7 @@ export class AdminHandler {
|
|||||||
identity: {
|
identity: {
|
||||||
jwt: dataArg.identity.jwt,
|
jwt: dataArg.identity.jwt,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
name: user.username,
|
name: user.name || user.username,
|
||||||
expiresAt: jwtData.expiresAt,
|
expiresAt: jwtData.expiresAt,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
type: 'user',
|
type: 'user',
|
||||||
@@ -224,6 +311,15 @@ export class AdminHandler {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const user = await this.resolveUser(jwtData.userId);
|
||||||
|
if (!user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataArg.identity.role && dataArg.identity.role !== user.role) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return false;
|
return false;
|
||||||
@@ -256,4 +352,120 @@ export class AdminHandler {
|
|||||||
name: 'adminIdentityGuard',
|
name: 'adminIdentityGuard',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private async authenticateUser(optionsArg: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
authSource?: interfaces.requests.TAdminLoginAuthSource;
|
||||||
|
}): Promise<TAdminUser | null> {
|
||||||
|
if (await this.hasPersistentAdminAccount()) {
|
||||||
|
const store = this.getAccountStore();
|
||||||
|
const authService = new plugins.idpSdkServer.AccountAuthService({
|
||||||
|
store: store!,
|
||||||
|
idpClient: this.getIdpClient() as plugins.idpSdkServer.IdpGlobalServerClient | undefined,
|
||||||
|
});
|
||||||
|
const result = await authService.authenticate({
|
||||||
|
email: optionsArg.username,
|
||||||
|
password: optionsArg.password,
|
||||||
|
authSource: optionsArg.authSource || 'auto',
|
||||||
|
});
|
||||||
|
return result ? this.accountToUser(result.account) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [_, userData] of this.users) {
|
||||||
|
if (userData.username === optionsArg.username && userData.password === optionsArg.password) {
|
||||||
|
return userData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveUser(userIdArg: string): Promise<TAdminUser | null> {
|
||||||
|
if (await this.hasPersistentAdminAccount()) {
|
||||||
|
const account = await this.getAccountStore()!.getAccountById(userIdArg);
|
||||||
|
if (!account || account.status !== 'active') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.accountToUser(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.users.get(userIdArg) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async hasPersistentAdminAccount(): Promise<boolean> {
|
||||||
|
const store = this.getAccountStore();
|
||||||
|
return store ? store.hasActiveAdminAccount() : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAccountStore(): plugins.idpSdkServer.SmartdataAccountStore | null {
|
||||||
|
if (this.opsServerRef.dcRouterRef.options.dbConfig?.enabled === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const dcRouterDb = this.opsServerRef.dcRouterRef.dcRouterDb;
|
||||||
|
if (!dcRouterDb?.isReady()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!this.accountStore) {
|
||||||
|
this.accountStore = new plugins.idpSdkServer.SmartdataAccountStore({
|
||||||
|
smartdataDb: dcRouterDb.getDb(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.accountStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getIdpClient(): Pick<plugins.idpSdkServer.IdpGlobalServerClient, 'loginWithEmailAndPassword' | 'stop'> | undefined {
|
||||||
|
const configuredClient = this.opsServerRef.dcRouterRef.options.adminAuth?.idpClient;
|
||||||
|
if (configuredClient) {
|
||||||
|
return configuredClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = this.opsServerRef.dcRouterRef.options.adminAuth?.idpGlobalUrl || process.env.DCROUTER_IDP_GLOBAL_URL;
|
||||||
|
if (!baseUrl) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.idpClient) {
|
||||||
|
this.idpClient = new plugins.idpSdkServer.IdpGlobalServerClient({ baseUrl });
|
||||||
|
this.ownsIdpClient = true;
|
||||||
|
}
|
||||||
|
return this.idpClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isIdpGlobalConfigured(): boolean {
|
||||||
|
return !!(
|
||||||
|
this.opsServerRef.dcRouterRef.options.adminAuth?.idpClient ||
|
||||||
|
this.opsServerRef.dcRouterRef.options.adminAuth?.idpGlobalUrl ||
|
||||||
|
process.env.DCROUTER_IDP_GLOBAL_URL
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private accountToUser(accountArg: plugins.idpSdkServer.IIdpSdkAccount): TAdminUser {
|
||||||
|
return {
|
||||||
|
id: accountArg.id,
|
||||||
|
username: accountArg.email,
|
||||||
|
email: accountArg.email,
|
||||||
|
name: accountArg.name,
|
||||||
|
role: accountArg.role,
|
||||||
|
status: accountArg.status,
|
||||||
|
authSources: accountArg.authSources,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createIdentityForUser(userArg: TAdminUser): Promise<interfaces.data.IIdentity> {
|
||||||
|
const expiresAtTimestamp = Date.now() + 3600 * 1000 * 24; // 24 hours
|
||||||
|
const jwt = await this.smartjwtInstance.createJWT({
|
||||||
|
userId: userArg.id,
|
||||||
|
status: 'loggedIn',
|
||||||
|
expiresAt: expiresAtTimestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
jwt,
|
||||||
|
userId: userArg.id,
|
||||||
|
name: userArg.name || userArg.username,
|
||||||
|
expiresAt: expiresAtTimestamp,
|
||||||
|
role: userArg.role,
|
||||||
|
type: 'user',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export class UsersHandler {
|
|||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListUsers>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListUsers>(
|
||||||
'listUsers',
|
'listUsers',
|
||||||
async (_dataArg) => {
|
async (_dataArg) => {
|
||||||
const users = this.opsServerRef.adminHandler.listUsers();
|
const users = await this.opsServerRef.adminHandler.listUsers();
|
||||||
return { users };
|
return { users };
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ export {
|
|||||||
typedsocket,
|
typedsocket,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @idp.global scope
|
||||||
|
import * as idpSdkServer from '@idp.global/sdk/server';
|
||||||
|
|
||||||
|
export {
|
||||||
|
idpSdkServer,
|
||||||
|
}
|
||||||
|
|
||||||
// @push.rocks scope
|
// @push.rocks scope
|
||||||
import * as projectinfo from '@push.rocks/projectinfo';
|
import * as projectinfo from '@push.rocks/projectinfo';
|
||||||
import * as qenv from '@push.rocks/qenv';
|
import * as qenv from '@push.rocks/qenv';
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import * as authInterfaces from '../data/auth.js';
|
import * as authInterfaces from '../data/auth.js';
|
||||||
|
|
||||||
|
export type TAdminLoginAuthSource = 'auto' | 'local' | 'idp.global';
|
||||||
|
|
||||||
|
export interface IAdminUserProjection {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
role: string;
|
||||||
|
status?: 'active' | 'disabled';
|
||||||
|
authSources?: Array<'local' | 'idp.global'>;
|
||||||
|
}
|
||||||
|
|
||||||
// Admin Login
|
// Admin Login
|
||||||
export interface IReq_AdminLoginWithUsernameAndPassword extends plugins.typedrequestInterfaces.implementsTR<
|
export interface IReq_AdminLoginWithUsernameAndPassword extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
plugins.typedrequestInterfaces.ITypedRequest,
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
@@ -10,12 +22,50 @@ export interface IReq_AdminLoginWithUsernameAndPassword extends plugins.typedreq
|
|||||||
request: {
|
request: {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
authSource?: TAdminLoginAuthSource;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity?: authInterfaces.IIdentity;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admin bootstrap status
|
||||||
|
export interface IReq_GetAdminBootstrapStatus extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetAdminBootstrapStatus
|
||||||
|
> {
|
||||||
|
method: 'getAdminBootstrapStatus';
|
||||||
|
request: {};
|
||||||
|
response: {
|
||||||
|
dbEnabled: boolean;
|
||||||
|
dbReady: boolean;
|
||||||
|
hasPersistentAdmin: boolean;
|
||||||
|
needsBootstrap: boolean;
|
||||||
|
ephemeralAdminAvailable: boolean;
|
||||||
|
idpGlobalConfigured: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the first persisted admin account. Requires the bootstrap/ephemeral admin identity.
|
||||||
|
export interface IReq_CreateInitialAdminUser extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_CreateInitialAdminUser
|
||||||
|
> {
|
||||||
|
method: 'createInitialAdminUser';
|
||||||
|
request: {
|
||||||
|
identity: authInterfaces.IIdentity;
|
||||||
|
email: string;
|
||||||
|
name?: string;
|
||||||
|
password: string;
|
||||||
|
enableIdpGlobalAuth?: boolean;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
user?: IAdminUserProjection;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Admin Logout
|
// Admin Logout
|
||||||
export interface IReq_AdminLogout extends plugins.typedrequestInterfaces.implementsTR<
|
export interface IReq_AdminLogout extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
plugins.typedrequestInterfaces.ITypedRequest,
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
@@ -43,4 +93,4 @@ export interface IReq_VerifyIdentity extends plugins.typedrequestInterfaces.impl
|
|||||||
valid: boolean;
|
valid: boolean;
|
||||||
identity?: authInterfaces.IIdentity;
|
identity?: authInterfaces.IIdentity;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import * as authInterfaces from '../data/auth.js';
|
import * as authInterfaces from '../data/auth.js';
|
||||||
|
import type { IAdminUserProjection } from './admin.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all OpsServer users (admin-only, read-only).
|
* List all OpsServer users (admin-only, read-only).
|
||||||
@@ -14,10 +15,6 @@ export interface IReq_ListUsers extends plugins.typedrequestInterfaces.implement
|
|||||||
identity: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
users: Array<{
|
users: IAdminUserProjection[];
|
||||||
id: string;
|
|
||||||
username: string;
|
|
||||||
role: string;
|
|
||||||
}>;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export interface ILoginState {
|
|||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type IAdminBootstrapStatus = interfaces.requests.IReq_GetAdminBootstrapStatus['response'];
|
||||||
|
|
||||||
export interface IStatsState {
|
export interface IStatsState {
|
||||||
serverStats: interfaces.data.IServerStats | null;
|
serverStats: interfaces.data.IServerStats | null;
|
||||||
emailStats: interfaces.data.IEmailStats | null;
|
emailStats: interfaces.data.IEmailStats | null;
|
||||||
@@ -312,7 +314,11 @@ export const routeManagementStatePart = await appState.getStatePart<IRouteManage
|
|||||||
export interface IUser {
|
export interface IUser {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
role: string;
|
role: string;
|
||||||
|
status?: 'active' | 'disabled';
|
||||||
|
authSources?: Array<'local' | 'idp.global'>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IUsersState {
|
export interface IUsersState {
|
||||||
@@ -351,6 +357,7 @@ const getActionContext = (): IActionContext => {
|
|||||||
export const loginAction = loginStatePart.createAction<{
|
export const loginAction = loginStatePart.createAction<{
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
authSource?: interfaces.requests.TAdminLoginAuthSource;
|
||||||
}>(async (statePartArg, dataArg): Promise<ILoginState> => {
|
}>(async (statePartArg, dataArg): Promise<ILoginState> => {
|
||||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
interfaces.requests.IReq_AdminLoginWithUsernameAndPassword
|
interfaces.requests.IReq_AdminLoginWithUsernameAndPassword
|
||||||
@@ -360,6 +367,7 @@ export const loginAction = loginStatePart.createAction<{
|
|||||||
const response = await typedRequest.fire({
|
const response = await typedRequest.fire({
|
||||||
username: dataArg.username,
|
username: dataArg.username,
|
||||||
password: dataArg.password,
|
password: dataArg.password,
|
||||||
|
authSource: dataArg.authSource,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.identity) {
|
if (response.identity) {
|
||||||
@@ -375,6 +383,47 @@ export const loginAction = loginStatePart.createAction<{
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export async function getAdminBootstrapStatus(): Promise<IAdminBootstrapStatus> {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_GetAdminBootstrapStatus
|
||||||
|
>('/typedrequest', 'getAdminBootstrapStatus');
|
||||||
|
|
||||||
|
return request.fire({});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createInitialAdminUser(optionsArg: {
|
||||||
|
email: string;
|
||||||
|
name?: string;
|
||||||
|
password: string;
|
||||||
|
enableIdpGlobalAuth?: boolean;
|
||||||
|
}) {
|
||||||
|
const context = getActionContext();
|
||||||
|
if (!context.identity) {
|
||||||
|
throw new Error('No identity available for admin bootstrap');
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_CreateInitialAdminUser
|
||||||
|
>('/typedrequest', 'createInitialAdminUser');
|
||||||
|
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: context.identity,
|
||||||
|
email: optionsArg.email,
|
||||||
|
name: optionsArg.name,
|
||||||
|
password: optionsArg.password,
|
||||||
|
enableIdpGlobalAuth: optionsArg.enableIdpGlobalAuth,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.identity) {
|
||||||
|
loginStatePart.setState({
|
||||||
|
identity: response.identity,
|
||||||
|
isLoggedIn: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
// Logout Action — always clears state, even if identity is expired/missing
|
// Logout Action — always clears state, even if identity is expired/missing
|
||||||
export const logoutAction = loginStatePart.createAction(async (statePartArg) => {
|
export const logoutAction = loginStatePart.createAction(async (statePartArg) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ export class OpsDashboard extends DeesElement {
|
|||||||
isLoggedIn: false,
|
isLoggedIn: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private bootstrapStepper?: any;
|
||||||
|
private bootstrapCheckPromise?: Promise<void>;
|
||||||
|
|
||||||
@state() accessor uiState: appstate.IUiState = {
|
@state() accessor uiState: appstate.IUiState = {
|
||||||
activeView: 'overview',
|
activeView: 'overview',
|
||||||
activeSubview: null,
|
activeSubview: null,
|
||||||
@@ -336,6 +339,7 @@ export class OpsDashboard extends DeesElement {
|
|||||||
await (simpleLogin as any).switchToSlottedContent();
|
await (simpleLogin as any).switchToSlottedContent();
|
||||||
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
|
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
|
||||||
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
|
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
|
||||||
|
await this.ensureAdminBootstrap();
|
||||||
} else {
|
} else {
|
||||||
// Server rejected the JWT — clear state, show login
|
// Server rejected the JWT — clear state, show login
|
||||||
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
|
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
|
||||||
@@ -370,10 +374,106 @@ export class OpsDashboard extends DeesElement {
|
|||||||
await simpleLogin!.switchToSlottedContent();
|
await simpleLogin!.switchToSlottedContent();
|
||||||
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
|
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
|
||||||
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
|
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
|
||||||
|
await this.ensureAdminBootstrap();
|
||||||
} else {
|
} else {
|
||||||
form!.setStatus('error', 'Login failed!');
|
form!.setStatus('error', 'Login failed!');
|
||||||
await domtools.convenience.smartdelay.delayFor(2000);
|
await domtools.convenience.smartdelay.delayFor(2000);
|
||||||
form!.reset();
|
form!.reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ensureAdminBootstrap(): Promise<void> {
|
||||||
|
if (!this.loginState.identity || this.bootstrapStepper?.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.bootstrapCheckPromise) {
|
||||||
|
return this.bootstrapCheckPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bootstrapCheckPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const status = await appstate.getAdminBootstrapStatus();
|
||||||
|
if (status.needsBootstrap) {
|
||||||
|
await this.showAdminBootstrapStepper(status);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Admin bootstrap status check failed:', error);
|
||||||
|
} finally {
|
||||||
|
this.bootstrapCheckPromise = undefined;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return this.bootstrapCheckPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showAdminBootstrapStepper(statusArg: appstate.IAdminBootstrapStatus): Promise<void> {
|
||||||
|
const { DeesStepper } = await import('@design.estate/dees-catalog');
|
||||||
|
this.bootstrapStepper = await DeesStepper.createAndShow({
|
||||||
|
cancelable: false,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
title: 'Create Persisted Admin',
|
||||||
|
content: html`
|
||||||
|
<div style="display: grid; gap: 16px; color: var(--dees-color-text-secondary); font-size: 14px; line-height: 1.5;">
|
||||||
|
<p style="margin: 0;">
|
||||||
|
This router is currently using the temporary bootstrap admin. Create the first persisted admin account to continue.
|
||||||
|
</p>
|
||||||
|
<dees-form>
|
||||||
|
<dees-input-text .key=${'email'} .label=${'Admin email'} .required=${true}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'name'} .label=${'Display name'}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'password'} .label=${'Password'} .required=${true} .isPasswordBool=${true}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'passwordConfirm'} .label=${'Confirm password'} .required=${true} .isPasswordBool=${true}></dees-input-text>
|
||||||
|
<dees-input-checkbox
|
||||||
|
.key=${'enableIdpGlobalAuth'}
|
||||||
|
.label=${'Allow idp.global login for this email'}
|
||||||
|
.description=${statusArg.idpGlobalConfigured
|
||||||
|
? 'The local account remains authoritative; idp.global only verifies identity.'
|
||||||
|
: 'Requires DCROUTER_IDP_GLOBAL_URL before idp.global logins can work.'}
|
||||||
|
></dees-input-checkbox>
|
||||||
|
</dees-form>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Create admin',
|
||||||
|
action: async (stepperArg: any) => {
|
||||||
|
const form = stepperArg.shadowRoot?.querySelector('.selected dees-form') as any;
|
||||||
|
if (!form) return;
|
||||||
|
const formData = await form.collectFormData();
|
||||||
|
const email = String(formData.email || '').trim();
|
||||||
|
const name = String(formData.name || '').trim();
|
||||||
|
const password = String(formData.password || '');
|
||||||
|
const passwordConfirm = String(formData.passwordConfirm || '');
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
form.setStatus?.('error', 'Email and password are required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password !== passwordConfirm) {
|
||||||
|
form.setStatus?.('error', 'Passwords do not match.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
form.setStatus?.('pending', 'Creating persisted admin...');
|
||||||
|
await appstate.createInitialAdminUser({
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
password,
|
||||||
|
enableIdpGlobalAuth: Boolean(formData.enableIdpGlobalAuth),
|
||||||
|
});
|
||||||
|
form.setStatus?.('success', 'Persisted admin created.');
|
||||||
|
await stepperArg.destroy();
|
||||||
|
this.bootstrapStepper = undefined;
|
||||||
|
await appstate.usersStatePart.dispatchAction(appstate.fetchUsersAction, null);
|
||||||
|
} catch (error) {
|
||||||
|
form.setStatus?.('error', error instanceof Error ? error.message : 'Failed to create admin.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user