feat(opsserver): add admin user create/delete management and default hosted idp.global auth support
This commit is contained in:
+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