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
|
||||
|
||||
### Features
|
||||
|
||||
+1
-1
@@ -38,7 +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.3.0",
|
||||
"@idp.global/sdk": "^1.3.1",
|
||||
"@push.rocks/lik": "^6.4.1",
|
||||
"@push.rocks/projectinfo": "^5.1.0",
|
||||
"@push.rocks/qenv": "^6.1.4",
|
||||
|
||||
Generated
+5
-5
@@ -30,8 +30,8 @@ importers:
|
||||
specifier: ^2.2.4
|
||||
version: 2.2.4
|
||||
'@idp.global/sdk':
|
||||
specifier: ^1.3.0
|
||||
version: 1.3.0(@push.rocks/smartserve@2.0.4)(socks@2.8.8)
|
||||
specifier: ^1.3.1
|
||||
version: 1.3.1(@push.rocks/smartserve@2.0.4)(socks@2.8.8)
|
||||
'@push.rocks/lik':
|
||||
specifier: ^6.4.1
|
||||
version: 6.4.1
|
||||
@@ -753,8 +753,8 @@ packages:
|
||||
'@idp.global/interfaces@1.0.1':
|
||||
resolution: {integrity: sha512-PEA538+V2VUnKTkLbt66OWxsRZxWHuhC1nduzeFvBaOlH7EIXIZ0rA9D20JKtUZyucZJlXj1hFp2WCMUmgqPwQ==}
|
||||
|
||||
'@idp.global/sdk@1.3.0':
|
||||
resolution: {integrity: sha512-ADxra57bBVHXrsOCrh6c82PhiJoxWZQ9LMPqkfWiQ/jspufo2WhSZIjm7G7C5SvFtGRmiYYJStKfwYBpCKO01w==}
|
||||
'@idp.global/sdk@1.3.1':
|
||||
resolution: {integrity: sha512-mZ7cRhbyaE7PoGo9WRJLNwQLIjs9mZxLK1HhVQntvf7hN+OrCRjpYfDn5/G3ub8tpTNq1trtROYNjlhbD/4GLw==}
|
||||
|
||||
'@img/colour@1.1.0':
|
||||
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||
@@ -5381,7 +5381,7 @@ snapshots:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
'@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:
|
||||
'@api.global/typedrequest': 3.3.1
|
||||
'@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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## Configuration Model
|
||||
|
||||
@@ -16,6 +16,7 @@ let testDb: DcRouterDb;
|
||||
let storagePath: string;
|
||||
let bootstrapIdentity: interfaces.data.IIdentity;
|
||||
let persistedIdentity: interfaces.data.IIdentity;
|
||||
let createdUserId: string;
|
||||
|
||||
const createStatusRequest = () => new TypedRequest<interfaces.requests.IReq_GetAdminBootstrapStatus>(
|
||||
baseUrl,
|
||||
@@ -84,6 +85,7 @@ tap.test('reports bootstrap required without auto-persisting an admin', async ()
|
||||
expect(status.hasPersistentAdmin).toEqual(false);
|
||||
expect(status.needsBootstrap).toEqual(true);
|
||||
expect(status.ephemeralAdminAvailable).toEqual(true);
|
||||
expect(status.idpGlobalConfigured).toEqual(true);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const request = new TypedRequest<interfaces.requests.IReq_ListUsers>(baseUrl, 'listUsers');
|
||||
const response = await request.fire({ identity: persistedIdentity });
|
||||
|
||||
@@ -169,7 +169,7 @@ export interface IDcRouterOptions {
|
||||
|
||||
/** Optional OpsServer account authentication settings. */
|
||||
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;
|
||||
/** Test/integration hook for injecting an idp.global-compatible password client. */
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
this.typedrouter.addTypedHandler(
|
||||
@@ -420,23 +507,17 @@ export class AdminHandler {
|
||||
}
|
||||
|
||||
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.idpClient = baseUrl
|
||||
? new plugins.idpSdkServer.IdpGlobalServerClient({ baseUrl })
|
||||
: new plugins.idpSdkServer.IdpGlobalServerClient({} as plugins.idpSdkServer.IIdpGlobalServerClientOptions);
|
||||
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
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* User data is owned by AdminHandler; this handler just exposes a safe
|
||||
* projection of it via TypedRequest.
|
||||
@@ -16,7 +16,7 @@ export class UsersHandler {
|
||||
private registerHandlers(): void {
|
||||
const router = this.opsServerRef.adminRouter;
|
||||
|
||||
// List users (admin-only, read-only)
|
||||
// List users (admin-only)
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_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
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ import * as plugins from '../plugins.js';
|
||||
import * as authInterfaces from '../data/auth.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.
|
||||
*/
|
||||
export interface IReq_ListUsers extends plugins.typedrequestInterfaces.implementsTR<
|
||||
@@ -18,3 +20,44 @@ export interface IReq_ListUsers extends plugins.typedrequestInterfaces.implement
|
||||
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> => {
|
||||
const context = getActionContext();
|
||||
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(
|
||||
name: string,
|
||||
scopes: interfaces.data.TApiTokenScope[],
|
||||
|
||||
@@ -116,12 +116,31 @@ export class OpsViewUsers extends DeesElement {
|
||||
.showColumnFilters=${true}
|
||||
.displayFunction=${(user: appstate.IUser) => ({
|
||||
ID: html`<span class="userIdCell">${user.id}</span>`,
|
||||
Username: user.username,
|
||||
Email: user.email || user.username,
|
||||
Name: user.name || '',
|
||||
Role: this.renderRoleBadge(user.role),
|
||||
Status: user.status || 'active',
|
||||
Auth: (user.authSources || []).join(', ') || 'bootstrap',
|
||||
Session: user.id === currentUserId
|
||||
? 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>
|
||||
</div>
|
||||
`;
|
||||
@@ -132,6 +151,125 @@ export class OpsViewUsers extends DeesElement {
|
||||
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() {
|
||||
if (this.loginState.isLoggedIn) {
|
||||
await appstate.usersStatePart.dispatchAction(appstate.fetchUsersAction, null);
|
||||
|
||||
@@ -271,6 +271,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
const tags = [...(mr.route.tags || [])];
|
||||
tags.push(mr.origin);
|
||||
if (!mr.enabled) tags.push('disabled');
|
||||
if (mr.route.vpnOnly) tags.push('vpn-only');
|
||||
|
||||
return {
|
||||
...mr.route,
|
||||
@@ -360,6 +361,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
<div style="color: #ccc; padding: 8px 0;">
|
||||
<p>Origin: <strong style="color: #0af;">${merged.origin}</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>
|
||||
${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>` : ''}
|
||||
@@ -491,6 +493,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
? (Array.isArray(firstTarget.host) ? firstTarget.host[0] : firstTarget.host)
|
||||
: '';
|
||||
const currentTargetPort = typeof firstTarget?.port === 'number' ? String(firstTarget.port) : '';
|
||||
const currentVpnOnly = route.vpnOnly === true;
|
||||
const currentRemoteIngressEnabled = route.remoteIngress?.enabled === true;
|
||||
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=${'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=${'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>
|
||||
<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>
|
||||
@@ -570,6 +574,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
const remoteIngressEdgeFilter: string[] = Array.isArray(formData.remoteIngressEdgeFilter)
|
||||
? formData.remoteIngressEdgeFilter.filter(Boolean)
|
||||
: [];
|
||||
const vpnOnly = Boolean(formData.vpnOnly);
|
||||
|
||||
const updatedRoute: any = {
|
||||
name: formData.name,
|
||||
@@ -586,6 +591,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
},
|
||||
],
|
||||
},
|
||||
vpnOnly: vpnOnly ? true : null,
|
||||
remoteIngress: remoteIngressEnabled
|
||||
? {
|
||||
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=${'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=${'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>
|
||||
<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>
|
||||
@@ -736,6 +743,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
const remoteIngressEdgeFilter: string[] = Array.isArray(formData.remoteIngressEdgeFilter)
|
||||
? formData.remoteIngressEdgeFilter.filter(Boolean)
|
||||
: [];
|
||||
const vpnOnly = Boolean(formData.vpnOnly);
|
||||
|
||||
const route: any = {
|
||||
name: formData.name,
|
||||
@@ -752,6 +760,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
},
|
||||
],
|
||||
},
|
||||
...(vpnOnly ? { vpnOnly: true } : {}),
|
||||
...(remoteIngressEnabled
|
||||
? {
|
||||
remoteIngress: {
|
||||
|
||||
@@ -426,9 +426,7 @@ export class OpsDashboard extends DeesElement {
|
||||
<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.'}
|
||||
.description=${'Uses https://idp.global by default; the local dcrouter account and role remain authoritative.'}
|
||||
></dees-input-checkbox>
|
||||
</dees-form>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user