Compare commits

..

2 Commits

Author SHA1 Message Date
e77fe9451e v13.5.0
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 09:01:08 +00:00
7971bd249e feat(opsserver-access): add admin user listing to the access dashboard 2026-04-08 09:01:08 +00:00
22 changed files with 292 additions and 12 deletions

View File

@@ -1,5 +1,12 @@
# Changelog
## 2026-04-08 - 13.5.0 - feat(opsserver-access)
add admin user listing to the access dashboard
- register a new admin-only typed request endpoint to list users with id, username, and role while excluding passwords
- add users state management and a dedicated access dashboard view for browsing OpsServer user accounts
- update access routing to include the new users subview and improve related table filtering and section headings
## 2026-04-08 - 13.4.2 - fix(repo)
no changes to commit

View File

@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "13.4.2",
"version": "13.5.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"exports": {

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.4.2',
version: '13.5.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}

View File

@@ -32,6 +32,7 @@ export class OpsServer {
private sourceProfileHandler!: handlers.SourceProfileHandler;
private targetProfileHandler!: handlers.TargetProfileHandler;
private networkTargetHandler!: handlers.NetworkTargetHandler;
private usersHandler!: handlers.UsersHandler;
constructor(dcRouterRefArg: DcRouter) {
this.dcRouterRef = dcRouterRefArg;
@@ -94,6 +95,7 @@ export class OpsServer {
this.sourceProfileHandler = new handlers.SourceProfileHandler(this);
this.targetProfileHandler = new handlers.TargetProfileHandler(this);
this.networkTargetHandler = new handlers.NetworkTargetHandler(this);
this.usersHandler = new handlers.UsersHandler(this);
console.log('✅ OpsServer TypedRequest handlers initialized');
}

View File

@@ -52,6 +52,18 @@ export class AdminHandler {
role: 'admin',
});
}
/**
* Return a safe projection of the users Map — excludes password fields.
* Used by UsersHandler to serve the admin-only listUsers endpoint.
*/
public listUsers(): Array<{ id: string; username: string; role: string }> {
return Array.from(this.users.values()).map((user) => ({
id: user.id,
username: user.username,
role: user.role,
}));
}
private registerHandlers(): void {
// Admin Login Handler

View File

@@ -12,4 +12,5 @@ export * from './api-token.handler.js';
export * from './vpn.handler.js';
export * from './source-profile.handler.js';
export * from './target-profile.handler.js';
export * from './network-target.handler.js';
export * from './network-target.handler.js';
export * from './users.handler.js';

View File

@@ -0,0 +1,30 @@
import * as plugins from '../../plugins.js';
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,
* 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.
*/
export class UsersHandler {
constructor(private opsServerRef: OpsServer) {
this.registerHandlers();
}
private registerHandlers(): void {
const router = this.opsServerRef.adminRouter;
// List users (admin-only, read-only)
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListUsers>(
'listUsers',
async (_dataArg) => {
const users = this.opsServerRef.adminHandler.listUsers();
return { users };
},
),
);
}
}

View File

@@ -12,4 +12,5 @@ export * from './api-tokens.js';
export * from './vpn.js';
export * from './source-profiles.js';
export * from './target-profiles.js';
export * from './network-targets.js';
export * from './network-targets.js';
export * from './users.js';

View File

@@ -0,0 +1,23 @@
import * as plugins from '../plugins.js';
import * as authInterfaces from '../data/auth.js';
/**
* List all OpsServer users (admin-only, read-only).
* Deliberately omits password/secret fields from the response.
*/
export interface IReq_ListUsers extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_ListUsers
> {
method: 'listUsers';
request: {
identity: authInterfaces.IIdentity;
};
response: {
users: Array<{
id: string;
username: string;
role: string;
}>;
};
}

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.4.2',
version: '13.5.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}

View File

@@ -251,6 +251,34 @@ export const routeManagementStatePart = await appState.getStatePart<IRouteManage
'soft'
);
// ============================================================================
// Users State (read-only list of OpsServer user accounts)
// ============================================================================
export interface IUser {
id: string;
username: string;
role: string;
}
export interface IUsersState {
users: IUser[];
isLoading: boolean;
error: string | null;
lastUpdated: number;
}
export const usersStatePart = await appState.getStatePart<IUsersState>(
'users',
{
users: [],
isLoading: false,
error: null,
lastUpdated: 0,
},
'soft',
);
// Actions for state management
interface IActionContext {
identity: interfaces.data.IIdentity | null;
@@ -1756,6 +1784,35 @@ export const fetchApiTokensAction = routeManagementStatePart.createAction(async
}
});
// Users (read-only list)
export const fetchUsersAction = usersStatePart.createAction(async (statePartArg): 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_ListUsers
>('/typedrequest', 'listUsers');
const response = await request.fire({
identity: context.identity,
});
return {
...currentState,
users: response.users,
error: null,
lastUpdated: Date.now(),
};
} catch (error) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to fetch users',
};
}
});
export async function createApiToken(name: string, scopes: interfaces.data.TApiTokenScope[], expiresInDays?: number | null) {
const context = getActionContext();
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<

View File

@@ -1 +1,2 @@
export * from './ops-view-apitokens.js';
export * from './ops-view-users.js';

View File

@@ -100,7 +100,7 @@ export class OpsViewApiTokens extends DeesElement {
const { apiTokens } = this.routeState;
return html`
<dees-heading level="2">API Tokens</dees-heading>
<dees-heading level="hr">API Tokens</dees-heading>
<div class="apiTokensContainer">
<dees-table

View File

@@ -0,0 +1,140 @@
import * as appstate from '../../appstate.js';
import { viewHostCss } from '../shared/css.js';
import {
DeesElement,
css,
cssManager,
customElement,
html,
state,
type TemplateResult,
} from '@design.estate/dees-element';
@customElement('ops-view-users')
export class OpsViewUsers extends DeesElement {
@state() accessor usersState: appstate.IUsersState = {
users: [],
isLoading: false,
error: null,
lastUpdated: 0,
};
@state() accessor loginState: appstate.ILoginState = {
identity: null,
isLoggedIn: false,
};
constructor() {
super();
const usersSub = appstate.usersStatePart
.select((s) => s)
.subscribe((usersState) => {
this.usersState = usersState;
});
this.rxSubscriptions.push(usersSub);
const loginSub = appstate.loginStatePart
.select((s) => s)
.subscribe((loginState) => {
this.loginState = loginState;
// Re-fetch users when user logs in (fixes race condition where
// the view is created before authentication completes)
if (loginState.isLoggedIn) {
appstate.usersStatePart.dispatchAction(appstate.fetchUsersAction, null);
}
});
this.rxSubscriptions.push(loginSub);
}
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
.usersContainer {
display: flex;
flex-direction: column;
gap: 24px;
}
.roleBadge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.roleBadge.admin {
background: ${cssManager.bdTheme('#fef3c7', '#451a03')};
color: ${cssManager.bdTheme('#92400e', '#fbbf24')};
}
.roleBadge.user {
background: ${cssManager.bdTheme('#e0f2fe', '#0c4a6e')};
color: ${cssManager.bdTheme('#075985', '#7dd3fc')};
}
.sessionBadge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
background: ${cssManager.bdTheme('#dcfce7', '#14532d')};
color: ${cssManager.bdTheme('#166534', '#4ade80')};
}
.userIdCell {
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace;
font-size: 11px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
`,
];
public render(): TemplateResult {
const { users } = this.usersState;
const currentUserId = this.loginState.identity?.userId;
return html`
<dees-heading level="2">Users</dees-heading>
<div class="usersContainer">
<dees-table
.heading1=${'Users'}
.heading2=${'OpsServer user accounts'}
.data=${users}
.dataName=${'user'}
.searchable=${true}
.showColumnFilters=${true}
.displayFunction=${(user: appstate.IUser) => ({
ID: html`<span class="userIdCell">${user.id}</span>`,
Username: user.username,
Role: this.renderRoleBadge(user.role),
Session: user.id === currentUserId
? html`<span class="sessionBadge">current</span>`
: '',
})}
></dees-table>
</div>
`;
}
private renderRoleBadge(role: string): TemplateResult {
const cls = role === 'admin' ? 'admin' : 'user';
return html`<span class="roleBadge ${cls}">${role}</span>`;
}
async firstUpdated() {
if (this.loginState.isLoggedIn) {
await appstate.usersStatePart.dispatchAction(appstate.fetchUsersAction, null);
}
}
}

View File

@@ -60,7 +60,7 @@ export class OpsViewEmails extends DeesElement {
public render() {
return html`
<dees-heading level="2">Email Operations</dees-heading>
<dees-heading level="hr">Email Log</dees-heading>
<div class="viewContainer">
${this.currentView === 'detail' && this.selectedEmail
? html`

View File

@@ -347,6 +347,7 @@ export class OpsViewNetworkActivity extends DeesElement {
heading1="Recent Network Activity"
heading2="Recent network requests"
searchable
.showColumnFilters=${true}
.pagination=${true}
.paginationSize=${50}
dataName="request"
@@ -606,6 +607,8 @@ export class OpsViewNetworkActivity extends DeesElement {
}}
heading1="Top Connected IPs"
heading2="IPs with most active connections and bandwidth"
searchable
.showColumnFilters=${true}
.pagination=${false}
dataName="ip"
></dees-table>
@@ -656,6 +659,7 @@ export class OpsViewNetworkActivity extends DeesElement {
heading1="Backend Protocols"
heading2="Auto-detected backend protocols and connection pool health"
searchable
.showColumnFilters=${true}
.pagination=${false}
dataName="backend"
></dees-table>

View File

@@ -36,6 +36,7 @@ import { OpsViewEmailSecurity } from './email/ops-view-email-security.js';
// Access group
import { OpsViewApiTokens } from './access/ops-view-apitokens.js';
import { OpsViewUsers } from './access/ops-view-users.js';
// Security group
import { OpsViewSecurityOverview } from './security/ops-view-security-overview.js';
@@ -114,6 +115,7 @@ export class OpsDashboard extends DeesElement {
iconName: 'lucide:keyRound',
subViews: [
{ slug: 'apitokens', name: 'API Tokens', iconName: 'lucide:key', element: OpsViewApiTokens },
{ slug: 'users', name: 'Users', iconName: 'lucide:users', element: OpsViewUsers },
],
},
{

View File

@@ -159,7 +159,7 @@ export class OpsViewCertificates extends DeesElement {
const { summary } = this.certState;
return html`
<dees-heading level="2">Certificates</dees-heading>
<dees-heading level="hr">Certificates</dees-heading>
<div class="certificatesContainer">
${this.renderStatsTiles(summary)}

View File

@@ -39,7 +39,7 @@ export class OpsViewLogs extends DeesElement {
public render() {
return html`
<dees-heading level="2">Logs</dees-heading>
<dees-heading level="hr">Logs</dees-heading>
<dees-chart-log
.label=${'Application Logs'}

View File

@@ -57,7 +57,7 @@ export class OpsViewConfig extends DeesElement {
public render() {
return html`
<dees-heading level="2">Configuration</dees-heading>
<dees-heading level="hr">Configuration</dees-heading>
${this.configState.isLoading
? html`

View File

@@ -94,7 +94,7 @@ export class OpsViewOverview extends DeesElement {
public render() {
return html`
<dees-heading level="2">Overview</dees-heading>
<dees-heading level="hr">Stats</dees-heading>
${this.statsState.isLoading ? html`
<div class="loadingMessage">

View File

@@ -11,7 +11,7 @@ const subviewMap: Record<string, readonly string[]> = {
overview: ['stats', 'configuration'] as const,
network: ['activity', 'routes', 'sourceprofiles', 'networktargets', 'targetprofiles', 'remoteingress', 'vpn'] as const,
email: ['log', 'security'] as const,
access: ['apitokens'] as const,
access: ['apitokens', 'users'] as const,
security: ['overview', 'blocked', 'authentication'] as const,
};