feat(opsserver): add admin user create/delete management and default hosted idp.global auth support

This commit is contained in:
2026-05-19 17:06:50 +00:00
parent 0b01a4c26b
commit 7986d01245
14 changed files with 436 additions and 27 deletions
+8
View File
@@ -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
View File
@@ -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",
+5 -5
View File
@@ -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)
+1 -1
View File
@@ -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
+41
View File
@@ -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 });
+1 -1
View File
@@ -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'>;
+91 -10
View File
@@ -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 {
+25 -2
View File
@@ -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,
}),
),
);
} }
} }
+1 -1
View File
@@ -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
+44 -1
View File
@@ -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
View File
@@ -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[],
+139 -1
View File
@@ -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: {
+1 -3
View File
@@ -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>