diff --git a/changelog.md b/changelog.md index 822969d..5bdc69d 100644 --- a/changelog.md +++ b/changelog.md @@ -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 diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 2eb30d1..5ad42f6 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -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.' } diff --git a/ts/opsserver/classes.opsserver.ts b/ts/opsserver/classes.opsserver.ts index ae1b822..6e10d8a 100644 --- a/ts/opsserver/classes.opsserver.ts +++ b/ts/opsserver/classes.opsserver.ts @@ -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'); } diff --git a/ts/opsserver/handlers/admin.handler.ts b/ts/opsserver/handlers/admin.handler.ts index 598371f..87ff6e8 100644 --- a/ts/opsserver/handlers/admin.handler.ts +++ b/ts/opsserver/handlers/admin.handler.ts @@ -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 diff --git a/ts/opsserver/handlers/index.ts b/ts/opsserver/handlers/index.ts index d064ef1..416b9f1 100644 --- a/ts/opsserver/handlers/index.ts +++ b/ts/opsserver/handlers/index.ts @@ -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'; \ No newline at end of file +export * from './network-target.handler.js'; +export * from './users.handler.js'; \ No newline at end of file diff --git a/ts/opsserver/handlers/users.handler.ts b/ts/opsserver/handlers/users.handler.ts new file mode 100644 index 0000000..8bc6081 --- /dev/null +++ b/ts/opsserver/handlers/users.handler.ts @@ -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( + 'listUsers', + async (_dataArg) => { + const users = this.opsServerRef.adminHandler.listUsers(); + return { users }; + }, + ), + ); + } +} diff --git a/ts_interfaces/requests/index.ts b/ts_interfaces/requests/index.ts index a30df98..c8affa2 100644 --- a/ts_interfaces/requests/index.ts +++ b/ts_interfaces/requests/index.ts @@ -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'; \ No newline at end of file +export * from './network-targets.js'; +export * from './users.js'; \ No newline at end of file diff --git a/ts_interfaces/requests/users.ts b/ts_interfaces/requests/users.ts new file mode 100644 index 0000000..288fbf1 --- /dev/null +++ b/ts_interfaces/requests/users.ts @@ -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; + }>; + }; +} diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 2eb30d1..5ad42f6 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -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.' } diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index 3d06881..49917a5 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -251,6 +251,34 @@ export const routeManagementStatePart = await appState.getStatePart( + '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 => { + 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< diff --git a/ts_web/elements/access/index.ts b/ts_web/elements/access/index.ts index cc46c36..c79e48a 100644 --- a/ts_web/elements/access/index.ts +++ b/ts_web/elements/access/index.ts @@ -1 +1,2 @@ export * from './ops-view-apitokens.js'; +export * from './ops-view-users.js'; diff --git a/ts_web/elements/access/ops-view-apitokens.ts b/ts_web/elements/access/ops-view-apitokens.ts index 1c308c0..24d2339 100644 --- a/ts_web/elements/access/ops-view-apitokens.ts +++ b/ts_web/elements/access/ops-view-apitokens.ts @@ -100,7 +100,7 @@ export class OpsViewApiTokens extends DeesElement { const { apiTokens } = this.routeState; return html` - API Tokens + API Tokens
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` + Users + +
+ ({ + ID: html`${user.id}`, + Username: user.username, + Role: this.renderRoleBadge(user.role), + Session: user.id === currentUserId + ? html`current` + : '', + })} + > +
+ `; + } + + private renderRoleBadge(role: string): TemplateResult { + const cls = role === 'admin' ? 'admin' : 'user'; + return html`${role}`; + } + + async firstUpdated() { + if (this.loginState.isLoggedIn) { + await appstate.usersStatePart.dispatchAction(appstate.fetchUsersAction, null); + } + } +} diff --git a/ts_web/elements/email/ops-view-emails.ts b/ts_web/elements/email/ops-view-emails.ts index a05dc65..02af9d1 100644 --- a/ts_web/elements/email/ops-view-emails.ts +++ b/ts_web/elements/email/ops-view-emails.ts @@ -60,7 +60,7 @@ export class OpsViewEmails extends DeesElement { public render() { return html` - Email Operations + Email Log
${this.currentView === 'detail' && this.selectedEmail ? html` diff --git a/ts_web/elements/network/ops-view-network-activity.ts b/ts_web/elements/network/ops-view-network-activity.ts index 6de5755..e4adaa1 100644 --- a/ts_web/elements/network/ops-view-network-activity.ts +++ b/ts_web/elements/network/ops-view-network-activity.ts @@ -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" > @@ -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" > diff --git a/ts_web/elements/ops-dashboard.ts b/ts_web/elements/ops-dashboard.ts index 2660378..9d4acc1 100644 --- a/ts_web/elements/ops-dashboard.ts +++ b/ts_web/elements/ops-dashboard.ts @@ -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 }, ], }, { diff --git a/ts_web/elements/ops-view-certificates.ts b/ts_web/elements/ops-view-certificates.ts index 80e401b..3b6264e 100644 --- a/ts_web/elements/ops-view-certificates.ts +++ b/ts_web/elements/ops-view-certificates.ts @@ -159,7 +159,7 @@ export class OpsViewCertificates extends DeesElement { const { summary } = this.certState; return html` - Certificates + Certificates
${this.renderStatsTiles(summary)} diff --git a/ts_web/elements/ops-view-logs.ts b/ts_web/elements/ops-view-logs.ts index 2586efa..7458467 100644 --- a/ts_web/elements/ops-view-logs.ts +++ b/ts_web/elements/ops-view-logs.ts @@ -39,7 +39,7 @@ export class OpsViewLogs extends DeesElement { public render() { return html` - Logs + Logs Configuration + Configuration ${this.configState.isLoading ? html` diff --git a/ts_web/elements/overview/ops-view-overview.ts b/ts_web/elements/overview/ops-view-overview.ts index 213467c..a9cad8e 100644 --- a/ts_web/elements/overview/ops-view-overview.ts +++ b/ts_web/elements/overview/ops-view-overview.ts @@ -94,7 +94,7 @@ export class OpsViewOverview extends DeesElement { public render() { return html` - Overview + Stats ${this.statsState.isLoading ? html`
diff --git a/ts_web/router.ts b/ts_web/router.ts index c2ec5b8..d2b076f 100644 --- a/ts_web/router.ts +++ b/ts_web/router.ts @@ -11,7 +11,7 @@ const subviewMap: Record = { 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, };