diff --git a/.smartconfig.json b/.smartconfig.json index c5f0795..9b60d74 100644 --- a/.smartconfig.json +++ b/.smartconfig.json @@ -77,7 +77,7 @@ "accessLevel": "public" }, "docker": { - "enabled": false, + "enabled": true, "engine": "tsdocker" } } diff --git a/changelog.md b/changelog.md index 1760e50..432bec9 100644 --- a/changelog.md +++ b/changelog.md @@ -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 diff --git a/package.json b/package.json index 9b81149..f2b1d8b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe2ebbb..8a740ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/test/test.admin-bootstrap.node.ts b/test/test.admin-bootstrap.node.ts new file mode 100644 index 0000000..13b9c99 --- /dev/null +++ b/test/test.admin-bootstrap.node.ts @@ -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( + baseUrl, + 'getAdminBootstrapStatus', +); + +const createLoginRequest = () => new TypedRequest( + 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( + 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( + 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(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(); diff --git a/ts/classes.dcrouter.ts b/ts/classes.dcrouter.ts index ceefaf1..1525574 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -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; + }; + remoteIngressConfig?: { /** Enable remote ingress hub (default: false) */ enabled?: boolean; diff --git a/ts/opsserver/classes.opsserver.ts b/ts/opsserver/classes.opsserver.ts index fceff3a..d413915 100644 --- a/ts/opsserver/classes.opsserver.ts +++ b/ts/opsserver/classes.opsserver.ts @@ -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(); diff --git a/ts/opsserver/handlers/admin.handler.ts b/ts/opsserver/handlers/admin.handler.ts index fe63afc..f8db92b 100644 --- a/ts/opsserver/handlers/admin.handler.ts +++ b/ts/opsserver/handlers/admin.handler.ts @@ -8,19 +8,33 @@ 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; - // 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(); + + 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 @@ -32,6 +46,14 @@ export class AdminHandler { this.initializeDefaultUsers(); this.registerHandlers(); } + + public async stop(): Promise { + if (this.ownsIdpClient) { + await this.idpClient?.stop(); + } + this.idpClient = undefined; + this.ownsIdpClient = false; + } private async initializeJwt(): Promise { 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. */ - public listUsers(): Array<{ id: string; username: string; role: string }> { + public async listUsers(): Promise { + 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, role: user.role, })); } + + public async getBootstrapStatus(): Promise { + 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 { + 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( + 'getAdminBootstrapStatus', + async (_dataArg) => this.getBootstrapStatus() + ) + ); + + this.opsServerRef.adminRouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + '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( '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 { + 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 { + 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 { + 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 | 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 { + 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', + }; + } } diff --git a/ts/opsserver/handlers/users.handler.ts b/ts/opsserver/handlers/users.handler.ts index 8bc6081..66c0106 100644 --- a/ts/opsserver/handlers/users.handler.ts +++ b/ts/opsserver/handlers/users.handler.ts @@ -21,7 +21,7 @@ export class UsersHandler { new plugins.typedrequest.TypedHandler( 'listUsers', async (_dataArg) => { - const users = this.opsServerRef.adminHandler.listUsers(); + const users = await this.opsServerRef.adminHandler.listUsers(); return { users }; }, ), diff --git a/ts/plugins.ts b/ts/plugins.ts index bf5cb50..4f2b918 100644 --- a/ts/plugins.ts +++ b/ts/plugins.ts @@ -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'; diff --git a/ts_interfaces/requests/admin.ts b/ts_interfaces/requests/admin.ts index 0876b84..c7ac29c 100644 --- a/ts_interfaces/requests/admin.ts +++ b/ts_interfaces/requests/admin.ts @@ -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, @@ -43,4 +93,4 @@ export interface IReq_VerifyIdentity extends plugins.typedrequestInterfaces.impl valid: boolean; identity?: authInterfaces.IIdentity; }; -} \ No newline at end of file +} diff --git a/ts_interfaces/requests/users.ts b/ts_interfaces/requests/users.ts index 288fbf1..413bfcc 100644 --- a/ts_interfaces/requests/users.ts +++ b/ts_interfaces/requests/users.ts @@ -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[]; }; } diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index 341007b..6c02695 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -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; } 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 => { 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 { + 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(); diff --git a/ts_web/elements/ops-dashboard.ts b/ts_web/elements/ops-dashboard.ts index 703bdfe..4db3806 100644 --- a/ts_web/elements/ops-dashboard.ts +++ b/ts_web/elements/ops-dashboard.ts @@ -66,6 +66,9 @@ export class OpsDashboard extends DeesElement { isLoggedIn: false, }; + private bootstrapStepper?: any; + private bootstrapCheckPromise?: Promise; + @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 { + 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 { + const { DeesStepper } = await import('@design.estate/dees-catalog'); + this.bootstrapStepper = await DeesStepper.createAndShow({ + cancelable: false, + steps: [ + { + title: 'Create Persisted Admin', + content: html` +
+

+ This router is currently using the temporary bootstrap admin. Create the first persisted admin account to continue. +

+ + + + + + + +
+ `, + 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.'); + } + }, + }, + ], + }, + ], + }); + } }