feat(opsserver): add admin user create/delete management and default hosted idp.global auth support
This commit is contained in:
@@ -5,6 +5,14 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- add admin user create/delete management and default hosted idp.global auth support (opsserver)
|
||||||
|
- adds admin-only createUser and deleteUser typed requests with safeguards against deleting the current user or last active admin
|
||||||
|
- updates the ops users UI to create and delete users, show richer account details, and support optional idp.global login during account creation
|
||||||
|
- treats idp.global as available by default via the hosted https://idp.global endpoint while keeping URL settings as optional overrides
|
||||||
|
- adds VPN-only route controls and indicators in the ops routes UI
|
||||||
|
|
||||||
## 2026-05-18 - 13.30.0
|
## 2026-05-18 - 13.30.0
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
+1
-1
@@ -38,7 +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.3.0",
|
"@idp.global/sdk": "^1.3.1",
|
||||||
"@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
+5
-5
@@ -30,8 +30,8 @@ importers:
|
|||||||
specifier: ^2.2.4
|
specifier: ^2.2.4
|
||||||
version: 2.2.4
|
version: 2.2.4
|
||||||
'@idp.global/sdk':
|
'@idp.global/sdk':
|
||||||
specifier: ^1.3.0
|
specifier: ^1.3.1
|
||||||
version: 1.3.0(@push.rocks/smartserve@2.0.4)(socks@2.8.8)
|
version: 1.3.1(@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
|
||||||
@@ -753,8 +753,8 @@ packages:
|
|||||||
'@idp.global/interfaces@1.0.1':
|
'@idp.global/interfaces@1.0.1':
|
||||||
resolution: {integrity: sha512-PEA538+V2VUnKTkLbt66OWxsRZxWHuhC1nduzeFvBaOlH7EIXIZ0rA9D20JKtUZyucZJlXj1hFp2WCMUmgqPwQ==}
|
resolution: {integrity: sha512-PEA538+V2VUnKTkLbt66OWxsRZxWHuhC1nduzeFvBaOlH7EIXIZ0rA9D20JKtUZyucZJlXj1hFp2WCMUmgqPwQ==}
|
||||||
|
|
||||||
'@idp.global/sdk@1.3.0':
|
'@idp.global/sdk@1.3.1':
|
||||||
resolution: {integrity: sha512-ADxra57bBVHXrsOCrh6c82PhiJoxWZQ9LMPqkfWiQ/jspufo2WhSZIjm7G7C5SvFtGRmiYYJStKfwYBpCKO01w==}
|
resolution: {integrity: sha512-mZ7cRhbyaE7PoGo9WRJLNwQLIjs9mZxLK1HhVQntvf7hN+OrCRjpYfDn5/G3ub8tpTNq1trtROYNjlhbD/4GLw==}
|
||||||
|
|
||||||
'@img/colour@1.1.0':
|
'@img/colour@1.1.0':
|
||||||
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||||
@@ -5381,7 +5381,7 @@ snapshots:
|
|||||||
'@api.global/typedrequest-interfaces': 3.0.19
|
'@api.global/typedrequest-interfaces': 3.0.19
|
||||||
'@tsclass/tsclass': 9.5.1
|
'@tsclass/tsclass': 9.5.1
|
||||||
|
|
||||||
'@idp.global/sdk@1.3.0(@push.rocks/smartserve@2.0.4)(socks@2.8.8)':
|
'@idp.global/sdk@1.3.1(@push.rocks/smartserve@2.0.4)(socks@2.8.8)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@api.global/typedrequest': 3.3.1
|
'@api.global/typedrequest': 3.3.1
|
||||||
'@api.global/typedsocket': 4.1.3(@push.rocks/smartserve@2.0.4)
|
'@api.global/typedsocket': 4.1.3(@push.rocks/smartserve@2.0.4)
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ Bootstrap behavior:
|
|||||||
- `getAdminBootstrapStatus` reports whether persistence is ready and whether a first admin is required.
|
- `getAdminBootstrapStatus` reports whether persistence is ready and whether a first admin is required.
|
||||||
- The temporary env/config admin identity is only used to authorize bootstrap access while no persisted admin exists.
|
- The temporary env/config admin identity is only used to authorize bootstrap access while no persisted admin exists.
|
||||||
- `createInitialAdminUser` creates the first persisted admin with normalized email and local password authentication.
|
- `createInitialAdminUser` creates the first persisted admin with normalized email and local password authentication.
|
||||||
- Optional `idp.global` authentication can be enabled for that local account; the local dcrouter role remains authoritative and the IdP email must match the local account email.
|
- Optional `idp.global` authentication can be enabled for that local account. The hosted `https://idp.global` endpoint is used by default, `adminAuth.idpGlobalUrl` or `DCROUTER_IDP_GLOBAL_URL` only override it, and the local dcrouter role remains authoritative.
|
||||||
- After a persisted admin exists, temporary bootstrap admin login is rejected and normal persisted-account authentication is used.
|
- After a persisted admin exists, temporary bootstrap admin login is rejected and normal persisted-account authentication is used.
|
||||||
|
|
||||||
## Configuration Model
|
## Configuration Model
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ let testDb: DcRouterDb;
|
|||||||
let storagePath: string;
|
let storagePath: string;
|
||||||
let bootstrapIdentity: interfaces.data.IIdentity;
|
let bootstrapIdentity: interfaces.data.IIdentity;
|
||||||
let persistedIdentity: interfaces.data.IIdentity;
|
let persistedIdentity: interfaces.data.IIdentity;
|
||||||
|
let createdUserId: string;
|
||||||
|
|
||||||
const createStatusRequest = () => new TypedRequest<interfaces.requests.IReq_GetAdminBootstrapStatus>(
|
const createStatusRequest = () => new TypedRequest<interfaces.requests.IReq_GetAdminBootstrapStatus>(
|
||||||
baseUrl,
|
baseUrl,
|
||||||
@@ -84,6 +85,7 @@ tap.test('reports bootstrap required without auto-persisting an admin', async ()
|
|||||||
expect(status.hasPersistentAdmin).toEqual(false);
|
expect(status.hasPersistentAdmin).toEqual(false);
|
||||||
expect(status.needsBootstrap).toEqual(true);
|
expect(status.needsBootstrap).toEqual(true);
|
||||||
expect(status.ephemeralAdminAvailable).toEqual(true);
|
expect(status.ephemeralAdminAvailable).toEqual(true);
|
||||||
|
expect(status.idpGlobalConfigured).toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('allows temporary bootstrap admin login before persisted admin exists', async () => {
|
tap.test('allows temporary bootstrap admin login before persisted admin exists', async () => {
|
||||||
@@ -183,6 +185,45 @@ tap.test('rejects idp.global login when IdP email does not match local account',
|
|||||||
expect(rejected).toEqual(true);
|
expect(rejected).toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('creates a persisted non-admin user explicitly', async () => {
|
||||||
|
const request = new TypedRequest<interfaces.requests.IReq_CreateUser>(baseUrl, 'createUser');
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: persistedIdentity,
|
||||||
|
email: 'operator@example.com',
|
||||||
|
name: 'Operator User',
|
||||||
|
role: 'user',
|
||||||
|
password: 'operator-password',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.success).toEqual(true);
|
||||||
|
expect(response.user?.role).toEqual('user');
|
||||||
|
expect(response.user?.email).toEqual('operator@example.com');
|
||||||
|
if (!response.user?.id) {
|
||||||
|
throw new Error('Expected created user id');
|
||||||
|
}
|
||||||
|
createdUserId = response.user.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('rejects deleting the current persisted admin user', async () => {
|
||||||
|
const request = new TypedRequest<interfaces.requests.IReq_DeleteUser>(baseUrl, 'deleteUser');
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: persistedIdentity,
|
||||||
|
id: persistedIdentity.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.success).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('deletes a persisted non-current user', async () => {
|
||||||
|
const request = new TypedRequest<interfaces.requests.IReq_DeleteUser>(baseUrl, 'deleteUser');
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: persistedIdentity,
|
||||||
|
id: createdUserId,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.success).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
tap.test('lists persisted users without password material', async () => {
|
tap.test('lists persisted users without password material', async () => {
|
||||||
const request = new TypedRequest<interfaces.requests.IReq_ListUsers>(baseUrl, 'listUsers');
|
const request = new TypedRequest<interfaces.requests.IReq_ListUsers>(baseUrl, 'listUsers');
|
||||||
const response = await request.fire({ identity: persistedIdentity });
|
const response = await request.fire({ identity: persistedIdentity });
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export interface IDcRouterOptions {
|
|||||||
|
|
||||||
/** Optional OpsServer account authentication settings. */
|
/** Optional OpsServer account authentication settings. */
|
||||||
adminAuth?: {
|
adminAuth?: {
|
||||||
/** Base URL for idp.global password authentication. Can also be set through DCROUTER_IDP_GLOBAL_URL. */
|
/** Optional idp.global password-authentication URL override. Defaults to the SDK's hosted https://idp.global endpoint. Can also be set through DCROUTER_IDP_GLOBAL_URL. */
|
||||||
idpGlobalUrl?: string;
|
idpGlobalUrl?: string;
|
||||||
/** Test/integration hook for injecting an idp.global-compatible password client. */
|
/** Test/integration hook for injecting an idp.global-compatible password client. */
|
||||||
idpClient?: Pick<plugins.idpSdkServer.IdpGlobalServerClient, 'loginWithEmailAndPassword' | 'stop'>;
|
idpClient?: Pick<plugins.idpSdkServer.IdpGlobalServerClient, 'loginWithEmailAndPassword' | 'stop'>;
|
||||||
|
|||||||
@@ -159,6 +159,93 @@ export class AdminHandler {
|
|||||||
throw new plugins.typedrequest.TypedResponseError((error as Error).message || 'failed to create initial admin');
|
throw new plugins.typedrequest.TypedResponseError((error as Error).message || 'failed to create initial admin');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async createUser(optionsArg: {
|
||||||
|
email: string;
|
||||||
|
name?: string;
|
||||||
|
role: interfaces.requests.TUserManagementRole;
|
||||||
|
password: string;
|
||||||
|
enableIdpGlobalAuth?: boolean;
|
||||||
|
}): Promise<interfaces.requests.IReq_CreateUser['response']> {
|
||||||
|
const store = this.getAccountStore();
|
||||||
|
if (!store) {
|
||||||
|
return { success: false, message: 'database is not ready' };
|
||||||
|
}
|
||||||
|
if (!(await store.hasActiveAdminAccount())) {
|
||||||
|
return { success: false, message: 'initial admin bootstrap is required before creating users' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = optionsArg.role;
|
||||||
|
if (role !== 'admin' && role !== 'user') {
|
||||||
|
return { success: false, message: 'role must be admin or user' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = String(optionsArg.password || '');
|
||||||
|
if (!password) {
|
||||||
|
return { success: false, message: 'password is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const authSources: Array<'local' | 'idp.global'> = ['local'];
|
||||||
|
if (optionsArg.enableIdpGlobalAuth) {
|
||||||
|
authSources.push('idp.global');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const email = String(optionsArg.email || '').trim();
|
||||||
|
const account = await store.createAccount({
|
||||||
|
email,
|
||||||
|
name: String(optionsArg.name || '').trim() || email,
|
||||||
|
role,
|
||||||
|
authSources,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
return { success: true, user: this.accountToUser(account) };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, message: (error as Error).message || 'failed to create user' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteUser(optionsArg: {
|
||||||
|
id: string;
|
||||||
|
requestingUserId: string;
|
||||||
|
}): Promise<interfaces.requests.IReq_DeleteUser['response']> {
|
||||||
|
const store = this.getAccountStore();
|
||||||
|
if (!store) {
|
||||||
|
return { success: false, message: 'database is not ready' };
|
||||||
|
}
|
||||||
|
if (!(await store.hasActiveAdminAccount())) {
|
||||||
|
return { success: false, message: 'initial admin bootstrap is required before deleting users' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = String(optionsArg.id || '').trim();
|
||||||
|
if (!id) {
|
||||||
|
return { success: false, message: 'user id is required' };
|
||||||
|
}
|
||||||
|
if (id === optionsArg.requestingUserId) {
|
||||||
|
return { success: false, message: 'cannot delete the current user' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await store.getAccountById(id);
|
||||||
|
if (!account) {
|
||||||
|
return { success: false, message: 'user not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account.role === 'admin' && account.status === 'active') {
|
||||||
|
const activeAdmins = (await store.listAccounts()).filter(
|
||||||
|
(accountArg) => accountArg.role === 'admin' && accountArg.status === 'active',
|
||||||
|
);
|
||||||
|
if (activeAdmins.length <= 1) {
|
||||||
|
return { success: false, message: 'cannot delete the last active admin' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = await plugins.idpSdkServer.IdpSdkAccountDoc.findById(id);
|
||||||
|
if (!doc) {
|
||||||
|
return { success: false, message: 'user not found' };
|
||||||
|
}
|
||||||
|
await doc.delete();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
private registerHandlers(): void {
|
private registerHandlers(): void {
|
||||||
this.typedrouter.addTypedHandler(
|
this.typedrouter.addTypedHandler(
|
||||||
@@ -420,23 +507,17 @@ export class AdminHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = this.opsServerRef.dcRouterRef.options.adminAuth?.idpGlobalUrl || process.env.DCROUTER_IDP_GLOBAL_URL;
|
const baseUrl = this.opsServerRef.dcRouterRef.options.adminAuth?.idpGlobalUrl || process.env.DCROUTER_IDP_GLOBAL_URL;
|
||||||
if (!baseUrl) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.idpClient) {
|
if (!this.idpClient) {
|
||||||
this.idpClient = new plugins.idpSdkServer.IdpGlobalServerClient({ baseUrl });
|
this.idpClient = baseUrl
|
||||||
|
? new plugins.idpSdkServer.IdpGlobalServerClient({ baseUrl })
|
||||||
|
: new plugins.idpSdkServer.IdpGlobalServerClient({} as plugins.idpSdkServer.IIdpGlobalServerClientOptions);
|
||||||
this.ownsIdpClient = true;
|
this.ownsIdpClient = true;
|
||||||
}
|
}
|
||||||
return this.idpClient;
|
return this.idpClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
private isIdpGlobalConfigured(): boolean {
|
private isIdpGlobalConfigured(): boolean {
|
||||||
return !!(
|
return true;
|
||||||
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 {
|
private accountToUser(accountArg: plugins.idpSdkServer.IIdpSdkAccount): TAdminUser {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { OpsServer } from '../classes.opsserver.js';
|
|||||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read-only handler for OpsServer user accounts. Registers on adminRouter,
|
* Handler for OpsServer user accounts. Registers on adminRouter,
|
||||||
* so admin middleware enforces auth + role check before the handler runs.
|
* so admin middleware enforces auth + role check before the handler runs.
|
||||||
* User data is owned by AdminHandler; this handler just exposes a safe
|
* User data is owned by AdminHandler; this handler just exposes a safe
|
||||||
* projection of it via TypedRequest.
|
* projection of it via TypedRequest.
|
||||||
@@ -16,7 +16,7 @@ export class UsersHandler {
|
|||||||
private registerHandlers(): void {
|
private registerHandlers(): void {
|
||||||
const router = this.opsServerRef.adminRouter;
|
const router = this.opsServerRef.adminRouter;
|
||||||
|
|
||||||
// List users (admin-only, read-only)
|
// List users (admin-only)
|
||||||
router.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListUsers>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListUsers>(
|
||||||
'listUsers',
|
'listUsers',
|
||||||
@@ -26,5 +26,28 @@ export class UsersHandler {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateUser>(
|
||||||
|
'createUser',
|
||||||
|
async (dataArg) => this.opsServerRef.adminHandler.createUser({
|
||||||
|
email: dataArg.email,
|
||||||
|
name: dataArg.name,
|
||||||
|
role: dataArg.role,
|
||||||
|
password: dataArg.password,
|
||||||
|
enableIdpGlobalAuth: dataArg.enableIdpGlobalAuth,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteUser>(
|
||||||
|
'deleteUser',
|
||||||
|
async (dataArg) => this.opsServerRef.adminHandler.deleteUser({
|
||||||
|
id: dataArg.id,
|
||||||
|
requestingUserId: dataArg.identity.userId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ for (const route of response.routes) {
|
|||||||
|
|
||||||
## Bootstrap Contracts
|
## Bootstrap Contracts
|
||||||
|
|
||||||
The auth request group includes `getAdminBootstrapStatus` and `createInitialAdminUser`. These exist so a fresh DB-backed dcrouter can require explicit first-admin creation instead of auto-persisting a default account. `createInitialAdminUser` requires the temporary bootstrap identity and can optionally enable `idp.global` authentication for the same normalized local email.
|
The auth request group includes `getAdminBootstrapStatus` and `createInitialAdminUser`. These exist so a fresh DB-backed dcrouter can require explicit first-admin creation instead of auto-persisting a default account. `createInitialAdminUser` requires the temporary bootstrap identity and can optionally enable `idp.global` authentication for the same normalized local email. The SDK defaults to hosted `https://idp.global`; dcrouter URL settings are overrides only.
|
||||||
|
|
||||||
## When To Use It
|
## When To Use It
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ 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';
|
import type { IAdminUserProjection } from './admin.js';
|
||||||
|
|
||||||
|
export type TUserManagementRole = 'admin' | 'user';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all OpsServer users (admin-only, read-only).
|
* List all OpsServer users (admin-only).
|
||||||
* Deliberately omits password/secret fields from the response.
|
* Deliberately omits password/secret fields from the response.
|
||||||
*/
|
*/
|
||||||
export interface IReq_ListUsers extends plugins.typedrequestInterfaces.implementsTR<
|
export interface IReq_ListUsers extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
@@ -18,3 +20,44 @@ export interface IReq_ListUsers extends plugins.typedrequestInterfaces.implement
|
|||||||
users: IAdminUserProjection[];
|
users: IAdminUserProjection[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a persisted OpsServer user account (admin-only).
|
||||||
|
*/
|
||||||
|
export interface IReq_CreateUser extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_CreateUser
|
||||||
|
> {
|
||||||
|
method: 'createUser';
|
||||||
|
request: {
|
||||||
|
identity: authInterfaces.IIdentity;
|
||||||
|
email: string;
|
||||||
|
name?: string;
|
||||||
|
role: TUserManagementRole;
|
||||||
|
password: string;
|
||||||
|
enableIdpGlobalAuth?: boolean;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
user?: IAdminUserProjection;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a persisted OpsServer user account (admin-only).
|
||||||
|
*/
|
||||||
|
export interface IReq_DeleteUser extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_DeleteUser
|
||||||
|
> {
|
||||||
|
method: 'deleteUser';
|
||||||
|
request: {
|
||||||
|
identity: authInterfaces.IIdentity;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
+69
-1
@@ -2637,7 +2637,7 @@ export async function createGatewayClientToken(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Users (read-only list)
|
// Users
|
||||||
export const fetchUsersAction = usersStatePart.createAction(async (statePartArg): Promise<IUsersState> => {
|
export const fetchUsersAction = usersStatePart.createAction(async (statePartArg): Promise<IUsersState> => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState()!;
|
const currentState = statePartArg.getState()!;
|
||||||
@@ -2666,6 +2666,74 @@ export const fetchUsersAction = usersStatePart.createAction(async (statePartArg)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const createUserAction = usersStatePart.createAction<{
|
||||||
|
email: string;
|
||||||
|
name?: string;
|
||||||
|
role: interfaces.requests.TUserManagementRole;
|
||||||
|
password: string;
|
||||||
|
enableIdpGlobalAuth?: boolean;
|
||||||
|
}>(async (statePartArg, dataArg, actionContext): Promise<IUsersState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const currentState = statePartArg.getState()!;
|
||||||
|
if (!context.identity) return currentState;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_CreateUser
|
||||||
|
>('/typedrequest', 'createUser');
|
||||||
|
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: context.identity,
|
||||||
|
email: dataArg.email,
|
||||||
|
name: dataArg.name,
|
||||||
|
role: dataArg.role,
|
||||||
|
password: dataArg.password,
|
||||||
|
enableIdpGlobalAuth: dataArg.enableIdpGlobalAuth,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(response.message || 'Failed to create user');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await actionContext!.dispatch(fetchUsersAction, null);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to create user',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteUserAction = usersStatePart.createAction<string>(
|
||||||
|
async (statePartArg, userIdArg, actionContext): Promise<IUsersState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const currentState = statePartArg.getState()!;
|
||||||
|
if (!context.identity) return currentState;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_DeleteUser
|
||||||
|
>('/typedrequest', 'deleteUser');
|
||||||
|
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: context.identity,
|
||||||
|
id: userIdArg,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(response.message || 'Failed to delete user');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await actionContext!.dispatch(fetchUsersAction, null);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to delete user',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export async function createApiToken(
|
export async function createApiToken(
|
||||||
name: string,
|
name: string,
|
||||||
scopes: interfaces.data.TApiTokenScope[],
|
scopes: interfaces.data.TApiTokenScope[],
|
||||||
|
|||||||
@@ -116,12 +116,31 @@ export class OpsViewUsers extends DeesElement {
|
|||||||
.showColumnFilters=${true}
|
.showColumnFilters=${true}
|
||||||
.displayFunction=${(user: appstate.IUser) => ({
|
.displayFunction=${(user: appstate.IUser) => ({
|
||||||
ID: html`<span class="userIdCell">${user.id}</span>`,
|
ID: html`<span class="userIdCell">${user.id}</span>`,
|
||||||
Username: user.username,
|
Email: user.email || user.username,
|
||||||
|
Name: user.name || '',
|
||||||
Role: this.renderRoleBadge(user.role),
|
Role: this.renderRoleBadge(user.role),
|
||||||
|
Status: user.status || 'active',
|
||||||
|
Auth: (user.authSources || []).join(', ') || 'bootstrap',
|
||||||
Session: user.id === currentUserId
|
Session: user.id === currentUserId
|
||||||
? html`<span class="sessionBadge">current</span>`
|
? html`<span class="sessionBadge">current</span>`
|
||||||
: '',
|
: '',
|
||||||
})}
|
})}
|
||||||
|
.dataActions=${[
|
||||||
|
{
|
||||||
|
name: 'Create User',
|
||||||
|
iconName: 'lucide:userPlus',
|
||||||
|
type: ['header'],
|
||||||
|
actionFunc: async () => await this.showCreateUserDialog(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Delete',
|
||||||
|
iconName: 'lucide:trash2',
|
||||||
|
type: ['inRow', 'contextmenu'] as any,
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
await this.showDeleteUserDialog(actionData.item as appstate.IUser);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
></dees-table>
|
></dees-table>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -132,6 +151,125 @@ export class OpsViewUsers extends DeesElement {
|
|||||||
return html`<span class="roleBadge ${cls}">${role}</span>`;
|
return html`<span class="roleBadge ${cls}">${role}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async showCreateUserDialog(): Promise<void> {
|
||||||
|
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
|
||||||
|
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading: 'Create User',
|
||||||
|
content: html`
|
||||||
|
<dees-form>
|
||||||
|
<dees-input-text .key=${'email'} .label=${'Email'} .required=${true}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'name'} .label=${'Display name'}></dees-input-text>
|
||||||
|
<dees-input-dropdown
|
||||||
|
.key=${'role'}
|
||||||
|
.label=${'Role'}
|
||||||
|
.options=${[
|
||||||
|
{ option: 'User', key: 'user' },
|
||||||
|
{ option: 'Admin', key: 'admin' },
|
||||||
|
]}
|
||||||
|
.selectedOption=${{ option: 'User', key: 'user' }}
|
||||||
|
.required=${true}
|
||||||
|
></dees-input-dropdown>
|
||||||
|
<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=${'Uses https://idp.global by default; the local dcrouter account and role remain authoritative.'}
|
||||||
|
></dees-input-checkbox>
|
||||||
|
</dees-form>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Cancel',
|
||||||
|
iconName: 'lucide:x',
|
||||||
|
action: async (modalArg: any) => await modalArg.destroy(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Create',
|
||||||
|
iconName: 'lucide:userPlus',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form') as any;
|
||||||
|
if (!form) return;
|
||||||
|
const data = await form.collectFormData();
|
||||||
|
const email = String(data.email || '').trim();
|
||||||
|
const name = String(data.name || '').trim();
|
||||||
|
const password = String(data.password || '');
|
||||||
|
const passwordConfirm = String(data.passwordConfirm || '');
|
||||||
|
const roleValue = String(data.role?.key ?? data.role ?? 'user');
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
form.setStatus?.('error', 'Email and password are required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password !== passwordConfirm) {
|
||||||
|
form.setStatus?.('error', 'Passwords do not match.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.setStatus?.('pending', 'Creating user...');
|
||||||
|
await appstate.usersStatePart.dispatchAction(appstate.createUserAction, {
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
role: roleValue === 'admin' ? 'admin' : 'user',
|
||||||
|
password,
|
||||||
|
enableIdpGlobalAuth: Boolean(data.enableIdpGlobalAuth),
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = appstate.usersStatePart.getState();
|
||||||
|
if (state?.error) {
|
||||||
|
form.setStatus?.('error', state.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DeesToast.show({ message: `User created for ${email}`, type: 'success', duration: 3000 });
|
||||||
|
await modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showDeleteUserDialog(userArg: appstate.IUser): Promise<void> {
|
||||||
|
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
|
||||||
|
const currentUserId = this.loginState.identity?.userId;
|
||||||
|
if (userArg.id === currentUserId) {
|
||||||
|
DeesToast.show({ message: 'You cannot delete the current user.', type: 'error', duration: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading: 'Delete User',
|
||||||
|
content: html`
|
||||||
|
<div style="padding: 8px 0; font-size: 14px; line-height: 1.5;">
|
||||||
|
<p>Delete <strong>${userArg.email || userArg.username}</strong>?</p>
|
||||||
|
<p style="color: #f59e0b; margin-top: 12px;">This removes the local dcrouter account and cannot be undone.</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Cancel',
|
||||||
|
iconName: 'lucide:x',
|
||||||
|
action: async (modalArg: any) => await modalArg.destroy(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Delete',
|
||||||
|
iconName: 'lucide:trash2',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
await appstate.usersStatePart.dispatchAction(appstate.deleteUserAction, userArg.id);
|
||||||
|
const state = appstate.usersStatePart.getState();
|
||||||
|
if (state?.error) {
|
||||||
|
DeesToast.show({ message: state.error, type: 'error', duration: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DeesToast.show({ message: 'User deleted.', type: 'success', duration: 3000 });
|
||||||
|
await modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async firstUpdated() {
|
async firstUpdated() {
|
||||||
if (this.loginState.isLoggedIn) {
|
if (this.loginState.isLoggedIn) {
|
||||||
await appstate.usersStatePart.dispatchAction(appstate.fetchUsersAction, null);
|
await appstate.usersStatePart.dispatchAction(appstate.fetchUsersAction, null);
|
||||||
|
|||||||
@@ -271,6 +271,7 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
const tags = [...(mr.route.tags || [])];
|
const tags = [...(mr.route.tags || [])];
|
||||||
tags.push(mr.origin);
|
tags.push(mr.origin);
|
||||||
if (!mr.enabled) tags.push('disabled');
|
if (!mr.enabled) tags.push('disabled');
|
||||||
|
if (mr.route.vpnOnly) tags.push('vpn-only');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...mr.route,
|
...mr.route,
|
||||||
@@ -360,6 +361,7 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
<div style="color: #ccc; padding: 8px 0;">
|
<div style="color: #ccc; padding: 8px 0;">
|
||||||
<p>Origin: <strong style="color: #0af;">${merged.origin}</strong></p>
|
<p>Origin: <strong style="color: #0af;">${merged.origin}</strong></p>
|
||||||
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled'}</strong></p>
|
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled'}</strong></p>
|
||||||
|
${merged.route.vpnOnly ? html`<p>Access: <strong style="color: #22c55e;">VPN only</strong></p>` : ''}
|
||||||
<p>ID: <code style="color: #888;">${merged.id}</code></p>
|
<p>ID: <code style="color: #888;">${merged.id}</code></p>
|
||||||
${isSystemManaged ? html`<p>This route is system-managed. Change its source config to modify it directly.</p>` : ''}
|
${isSystemManaged ? html`<p>This route is system-managed. Change its source config to modify it directly.</p>` : ''}
|
||||||
${meta?.sourceProfileName ? html`<p>Source Profile: <strong style="color: #a78bfa;">${meta.sourceProfileName}</strong></p>` : ''}
|
${meta?.sourceProfileName ? html`<p>Source Profile: <strong style="color: #a78bfa;">${meta.sourceProfileName}</strong></p>` : ''}
|
||||||
@@ -491,6 +493,7 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
? (Array.isArray(firstTarget.host) ? firstTarget.host[0] : firstTarget.host)
|
? (Array.isArray(firstTarget.host) ? firstTarget.host[0] : firstTarget.host)
|
||||||
: '';
|
: '';
|
||||||
const currentTargetPort = typeof firstTarget?.port === 'number' ? String(firstTarget.port) : '';
|
const currentTargetPort = typeof firstTarget?.port === 'number' ? String(firstTarget.port) : '';
|
||||||
|
const currentVpnOnly = route.vpnOnly === true;
|
||||||
const currentRemoteIngressEnabled = route.remoteIngress?.enabled === true;
|
const currentRemoteIngressEnabled = route.remoteIngress?.enabled === true;
|
||||||
const currentEdgeFilter = route.remoteIngress?.edgeFilter || [];
|
const currentEdgeFilter = route.remoteIngress?.edgeFilter || [];
|
||||||
|
|
||||||
@@ -518,6 +521,7 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${currentTargetHost}></dees-input-text>
|
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${currentTargetHost}></dees-input-text>
|
||||||
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'} .value=${currentTargetPort}></dees-input-text>
|
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'} .value=${currentTargetPort}></dees-input-text>
|
||||||
<dees-input-checkbox .key=${'preserveMatchPort'} .label=${'Preserve incoming port'} .value=${currentPreserveMatchPort}></dees-input-checkbox>
|
<dees-input-checkbox .key=${'preserveMatchPort'} .label=${'Preserve incoming port'} .value=${currentPreserveMatchPort}></dees-input-checkbox>
|
||||||
|
<dees-input-checkbox .key=${'vpnOnly'} .label=${'VPN only'} .description=${'Only VPN clients with matching target profiles can access this route'} .value=${currentVpnOnly}></dees-input-checkbox>
|
||||||
<dees-input-checkbox .key=${'remoteIngressEnabled'} .label=${'Enable Remote Ingress'} .value=${currentRemoteIngressEnabled}></dees-input-checkbox>
|
<dees-input-checkbox .key=${'remoteIngressEnabled'} .label=${'Enable Remote Ingress'} .value=${currentRemoteIngressEnabled}></dees-input-checkbox>
|
||||||
<div class="remoteIngressGroup" style="display: ${currentRemoteIngressEnabled ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
|
<div class="remoteIngressGroup" style="display: ${currentRemoteIngressEnabled ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
|
||||||
<dees-input-list .key=${'remoteIngressEdgeFilter'} .label=${'Edge Filter'} .description=${'Optional edge IDs or tags. Leave empty to allow all edges.'} .placeholder=${'Add edge ID or tag...'} .value=${currentEdgeFilter}></dees-input-list>
|
<dees-input-list .key=${'remoteIngressEdgeFilter'} .label=${'Edge Filter'} .description=${'Optional edge IDs or tags. Leave empty to allow all edges.'} .placeholder=${'Add edge ID or tag...'} .value=${currentEdgeFilter}></dees-input-list>
|
||||||
@@ -570,6 +574,7 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
const remoteIngressEdgeFilter: string[] = Array.isArray(formData.remoteIngressEdgeFilter)
|
const remoteIngressEdgeFilter: string[] = Array.isArray(formData.remoteIngressEdgeFilter)
|
||||||
? formData.remoteIngressEdgeFilter.filter(Boolean)
|
? formData.remoteIngressEdgeFilter.filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
|
const vpnOnly = Boolean(formData.vpnOnly);
|
||||||
|
|
||||||
const updatedRoute: any = {
|
const updatedRoute: any = {
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
@@ -586,6 +591,7 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
vpnOnly: vpnOnly ? true : null,
|
||||||
remoteIngress: remoteIngressEnabled
|
remoteIngress: remoteIngressEnabled
|
||||||
? {
|
? {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -684,6 +690,7 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${'localhost'}></dees-input-text>
|
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${'localhost'}></dees-input-text>
|
||||||
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'}></dees-input-text>
|
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'}></dees-input-text>
|
||||||
<dees-input-checkbox .key=${'preserveMatchPort'} .label=${'Preserve incoming port'} .value=${false}></dees-input-checkbox>
|
<dees-input-checkbox .key=${'preserveMatchPort'} .label=${'Preserve incoming port'} .value=${false}></dees-input-checkbox>
|
||||||
|
<dees-input-checkbox .key=${'vpnOnly'} .label=${'VPN only'} .description=${'Only VPN clients with matching target profiles can access this route'} .value=${false}></dees-input-checkbox>
|
||||||
<dees-input-checkbox .key=${'remoteIngressEnabled'} .label=${'Enable Remote Ingress'} .value=${false}></dees-input-checkbox>
|
<dees-input-checkbox .key=${'remoteIngressEnabled'} .label=${'Enable Remote Ingress'} .value=${false}></dees-input-checkbox>
|
||||||
<div class="remoteIngressGroup" style="display: none; flex-direction: column; gap: 16px;">
|
<div class="remoteIngressGroup" style="display: none; flex-direction: column; gap: 16px;">
|
||||||
<dees-input-list .key=${'remoteIngressEdgeFilter'} .label=${'Edge Filter'} .description=${'Optional edge IDs or tags. Leave empty to allow all edges.'} .placeholder=${'Add edge ID or tag...'}></dees-input-list>
|
<dees-input-list .key=${'remoteIngressEdgeFilter'} .label=${'Edge Filter'} .description=${'Optional edge IDs or tags. Leave empty to allow all edges.'} .placeholder=${'Add edge ID or tag...'}></dees-input-list>
|
||||||
@@ -736,6 +743,7 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
const remoteIngressEdgeFilter: string[] = Array.isArray(formData.remoteIngressEdgeFilter)
|
const remoteIngressEdgeFilter: string[] = Array.isArray(formData.remoteIngressEdgeFilter)
|
||||||
? formData.remoteIngressEdgeFilter.filter(Boolean)
|
? formData.remoteIngressEdgeFilter.filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
|
const vpnOnly = Boolean(formData.vpnOnly);
|
||||||
|
|
||||||
const route: any = {
|
const route: any = {
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
@@ -752,6 +760,7 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
...(vpnOnly ? { vpnOnly: true } : {}),
|
||||||
...(remoteIngressEnabled
|
...(remoteIngressEnabled
|
||||||
? {
|
? {
|
||||||
remoteIngress: {
|
remoteIngress: {
|
||||||
|
|||||||
@@ -426,9 +426,7 @@ export class OpsDashboard extends DeesElement {
|
|||||||
<dees-input-checkbox
|
<dees-input-checkbox
|
||||||
.key=${'enableIdpGlobalAuth'}
|
.key=${'enableIdpGlobalAuth'}
|
||||||
.label=${'Allow idp.global login for this email'}
|
.label=${'Allow idp.global login for this email'}
|
||||||
.description=${statusArg.idpGlobalConfigured
|
.description=${'Uses https://idp.global by default; the local dcrouter account and role remain authoritative.'}
|
||||||
? '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-input-checkbox>
|
||||||
</dees-form>
|
</dees-form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user