feat: role-based UI access control
- Hide Admin sidebar section for non-ADMIN users - Add RequireAdmin route guard — /admin/* redirects to / for non-admin - Move App Config from admin section to main Config tab (per-app, visible when app selected). VIEWER sees read-only, OPERATOR+ can edit - Hide diagram node toolbar for VIEWER (onNodeAction conditional) - Add useIsAdmin/useCanControl helpers to centralize role checks - Remove App Config from admin sidebar tree Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
8
ui/src/auth/RequireAdmin.tsx
Normal file
8
ui/src/auth/RequireAdmin.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Navigate, Outlet } from 'react-router';
|
||||||
|
import { useIsAdmin } from './auth-store';
|
||||||
|
|
||||||
|
export function RequireAdmin() {
|
||||||
|
const isAdmin = useIsAdmin();
|
||||||
|
if (!isAdmin) return <Navigate to="/" replace />;
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
@@ -164,3 +164,6 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const useIsAdmin = () => useAuthStore((s) => s.roles.some(r => r === 'ADMIN'));
|
||||||
|
export const useCanControl = () => useAuthStore((s) => s.roles.some(r => r === 'OPERATOR' || r === 'ADMIN'));
|
||||||
|
|||||||
@@ -3,13 +3,18 @@ import type { TabKey, Scope } from '../hooks/useScope';
|
|||||||
import { TabKpis } from './TabKpis';
|
import { TabKpis } from './TabKpis';
|
||||||
import styles from './ContentTabs.module.css';
|
import styles from './ContentTabs.module.css';
|
||||||
|
|
||||||
const TABS = [
|
const BASE_TABS = [
|
||||||
{ label: 'Exchanges', value: 'exchanges' },
|
{ label: 'Exchanges', value: 'exchanges' },
|
||||||
{ label: 'Dashboard', value: 'dashboard' },
|
{ label: 'Dashboard', value: 'dashboard' },
|
||||||
{ label: 'Runtime', value: 'runtime' },
|
{ label: 'Runtime', value: 'runtime' },
|
||||||
{ label: 'Logs', value: 'logs' },
|
{ label: 'Logs', value: 'logs' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const TABS_WITH_CONFIG = [
|
||||||
|
...BASE_TABS,
|
||||||
|
{ label: 'Config', value: 'config' },
|
||||||
|
];
|
||||||
|
|
||||||
interface ContentTabsProps {
|
interface ContentTabsProps {
|
||||||
active: TabKey;
|
active: TabKey;
|
||||||
onChange: (tab: TabKey) => void;
|
onChange: (tab: TabKey) => void;
|
||||||
@@ -17,10 +22,12 @@ interface ContentTabsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ContentTabs({ active, onChange, scope }: ContentTabsProps) {
|
export function ContentTabs({ active, onChange, scope }: ContentTabsProps) {
|
||||||
|
// Config tab only shown when an app is selected
|
||||||
|
const tabs = scope.appId ? TABS_WITH_CONFIG : BASE_TABS;
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.wrapper}>
|
||||||
<Tabs
|
<Tabs
|
||||||
tabs={TABS}
|
tabs={tabs}
|
||||||
active={active}
|
active={active}
|
||||||
onChange={(v) => onChange(v as TabKey)}
|
onChange={(v) => onChange(v as TabKey)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { useAgents } from '../api/queries/agents';
|
|||||||
import { useSearchExecutions, useAttributeKeys } from '../api/queries/executions';
|
import { useSearchExecutions, useAttributeKeys } from '../api/queries/executions';
|
||||||
import { useUsers, useGroups, useRoles } from '../api/queries/admin/rbac';
|
import { useUsers, useGroups, useRoles } from '../api/queries/admin/rbac';
|
||||||
import type { UserDetail, GroupDetail, RoleDetail } from '../api/queries/admin/rbac';
|
import type { UserDetail, GroupDetail, RoleDetail } from '../api/queries/admin/rbac';
|
||||||
import { useAuthStore } from '../auth/auth-store';
|
import { useAuthStore, useIsAdmin } from '../auth/auth-store';
|
||||||
import { useEnvironmentStore } from '../api/environment-store';
|
import { useEnvironmentStore } from '../api/environment-store';
|
||||||
import { useState, useMemo, useCallback, useEffect, useRef, createElement } from 'react';
|
import { useState, useMemo, useCallback, useEffect, useRef, createElement } from 'react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
@@ -277,6 +277,9 @@ function LayoutContent() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { timeRange, autoRefresh, refreshTimeRange } = useGlobalFilters();
|
const { timeRange, autoRefresh, refreshTimeRange } = useGlobalFilters();
|
||||||
|
|
||||||
|
// --- Role checks ----------------------------------------------------
|
||||||
|
const isAdmin = useIsAdmin();
|
||||||
|
|
||||||
// --- Environment filtering -----------------------------------------
|
// --- Environment filtering -----------------------------------------
|
||||||
const selectedEnv = useEnvironmentStore((s) => s.environment);
|
const selectedEnv = useEnvironmentStore((s) => s.environment);
|
||||||
const setSelectedEnvRaw = useEnvironmentStore((s) => s.setEnvironment);
|
const setSelectedEnvRaw = useEnvironmentStore((s) => s.setEnvironment);
|
||||||
@@ -668,25 +671,27 @@ function LayoutContent() {
|
|||||||
</Sidebar.Section>
|
</Sidebar.Section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Admin section — stays in place, expands when on admin pages */}
|
{/* Admin section — only visible to ADMIN role */}
|
||||||
<Sidebar.Section
|
{isAdmin && (
|
||||||
icon={createElement(Settings, { size: 16 })}
|
<Sidebar.Section
|
||||||
label="Admin"
|
icon={createElement(Settings, { size: 16 })}
|
||||||
open={adminOpen}
|
label="Admin"
|
||||||
onToggle={toggleAdmin}
|
open={adminOpen}
|
||||||
active={isAdminPage}
|
onToggle={toggleAdmin}
|
||||||
>
|
active={isAdminPage}
|
||||||
<SidebarTree
|
>
|
||||||
nodes={adminTreeNodes}
|
<SidebarTree
|
||||||
selectedPath={location.pathname}
|
nodes={adminTreeNodes}
|
||||||
isStarred={isStarred}
|
selectedPath={location.pathname}
|
||||||
onToggleStar={toggleStar}
|
isStarred={isStarred}
|
||||||
filterQuery={filterQuery}
|
onToggleStar={toggleStar}
|
||||||
persistKey="admin"
|
filterQuery={filterQuery}
|
||||||
autoRevealPath={sidebarRevealPath}
|
persistKey="admin"
|
||||||
onNavigate={handleSidebarNavigate}
|
autoRevealPath={sidebarRevealPath}
|
||||||
/>
|
onNavigate={handleSidebarNavigate}
|
||||||
</Sidebar.Section>
|
/>
|
||||||
|
</Sidebar.Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<Sidebar.Footer>
|
<Sidebar.Footer>
|
||||||
|
|||||||
@@ -101,7 +101,6 @@ export function buildAdminTreeNodes(): SidebarTreeNode[] {
|
|||||||
{ id: 'admin:rbac', label: 'Users & Roles', path: '/admin/rbac' },
|
{ id: 'admin:rbac', label: 'Users & Roles', path: '/admin/rbac' },
|
||||||
{ id: 'admin:audit', label: 'Audit Log', path: '/admin/audit' },
|
{ id: 'admin:audit', label: 'Audit Log', path: '/admin/audit' },
|
||||||
{ id: 'admin:oidc', label: 'OIDC', path: '/admin/oidc' },
|
{ id: 'admin:oidc', label: 'OIDC', path: '/admin/oidc' },
|
||||||
{ id: 'admin:appconfig', label: 'App Config', path: '/admin/appconfig' },
|
|
||||||
{ id: 'admin:database', label: 'Database', path: '/admin/database' },
|
{ id: 'admin:database', label: 'Database', path: '/admin/database' },
|
||||||
{ id: 'admin:clickhouse', label: 'ClickHouse', path: '/admin/clickhouse' },
|
{ id: 'admin:clickhouse', label: 'ClickHouse', path: '/admin/clickhouse' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
import { useParams, useNavigate, useLocation } from 'react-router';
|
import { useParams, useNavigate, useLocation } from 'react-router';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
export type TabKey = 'exchanges' | 'dashboard' | 'runtime' | 'logs';
|
export type TabKey = 'exchanges' | 'dashboard' | 'runtime' | 'logs' | 'config';
|
||||||
|
|
||||||
const VALID_TABS = new Set<TabKey>(['exchanges', 'dashboard', 'runtime', 'logs']);
|
const VALID_TABS = new Set<TabKey>(['exchanges', 'dashboard', 'runtime', 'logs', 'config']);
|
||||||
|
|
||||||
export interface Scope {
|
export interface Scope {
|
||||||
tab: TabKey;
|
tab: TabKey;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo, useEffect } from 'react';
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router';
|
import { useNavigate, useSearchParams, useParams } from 'react-router';
|
||||||
import { Pencil, X } from 'lucide-react';
|
import { Pencil, X } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
DataTable, Badge, MonoText, DetailPanel, SectionHeader, Button, Toggle, Spinner, useToast,
|
DataTable, Badge, MonoText, DetailPanel, SectionHeader, Button, Toggle, Spinner, useToast,
|
||||||
@@ -8,6 +8,7 @@ import type { Column } from '@cameleer/design-system';
|
|||||||
import { useAllApplicationConfigs, useApplicationConfig, useUpdateApplicationConfig, useProcessorRouteMapping } from '../../api/queries/commands';
|
import { useAllApplicationConfigs, useApplicationConfig, useUpdateApplicationConfig, useProcessorRouteMapping } from '../../api/queries/commands';
|
||||||
import type { ApplicationConfig, TapDefinition, ConfigUpdateResponse } from '../../api/queries/commands';
|
import type { ApplicationConfig, TapDefinition, ConfigUpdateResponse } from '../../api/queries/commands';
|
||||||
import { useRouteCatalog } from '../../api/queries/catalog';
|
import { useRouteCatalog } from '../../api/queries/catalog';
|
||||||
|
import { useCanControl } from '../../auth/auth-store';
|
||||||
import type { AppCatalogEntry, RouteSummary } from '../../api/types';
|
import type { AppCatalogEntry, RouteSummary } from '../../api/types';
|
||||||
import styles from './AppConfigPage.module.css';
|
import styles from './AppConfigPage.module.css';
|
||||||
|
|
||||||
@@ -72,6 +73,7 @@ function buildColumns(): Column<ConfigRow>[] {
|
|||||||
function AppConfigDetail({ appId, onClose }: { appId: string; onClose: () => void }) {
|
function AppConfigDetail({ appId, onClose }: { appId: string; onClose: () => void }) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const canEdit = useCanControl();
|
||||||
const { data: config, isLoading } = useApplicationConfig(appId);
|
const { data: config, isLoading } = useApplicationConfig(appId);
|
||||||
const updateConfig = useUpdateApplicationConfig();
|
const updateConfig = useUpdateApplicationConfig();
|
||||||
const { data: catalog } = useRouteCatalog();
|
const { data: catalog } = useRouteCatalog();
|
||||||
@@ -241,9 +243,9 @@ function AppConfigDetail({ appId, onClose }: { appId: string; onClose: () => voi
|
|||||||
{updateConfig.isPending ? 'Saving\u2026' : 'Save'}
|
{updateConfig.isPending ? 'Saving\u2026' : 'Save'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : canEdit ? (
|
||||||
<button className={styles.editBtn} onClick={startEditing} title="Edit configuration"><Pencil size={14} /></button>
|
<button className={styles.editBtn} onClick={startEditing} title="Edit configuration"><Pencil size={14} /></button>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Settings */}
|
{/* Settings */}
|
||||||
@@ -319,17 +321,22 @@ function AppConfigDetail({ appId, onClose }: { appId: string; onClose: () => voi
|
|||||||
// ── Main Page ────────────────────────────────────────────────────────────────
|
// ── Main Page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function AppConfigPage() {
|
export default function AppConfigPage() {
|
||||||
|
const { appId: routeAppId } = useParams<{ appId?: string }>();
|
||||||
const { data: configs } = useAllApplicationConfigs();
|
const { data: configs } = useAllApplicationConfigs();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [selectedApp, setSelectedApp] = useState<string | null>(null);
|
const [selectedApp, setSelectedApp] = useState<string | null>(routeAppId ?? null);
|
||||||
const columns = useMemo(buildColumns, []);
|
const columns = useMemo(buildColumns, []);
|
||||||
|
|
||||||
|
// Sync from route param when it changes (sidebar navigation)
|
||||||
|
useEffect(() => {
|
||||||
|
if (routeAppId) setSelectedApp(routeAppId);
|
||||||
|
}, [routeAppId]);
|
||||||
|
|
||||||
// Auto-select app from query param (e.g., ?app=caller-app)
|
// Auto-select app from query param (e.g., ?app=caller-app)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const appParam = searchParams.get('app');
|
const appParam = searchParams.get('app');
|
||||||
if (appParam && !selectedApp) {
|
if (appParam && !selectedApp) {
|
||||||
setSelectedApp(appParam);
|
setSelectedApp(appParam);
|
||||||
// Clean up the query param
|
|
||||||
searchParams.delete('app');
|
searchParams.delete('app');
|
||||||
searchParams.delete('processor');
|
searchParams.delete('processor');
|
||||||
setSearchParams(searchParams, { replace: true });
|
setSearchParams(searchParams, { replace: true });
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { StatusDot, MonoText, Badge, useGlobalFilters } from '@cameleer/design-s
|
|||||||
import { useCorrelationChain } from '../../api/queries/correlation';
|
import { useCorrelationChain } from '../../api/queries/correlation';
|
||||||
import { useAgents } from '../../api/queries/agents';
|
import { useAgents } from '../../api/queries/agents';
|
||||||
import { useRouteCatalog } from '../../api/queries/catalog';
|
import { useRouteCatalog } from '../../api/queries/catalog';
|
||||||
import { useAuthStore } from '../../auth/auth-store';
|
import { useCanControl } from '../../auth/auth-store';
|
||||||
import type { ExecutionDetail } from '../../components/ExecutionDiagram/types';
|
import type { ExecutionDetail } from '../../components/ExecutionDiagram/types';
|
||||||
import { attributeBadgeColor } from '../../utils/attribute-color';
|
import { attributeBadgeColor } from '../../utils/attribute-color';
|
||||||
import { RouteControlBar } from './RouteControlBar';
|
import { RouteControlBar } from './RouteControlBar';
|
||||||
@@ -79,8 +79,7 @@ export function ExchangeHeader({ detail, onCorrelatedSelect, onClearSelection }:
|
|||||||
};
|
};
|
||||||
}, [agents, detail.instanceId]);
|
}, [agents, detail.instanceId]);
|
||||||
|
|
||||||
const roles = useAuthStore((s) => s.roles);
|
const canControl = useCanControl();
|
||||||
const canControl = roles.some(r => r === 'OPERATOR' || r === 'ADMIN');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useRouteCatalog } from '../../api/queries/catalog';
|
|||||||
import { useAgents } from '../../api/queries/agents';
|
import { useAgents } from '../../api/queries/agents';
|
||||||
import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands';
|
import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands';
|
||||||
import type { TapDefinition, ConfigUpdateResponse } from '../../api/queries/commands';
|
import type { TapDefinition, ConfigUpdateResponse } from '../../api/queries/commands';
|
||||||
import { useAuthStore } from '../../auth/auth-store';
|
import { useCanControl } from '../../auth/auth-store';
|
||||||
import { useTracingStore } from '../../stores/tracing-store';
|
import { useTracingStore } from '../../stores/tracing-store';
|
||||||
import type { NodeAction, NodeConfig } from '../../components/ProcessDiagram/types';
|
import type { NodeAction, NodeConfig } from '../../components/ProcessDiagram/types';
|
||||||
import { TapConfigModal } from '../../components/TapConfigModal';
|
import { TapConfigModal } from '../../components/TapConfigModal';
|
||||||
@@ -154,8 +154,7 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS
|
|||||||
|
|
||||||
// Route state + capabilities for topology-only control bar
|
// Route state + capabilities for topology-only control bar
|
||||||
const { data: agents } = useAgents(undefined, appId);
|
const { data: agents } = useAgents(undefined, appId);
|
||||||
const roles = useAuthStore((s) => s.roles);
|
const canControl = useCanControl();
|
||||||
const canControl = roles.some(r => r === 'OPERATOR' || r === 'ADMIN');
|
|
||||||
const { hasRouteControl, hasReplay } = useMemo(() => {
|
const { hasRouteControl, hasReplay } = useMemo(() => {
|
||||||
if (!agents) return { hasRouteControl: false, hasReplay: false };
|
if (!agents) return { hasRouteControl: false, hasReplay: false };
|
||||||
const agentList = agents as any[];
|
const agentList = agents as any[];
|
||||||
@@ -332,7 +331,7 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS
|
|||||||
executionDetail={detail}
|
executionDetail={detail}
|
||||||
knownRouteIds={knownRouteIds}
|
knownRouteIds={knownRouteIds}
|
||||||
endpointRouteMap={endpointRouteMap}
|
endpointRouteMap={endpointRouteMap}
|
||||||
onNodeAction={handleNodeAction}
|
onNodeAction={canControl ? handleNodeAction : undefined}
|
||||||
nodeConfigs={nodeConfigs}
|
nodeConfigs={nodeConfigs}
|
||||||
/>
|
/>
|
||||||
{tapModal}
|
{tapModal}
|
||||||
@@ -359,7 +358,7 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS
|
|||||||
diagramLayout={diagramQuery.data}
|
diagramLayout={diagramQuery.data}
|
||||||
knownRouteIds={knownRouteIds}
|
knownRouteIds={knownRouteIds}
|
||||||
endpointRouteMap={endpointRouteMap}
|
endpointRouteMap={endpointRouteMap}
|
||||||
onNodeAction={handleNodeAction}
|
onNodeAction={canControl ? handleNodeAction : undefined}
|
||||||
nodeConfigs={nodeConfigs}
|
nodeConfigs={nodeConfigs}
|
||||||
/>
|
/>
|
||||||
{tapModal}
|
{tapModal}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createBrowserRouter, Navigate, useParams } from 'react-router';
|
import { createBrowserRouter, Navigate, useParams } from 'react-router';
|
||||||
import { ProtectedRoute } from './auth/ProtectedRoute';
|
import { ProtectedRoute } from './auth/ProtectedRoute';
|
||||||
|
import { RequireAdmin } from './auth/RequireAdmin';
|
||||||
import { LoginPage } from './auth/LoginPage';
|
import { LoginPage } from './auth/LoginPage';
|
||||||
import { OidcCallback } from './auth/OidcCallback';
|
import { OidcCallback } from './auth/OidcCallback';
|
||||||
import { LayoutShell } from './components/LayoutShell';
|
import { LayoutShell } from './components/LayoutShell';
|
||||||
@@ -76,6 +77,10 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'logs/:appId', element: <SuspenseWrapper><LogsPage /></SuspenseWrapper> },
|
{ path: 'logs/:appId', element: <SuspenseWrapper><LogsPage /></SuspenseWrapper> },
|
||||||
{ path: 'logs/:appId/:routeId', element: <SuspenseWrapper><LogsPage /></SuspenseWrapper> },
|
{ path: 'logs/:appId/:routeId', element: <SuspenseWrapper><LogsPage /></SuspenseWrapper> },
|
||||||
|
|
||||||
|
// Config tab (per-app, accessible to VIEWER+)
|
||||||
|
{ path: 'config', element: <Navigate to="/exchanges" replace /> },
|
||||||
|
{ path: 'config/:appId', element: <SuspenseWrapper><AppConfigPage /></SuspenseWrapper> },
|
||||||
|
|
||||||
// Legacy redirects — Sidebar uses hardcoded /apps/... and /agents/... paths
|
// Legacy redirects — Sidebar uses hardcoded /apps/... and /agents/... paths
|
||||||
{ path: 'apps', element: <Navigate to="/exchanges" replace /> },
|
{ path: 'apps', element: <Navigate to="/exchanges" replace /> },
|
||||||
{ path: 'apps/:appId', element: <LegacyAppRedirect /> },
|
{ path: 'apps/:appId', element: <LegacyAppRedirect /> },
|
||||||
@@ -84,19 +89,21 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'agents/:appId', element: <LegacyAgentRedirect /> },
|
{ path: 'agents/:appId', element: <LegacyAgentRedirect /> },
|
||||||
{ path: 'agents/:appId/:instanceId', element: <LegacyAgentRedirect /> },
|
{ path: 'agents/:appId/:instanceId', element: <LegacyAgentRedirect /> },
|
||||||
|
|
||||||
// Admin (unchanged)
|
// Admin (ADMIN role required)
|
||||||
{
|
{
|
||||||
path: 'admin',
|
element: <RequireAdmin />,
|
||||||
element: <SuspenseWrapper><AdminLayout /></SuspenseWrapper>,
|
children: [{
|
||||||
children: [
|
path: 'admin',
|
||||||
{ index: true, element: <Navigate to="/admin/rbac" replace /> },
|
element: <SuspenseWrapper><AdminLayout /></SuspenseWrapper>,
|
||||||
{ path: 'rbac', element: <SuspenseWrapper><RbacPage /></SuspenseWrapper> },
|
children: [
|
||||||
{ path: 'audit', element: <SuspenseWrapper><AuditLogPage /></SuspenseWrapper> },
|
{ index: true, element: <Navigate to="/admin/rbac" replace /> },
|
||||||
{ path: 'oidc', element: <SuspenseWrapper><OidcConfigPage /></SuspenseWrapper> },
|
{ path: 'rbac', element: <SuspenseWrapper><RbacPage /></SuspenseWrapper> },
|
||||||
{ path: 'appconfig', element: <SuspenseWrapper><AppConfigPage /></SuspenseWrapper> },
|
{ path: 'audit', element: <SuspenseWrapper><AuditLogPage /></SuspenseWrapper> },
|
||||||
{ path: 'database', element: <SuspenseWrapper><DatabaseAdminPage /></SuspenseWrapper> },
|
{ path: 'oidc', element: <SuspenseWrapper><OidcConfigPage /></SuspenseWrapper> },
|
||||||
{ path: 'clickhouse', element: <SuspenseWrapper><ClickHouseAdminPage /></SuspenseWrapper> },
|
{ path: 'database', element: <SuspenseWrapper><DatabaseAdminPage /></SuspenseWrapper> },
|
||||||
],
|
{ path: 'clickhouse', element: <SuspenseWrapper><ClickHouseAdminPage /></SuspenseWrapper> },
|
||||||
|
],
|
||||||
|
}],
|
||||||
},
|
},
|
||||||
{ path: 'api-docs', element: <SuspenseWrapper><SwaggerPage /></SuspenseWrapper> },
|
{ path: 'api-docs', element: <SuspenseWrapper><SwaggerPage /></SuspenseWrapper> },
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user