feat(opsserver-access): add admin user listing to the access dashboard
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# 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)
|
## 2026-04-08 - 13.4.2 - fix(repo)
|
||||||
no changes to commit
|
no changes to commit
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '13.4.2',
|
version: '13.5.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export class OpsServer {
|
|||||||
private sourceProfileHandler!: handlers.SourceProfileHandler;
|
private sourceProfileHandler!: handlers.SourceProfileHandler;
|
||||||
private targetProfileHandler!: handlers.TargetProfileHandler;
|
private targetProfileHandler!: handlers.TargetProfileHandler;
|
||||||
private networkTargetHandler!: handlers.NetworkTargetHandler;
|
private networkTargetHandler!: handlers.NetworkTargetHandler;
|
||||||
|
private usersHandler!: handlers.UsersHandler;
|
||||||
|
|
||||||
constructor(dcRouterRefArg: DcRouter) {
|
constructor(dcRouterRefArg: DcRouter) {
|
||||||
this.dcRouterRef = dcRouterRefArg;
|
this.dcRouterRef = dcRouterRefArg;
|
||||||
@@ -94,6 +95,7 @@ export class OpsServer {
|
|||||||
this.sourceProfileHandler = new handlers.SourceProfileHandler(this);
|
this.sourceProfileHandler = new handlers.SourceProfileHandler(this);
|
||||||
this.targetProfileHandler = new handlers.TargetProfileHandler(this);
|
this.targetProfileHandler = new handlers.TargetProfileHandler(this);
|
||||||
this.networkTargetHandler = new handlers.NetworkTargetHandler(this);
|
this.networkTargetHandler = new handlers.NetworkTargetHandler(this);
|
||||||
|
this.usersHandler = new handlers.UsersHandler(this);
|
||||||
|
|
||||||
console.log('✅ OpsServer TypedRequest handlers initialized');
|
console.log('✅ OpsServer TypedRequest handlers initialized');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,18 @@ export class AdminHandler {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
private registerHandlers(): void {
|
||||||
// Admin Login Handler
|
// Admin Login Handler
|
||||||
this.typedrouter.addTypedHandler(
|
this.typedrouter.addTypedHandler(
|
||||||
|
|||||||
@@ -13,3 +13,4 @@ export * from './vpn.handler.js';
|
|||||||
export * from './source-profile.handler.js';
|
export * from './source-profile.handler.js';
|
||||||
export * from './target-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';
|
||||||
30
ts/opsserver/handlers/users.handler.ts
Normal file
30
ts/opsserver/handlers/users.handler.ts
Normal 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 };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,3 +13,4 @@ export * from './vpn.js';
|
|||||||
export * from './source-profiles.js';
|
export * from './source-profiles.js';
|
||||||
export * from './target-profiles.js';
|
export * from './target-profiles.js';
|
||||||
export * from './network-targets.js';
|
export * from './network-targets.js';
|
||||||
|
export * from './users.js';
|
||||||
23
ts_interfaces/requests/users.ts
Normal file
23
ts_interfaces/requests/users.ts
Normal 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;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '13.4.2',
|
version: '13.5.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -251,6 +251,34 @@ export const routeManagementStatePart = await appState.getStatePart<IRouteManage
|
|||||||
'soft'
|
'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
|
// Actions for state management
|
||||||
interface IActionContext {
|
interface IActionContext {
|
||||||
identity: interfaces.data.IIdentity | null;
|
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) {
|
export async function createApiToken(name: string, scopes: interfaces.data.TApiTokenScope[], expiresInDays?: number | null) {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from './ops-view-apitokens.js';
|
export * from './ops-view-apitokens.js';
|
||||||
|
export * from './ops-view-users.js';
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export class OpsViewApiTokens extends DeesElement {
|
|||||||
const { apiTokens } = this.routeState;
|
const { apiTokens } = this.routeState;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="2">API Tokens</dees-heading>
|
<dees-heading level="hr">API Tokens</dees-heading>
|
||||||
|
|
||||||
<div class="apiTokensContainer">
|
<div class="apiTokensContainer">
|
||||||
<dees-table
|
<dees-table
|
||||||
|
|||||||
140
ts_web/elements/access/ops-view-users.ts
Normal file
140
ts_web/elements/access/ops-view-users.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,7 +60,7 @@ export class OpsViewEmails extends DeesElement {
|
|||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="2">Email Operations</dees-heading>
|
<dees-heading level="hr">Email Log</dees-heading>
|
||||||
<div class="viewContainer">
|
<div class="viewContainer">
|
||||||
${this.currentView === 'detail' && this.selectedEmail
|
${this.currentView === 'detail' && this.selectedEmail
|
||||||
? html`
|
? html`
|
||||||
|
|||||||
@@ -347,6 +347,7 @@ export class OpsViewNetworkActivity extends DeesElement {
|
|||||||
heading1="Recent Network Activity"
|
heading1="Recent Network Activity"
|
||||||
heading2="Recent network requests"
|
heading2="Recent network requests"
|
||||||
searchable
|
searchable
|
||||||
|
.showColumnFilters=${true}
|
||||||
.pagination=${true}
|
.pagination=${true}
|
||||||
.paginationSize=${50}
|
.paginationSize=${50}
|
||||||
dataName="request"
|
dataName="request"
|
||||||
@@ -606,6 +607,8 @@ export class OpsViewNetworkActivity extends DeesElement {
|
|||||||
}}
|
}}
|
||||||
heading1="Top Connected IPs"
|
heading1="Top Connected IPs"
|
||||||
heading2="IPs with most active connections and bandwidth"
|
heading2="IPs with most active connections and bandwidth"
|
||||||
|
searchable
|
||||||
|
.showColumnFilters=${true}
|
||||||
.pagination=${false}
|
.pagination=${false}
|
||||||
dataName="ip"
|
dataName="ip"
|
||||||
></dees-table>
|
></dees-table>
|
||||||
@@ -656,6 +659,7 @@ export class OpsViewNetworkActivity extends DeesElement {
|
|||||||
heading1="Backend Protocols"
|
heading1="Backend Protocols"
|
||||||
heading2="Auto-detected backend protocols and connection pool health"
|
heading2="Auto-detected backend protocols and connection pool health"
|
||||||
searchable
|
searchable
|
||||||
|
.showColumnFilters=${true}
|
||||||
.pagination=${false}
|
.pagination=${false}
|
||||||
dataName="backend"
|
dataName="backend"
|
||||||
></dees-table>
|
></dees-table>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { OpsViewEmailSecurity } from './email/ops-view-email-security.js';
|
|||||||
|
|
||||||
// Access group
|
// Access group
|
||||||
import { OpsViewApiTokens } from './access/ops-view-apitokens.js';
|
import { OpsViewApiTokens } from './access/ops-view-apitokens.js';
|
||||||
|
import { OpsViewUsers } from './access/ops-view-users.js';
|
||||||
|
|
||||||
// Security group
|
// Security group
|
||||||
import { OpsViewSecurityOverview } from './security/ops-view-security-overview.js';
|
import { OpsViewSecurityOverview } from './security/ops-view-security-overview.js';
|
||||||
@@ -114,6 +115,7 @@ export class OpsDashboard extends DeesElement {
|
|||||||
iconName: 'lucide:keyRound',
|
iconName: 'lucide:keyRound',
|
||||||
subViews: [
|
subViews: [
|
||||||
{ slug: 'apitokens', name: 'API Tokens', iconName: 'lucide:key', element: OpsViewApiTokens },
|
{ slug: 'apitokens', name: 'API Tokens', iconName: 'lucide:key', element: OpsViewApiTokens },
|
||||||
|
{ slug: 'users', name: 'Users', iconName: 'lucide:users', element: OpsViewUsers },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export class OpsViewCertificates extends DeesElement {
|
|||||||
const { summary } = this.certState;
|
const { summary } = this.certState;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="2">Certificates</dees-heading>
|
<dees-heading level="hr">Certificates</dees-heading>
|
||||||
|
|
||||||
<div class="certificatesContainer">
|
<div class="certificatesContainer">
|
||||||
${this.renderStatsTiles(summary)}
|
${this.renderStatsTiles(summary)}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export class OpsViewLogs extends DeesElement {
|
|||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="2">Logs</dees-heading>
|
<dees-heading level="hr">Logs</dees-heading>
|
||||||
|
|
||||||
<dees-chart-log
|
<dees-chart-log
|
||||||
.label=${'Application Logs'}
|
.label=${'Application Logs'}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export class OpsViewConfig extends DeesElement {
|
|||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="2">Configuration</dees-heading>
|
<dees-heading level="hr">Configuration</dees-heading>
|
||||||
|
|
||||||
${this.configState.isLoading
|
${this.configState.isLoading
|
||||||
? html`
|
? html`
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export class OpsViewOverview extends DeesElement {
|
|||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="2">Overview</dees-heading>
|
<dees-heading level="hr">Stats</dees-heading>
|
||||||
|
|
||||||
${this.statsState.isLoading ? html`
|
${this.statsState.isLoading ? html`
|
||||||
<div class="loadingMessage">
|
<div class="loadingMessage">
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const subviewMap: Record<string, readonly string[]> = {
|
|||||||
overview: ['stats', 'configuration'] as const,
|
overview: ['stats', 'configuration'] as const,
|
||||||
network: ['activity', 'routes', 'sourceprofiles', 'networktargets', 'targetprofiles', 'remoteingress', 'vpn'] as const,
|
network: ['activity', 'routes', 'sourceprofiles', 'networktargets', 'targetprofiles', 'remoteingress', 'vpn'] as const,
|
||||||
email: ['log', 'security'] as const,
|
email: ['log', 'security'] as const,
|
||||||
access: ['apitokens'] as const,
|
access: ['apitokens', 'users'] as const,
|
||||||
security: ['overview', 'blocked', 'authentication'] as const,
|
security: ['overview', 'blocked', 'authentication'] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user