feat(opsserver-admin): add persisted admin bootstrap flow with optional idp.global authentication

This commit is contained in:
2026-05-14 00:30:09 +00:00
parent 47a1f5d7db
commit 70fcd46d52
14 changed files with 733 additions and 40 deletions
+1 -1
View File
@@ -77,7 +77,7 @@
"accessLevel": "public"
},
"docker": {
"enabled": false,
"enabled": true,
"engine": "tsdocker"
}
}
+9
View File
@@ -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
- 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)
add managed gateway client administration and token-bound route ownership
+1
View File
@@ -38,6 +38,7 @@
"@apiclient.xyz/cloudflare": "^7.1.0",
"@design.estate/dees-catalog": "^3.81.0",
"@design.estate/dees-element": "^2.2.4",
"@idp.global/sdk": "^1.2.0",
"@push.rocks/lik": "^6.4.1",
"@push.rocks/projectinfo": "^5.1.0",
"@push.rocks/qenv": "^6.1.4",
+49
View File
@@ -29,6 +29,9 @@ importers:
'@design.estate/dees-element':
specifier: ^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':
specifier: ^6.4.1
version: 6.4.1
@@ -591,6 +594,9 @@ packages:
resolution: {integrity: sha512-lBW6/m5BIFl3pMuWPNN0lIOYw9LMCmPfix53ExS3FBi4E+NELEljQ3xH6aAV9IYiQRfn9YIIgzzMrD0vIcD7tw==}
engines: {node: '>=20.0.0'}
'@idp.global/sdk@1.2.0':
resolution: {integrity: sha512-L1SUh+wt9dKZ9DzX97M0wrJ080PF3sj1sEtmAOM7A67ZYs0RecCciFB3D5qspOBBVlsw+L4lPmOWv3j720lVTQ==}
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
@@ -1372,6 +1378,9 @@ packages:
'@push.rocks/taskbuffer@8.0.2':
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':
resolution: {integrity: sha512-wVSCaXqJ9Vh+rbwVz0wDl46dYz4rnwwSrm5vbVXKbuH6oKTPF0YRoujeJPqRltIn64RVGdLeY9/6ix+ZCrzhsg==}
@@ -1593,6 +1602,9 @@ packages:
'@serve.zone/interfaces@5.5.0':
resolution: {integrity: sha512-SZH4sKxBhfX+xF7zPFcHtyWdXMz7XINP5X9tqtLKPa3rJd5XkoeOFsbgDxWfeuBkCGJglvY2FI24oCPexy5acg==}
'@serve.zone/interfaces@file:../interfaces':
resolution: {directory: ../interfaces, type: directory}
'@serve.zone/remoteingress@4.17.1':
resolution: {integrity: sha512-k3n+AF1rNybiKPlHHyhwCVEF0/T7eZD46kNn7JlEJPCxfUy09mjkpwDQ2CzaUkppqNgFOAYXgAKqjDqpJ27RvA==}
@@ -5182,6 +5194,33 @@ snapshots:
- bufferutil
- 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/sharp-darwin-arm64@0.34.5':
@@ -6645,6 +6684,10 @@ snapshots:
- supports-color
- vue
'@push.rocks/webjwt@1.0.10':
dependencies:
'@push.rocks/smartstring': 4.1.1
'@push.rocks/webrequest@4.0.5':
dependencies:
'@push.rocks/smartdelay': 3.1.0
@@ -6846,6 +6889,12 @@ snapshots:
'@push.rocks/smartlog-interfaces': 3.0.2
'@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':
dependencies:
'@push.rocks/qenv': 6.1.4
+208
View File
@@ -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();
+8
View File
@@ -167,6 +167,14 @@ export interface IDcRouterOptions {
/** Port for the OpsServer web UI (default: 3000) */
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?: {
/** Enable remote ingress hub (default: false) */
enabled?: boolean;
+3
View File
@@ -113,6 +113,9 @@ export class OpsServer {
}
public async stop() {
if (this.adminHandler) {
await this.adminHandler.stop();
}
// Clean up log handler streams and push destination before stopping the server
if (this.logsHandler) {
this.logsHandler.cleanup();
+243 -31
View File
@@ -8,13 +8,23 @@ export interface IJwtData {
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 {
public typedrouter = new plugins.typedrequest.TypedRouter();
// JWT instance
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, {
id: string;
username: string;
@@ -22,6 +32,10 @@ export class AdminHandler {
role: string;
}>();
private accountStore?: plugins.idpSdkServer.SmartdataAccountStore;
private idpClient?: plugins.idpSdkServer.IdpGlobalServerClient;
private ownsIdpClient = false;
constructor(private opsServerRef: OpsServer) {
// Add this handler's router to the parent
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
@@ -33,6 +47,14 @@ export class AdminHandler {
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> {
this.smartjwtInstance = new plugins.smartjwt.SmartJwt();
await this.smartjwtInstance.init();
@@ -61,10 +83,16 @@ 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.
*/
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) => ({
id: user.id,
username: user.username,
@@ -72,43 +100,103 @@ export class AdminHandler {
}));
}
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 {
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
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'adminLoginWithUsernameAndPassword',
async (dataArg) => {
try {
// Find user by username and password
let user: { id: string; username: string; password: string; role: string } | null = null;
for (const [_, userData] of this.users) {
if (userData.username === dataArg.username && userData.password === dataArg.password) {
user = userData;
break;
}
}
const user = await this.authenticateUser({
username: dataArg.username,
password: dataArg.password,
authSource: dataArg.authSource,
});
if (!user) {
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 {
identity: {
jwt,
userId: user.id,
name: user.username,
expiresAt: expiresAtTimestamp,
role: user.role,
type: 'user',
},
identity: await this.createIdentityForUser(user),
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) {
@@ -162,8 +250,7 @@ export class AdminHandler {
};
}
// Find user
const user = this.users.get(jwtData.userId);
const user = await this.resolveUser(jwtData.userId);
if (!user) {
return {
valid: false,
@@ -175,7 +262,7 @@ export class AdminHandler {
identity: {
jwt: dataArg.identity.jwt,
userId: user.id,
name: user.username,
name: user.name || user.username,
expiresAt: jwtData.expiresAt,
role: user.role,
type: 'user',
@@ -224,6 +311,15 @@ export class AdminHandler {
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;
} catch (error) {
return false;
@@ -256,4 +352,120 @@ export class AdminHandler {
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',
};
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ export class UsersHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListUsers>(
'listUsers',
async (_dataArg) => {
const users = this.opsServerRef.adminHandler.listUsers();
const users = await this.opsServerRef.adminHandler.listUsers();
return { users };
},
),
+7
View File
@@ -41,6 +41,13 @@ export {
typedsocket,
}
// @idp.global scope
import * as idpSdkServer from '@idp.global/sdk/server';
export {
idpSdkServer,
}
// @push.rocks scope
import * as projectinfo from '@push.rocks/projectinfo';
import * as qenv from '@push.rocks/qenv';
+50
View File
@@ -1,6 +1,18 @@
import * as plugins from '../plugins.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
export interface IReq_AdminLoginWithUsernameAndPassword extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
@@ -10,12 +22,50 @@ export interface IReq_AdminLoginWithUsernameAndPassword extends plugins.typedreq
request: {
username: string;
password: string;
authSource?: TAdminLoginAuthSource;
};
response: {
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
export interface IReq_AdminLogout extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
+2 -5
View File
@@ -1,5 +1,6 @@
import * as plugins from '../plugins.js';
import * as authInterfaces from '../data/auth.js';
import type { IAdminUserProjection } from './admin.js';
/**
* List all OpsServer users (admin-only, read-only).
@@ -14,10 +15,6 @@ export interface IReq_ListUsers extends plugins.typedrequestInterfaces.implement
identity: authInterfaces.IIdentity;
};
response: {
users: Array<{
id: string;
username: string;
role: string;
}>;
users: IAdminUserProjection[];
};
}
+49
View File
@@ -10,6 +10,8 @@ export interface ILoginState {
isLoggedIn: boolean;
}
export type IAdminBootstrapStatus = interfaces.requests.IReq_GetAdminBootstrapStatus['response'];
export interface IStatsState {
serverStats: interfaces.data.IServerStats | null;
emailStats: interfaces.data.IEmailStats | null;
@@ -312,7 +314,11 @@ export const routeManagementStatePart = await appState.getStatePart<IRouteManage
export interface IUser {
id: string;
username: string;
email?: string;
name?: string;
role: string;
status?: 'active' | 'disabled';
authSources?: Array<'local' | 'idp.global'>;
}
export interface IUsersState {
@@ -351,6 +357,7 @@ const getActionContext = (): IActionContext => {
export const loginAction = loginStatePart.createAction<{
username: string;
password: string;
authSource?: interfaces.requests.TAdminLoginAuthSource;
}>(async (statePartArg, dataArg): Promise<ILoginState> => {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_AdminLoginWithUsernameAndPassword
@@ -360,6 +367,7 @@ export const loginAction = loginStatePart.createAction<{
const response = await typedRequest.fire({
username: dataArg.username,
password: dataArg.password,
authSource: dataArg.authSource,
});
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
export const logoutAction = loginStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
+100
View File
@@ -66,6 +66,9 @@ export class OpsDashboard extends DeesElement {
isLoggedIn: false,
};
private bootstrapStepper?: any;
private bootstrapCheckPromise?: Promise<void>;
@state() accessor uiState: appstate.IUiState = {
activeView: 'overview',
activeSubview: null,
@@ -336,6 +339,7 @@ export class OpsDashboard extends DeesElement {
await (simpleLogin as any).switchToSlottedContent();
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
await this.ensureAdminBootstrap();
} else {
// Server rejected the JWT — clear state, show login
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
@@ -370,10 +374,106 @@ export class OpsDashboard extends DeesElement {
await simpleLogin!.switchToSlottedContent();
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
await this.ensureAdminBootstrap();
} else {
form!.setStatus('error', 'Login failed!');
await domtools.convenience.smartdelay.delayFor(2000);
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.');
}
},
},
],
},
],
});
}
}