diff --git a/ui/src/auth/RequireAdmin.tsx b/ui/src/auth/RequireAdmin.tsx new file mode 100644 index 00000000..ca31947e --- /dev/null +++ b/ui/src/auth/RequireAdmin.tsx @@ -0,0 +1,8 @@ +import { Navigate, Outlet } from 'react-router'; +import { useIsAdmin } from './auth-store'; + +export function RequireAdmin() { + const isAdmin = useIsAdmin(); + if (!isAdmin) return ; + return ; +} diff --git a/ui/src/auth/auth-store.ts b/ui/src/auth/auth-store.ts index 6b775391..615b5e46 100644 --- a/ui/src/auth/auth-store.ts +++ b/ui/src/auth/auth-store.ts @@ -164,3 +164,6 @@ export const useAuthStore = create((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')); diff --git a/ui/src/components/ContentTabs.tsx b/ui/src/components/ContentTabs.tsx index f44aabd3..a85f366b 100644 --- a/ui/src/components/ContentTabs.tsx +++ b/ui/src/components/ContentTabs.tsx @@ -3,13 +3,18 @@ import type { TabKey, Scope } from '../hooks/useScope'; import { TabKpis } from './TabKpis'; import styles from './ContentTabs.module.css'; -const TABS = [ +const BASE_TABS = [ { label: 'Exchanges', value: 'exchanges' }, { label: 'Dashboard', value: 'dashboard' }, { label: 'Runtime', value: 'runtime' }, { label: 'Logs', value: 'logs' }, ]; +const TABS_WITH_CONFIG = [ + ...BASE_TABS, + { label: 'Config', value: 'config' }, +]; + interface ContentTabsProps { active: TabKey; onChange: (tab: TabKey) => void; @@ -17,10 +22,12 @@ interface 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 (
onChange(v as TabKey)} /> diff --git a/ui/src/components/LayoutShell.tsx b/ui/src/components/LayoutShell.tsx index ab38b5da..b2d2ead3 100644 --- a/ui/src/components/LayoutShell.tsx +++ b/ui/src/components/LayoutShell.tsx @@ -23,7 +23,7 @@ import { useAgents } from '../api/queries/agents'; import { useSearchExecutions, useAttributeKeys } from '../api/queries/executions'; import { useUsers, useGroups, useRoles } 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 { useState, useMemo, useCallback, useEffect, useRef, createElement } from 'react'; import type { ReactNode } from 'react'; @@ -277,6 +277,9 @@ function LayoutContent() { const queryClient = useQueryClient(); const { timeRange, autoRefresh, refreshTimeRange } = useGlobalFilters(); + // --- Role checks ---------------------------------------------------- + const isAdmin = useIsAdmin(); + // --- Environment filtering ----------------------------------------- const selectedEnv = useEnvironmentStore((s) => s.environment); const setSelectedEnvRaw = useEnvironmentStore((s) => s.setEnvironment); @@ -668,25 +671,27 @@ function LayoutContent() { )} - {/* Admin section — stays in place, expands when on admin pages */} - - - + {/* Admin section — only visible to ADMIN role */} + {isAdmin && ( + + + + )} {/* Footer */} diff --git a/ui/src/components/sidebar-utils.ts b/ui/src/components/sidebar-utils.ts index 0a748c47..1a78567d 100644 --- a/ui/src/components/sidebar-utils.ts +++ b/ui/src/components/sidebar-utils.ts @@ -101,7 +101,6 @@ export function buildAdminTreeNodes(): SidebarTreeNode[] { { id: 'admin:rbac', label: 'Users & Roles', path: '/admin/rbac' }, { id: 'admin:audit', label: 'Audit Log', path: '/admin/audit' }, { 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:clickhouse', label: 'ClickHouse', path: '/admin/clickhouse' }, ]; diff --git a/ui/src/hooks/useScope.ts b/ui/src/hooks/useScope.ts index 296b587f..18cf5418 100644 --- a/ui/src/hooks/useScope.ts +++ b/ui/src/hooks/useScope.ts @@ -2,9 +2,9 @@ import { useParams, useNavigate, useLocation } from 'react-router'; import { useCallback } from 'react'; -export type TabKey = 'exchanges' | 'dashboard' | 'runtime' | 'logs'; +export type TabKey = 'exchanges' | 'dashboard' | 'runtime' | 'logs' | 'config'; -const VALID_TABS = new Set(['exchanges', 'dashboard', 'runtime', 'logs']); +const VALID_TABS = new Set(['exchanges', 'dashboard', 'runtime', 'logs', 'config']); export interface Scope { tab: TabKey; diff --git a/ui/src/pages/Admin/AppConfigPage.tsx b/ui/src/pages/Admin/AppConfigPage.tsx index e2eb4786..1bfe2a01 100644 --- a/ui/src/pages/Admin/AppConfigPage.tsx +++ b/ui/src/pages/Admin/AppConfigPage.tsx @@ -1,5 +1,5 @@ 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 { 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 type { ApplicationConfig, TapDefinition, ConfigUpdateResponse } from '../../api/queries/commands'; import { useRouteCatalog } from '../../api/queries/catalog'; +import { useCanControl } from '../../auth/auth-store'; import type { AppCatalogEntry, RouteSummary } from '../../api/types'; import styles from './AppConfigPage.module.css'; @@ -72,6 +73,7 @@ function buildColumns(): Column[] { function AppConfigDetail({ appId, onClose }: { appId: string; onClose: () => void }) { const { toast } = useToast(); const navigate = useNavigate(); + const canEdit = useCanControl(); const { data: config, isLoading } = useApplicationConfig(appId); const updateConfig = useUpdateApplicationConfig(); const { data: catalog } = useRouteCatalog(); @@ -241,9 +243,9 @@ function AppConfigDetail({ appId, onClose }: { appId: string; onClose: () => voi {updateConfig.isPending ? 'Saving\u2026' : 'Save'}
- ) : ( + ) : canEdit ? ( - )} + ) : null} {/* Settings */} @@ -319,17 +321,22 @@ function AppConfigDetail({ appId, onClose }: { appId: string; onClose: () => voi // ── Main Page ──────────────────────────────────────────────────────────────── export default function AppConfigPage() { + const { appId: routeAppId } = useParams<{ appId?: string }>(); const { data: configs } = useAllApplicationConfigs(); const [searchParams, setSearchParams] = useSearchParams(); - const [selectedApp, setSelectedApp] = useState(null); + const [selectedApp, setSelectedApp] = useState(routeAppId ?? null); 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) useEffect(() => { const appParam = searchParams.get('app'); if (appParam && !selectedApp) { setSelectedApp(appParam); - // Clean up the query param searchParams.delete('app'); searchParams.delete('processor'); setSearchParams(searchParams, { replace: true }); diff --git a/ui/src/pages/Exchanges/ExchangeHeader.tsx b/ui/src/pages/Exchanges/ExchangeHeader.tsx index 5d41a454..2d373b9c 100644 --- a/ui/src/pages/Exchanges/ExchangeHeader.tsx +++ b/ui/src/pages/Exchanges/ExchangeHeader.tsx @@ -5,7 +5,7 @@ import { StatusDot, MonoText, Badge, useGlobalFilters } from '@cameleer/design-s import { useCorrelationChain } from '../../api/queries/correlation'; import { useAgents } from '../../api/queries/agents'; 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 { attributeBadgeColor } from '../../utils/attribute-color'; import { RouteControlBar } from './RouteControlBar'; @@ -79,8 +79,7 @@ export function ExchangeHeader({ detail, onCorrelatedSelect, onClearSelection }: }; }, [agents, detail.instanceId]); - const roles = useAuthStore((s) => s.roles); - const canControl = roles.some(r => r === 'OPERATOR' || r === 'ADMIN'); + const canControl = useCanControl(); return (
diff --git a/ui/src/pages/Exchanges/ExchangesPage.tsx b/ui/src/pages/Exchanges/ExchangesPage.tsx index 591d7f48..e046f48f 100644 --- a/ui/src/pages/Exchanges/ExchangesPage.tsx +++ b/ui/src/pages/Exchanges/ExchangesPage.tsx @@ -7,7 +7,7 @@ import { useRouteCatalog } from '../../api/queries/catalog'; import { useAgents } from '../../api/queries/agents'; import { useApplicationConfig, useUpdateApplicationConfig } 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 type { NodeAction, NodeConfig } from '../../components/ProcessDiagram/types'; import { TapConfigModal } from '../../components/TapConfigModal'; @@ -154,8 +154,7 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS // Route state + capabilities for topology-only control bar const { data: agents } = useAgents(undefined, appId); - const roles = useAuthStore((s) => s.roles); - const canControl = roles.some(r => r === 'OPERATOR' || r === 'ADMIN'); + const canControl = useCanControl(); const { hasRouteControl, hasReplay } = useMemo(() => { if (!agents) return { hasRouteControl: false, hasReplay: false }; const agentList = agents as any[]; @@ -332,7 +331,7 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS executionDetail={detail} knownRouteIds={knownRouteIds} endpointRouteMap={endpointRouteMap} - onNodeAction={handleNodeAction} + onNodeAction={canControl ? handleNodeAction : undefined} nodeConfigs={nodeConfigs} /> {tapModal} @@ -359,7 +358,7 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS diagramLayout={diagramQuery.data} knownRouteIds={knownRouteIds} endpointRouteMap={endpointRouteMap} - onNodeAction={handleNodeAction} + onNodeAction={canControl ? handleNodeAction : undefined} nodeConfigs={nodeConfigs} /> {tapModal} diff --git a/ui/src/router.tsx b/ui/src/router.tsx index 36c4bfdf..aeb87101 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -1,5 +1,6 @@ import { createBrowserRouter, Navigate, useParams } from 'react-router'; import { ProtectedRoute } from './auth/ProtectedRoute'; +import { RequireAdmin } from './auth/RequireAdmin'; import { LoginPage } from './auth/LoginPage'; import { OidcCallback } from './auth/OidcCallback'; import { LayoutShell } from './components/LayoutShell'; @@ -76,6 +77,10 @@ export const router = createBrowserRouter([ { path: 'logs/:appId', element: }, { path: 'logs/:appId/:routeId', element: }, + // Config tab (per-app, accessible to VIEWER+) + { path: 'config', element: }, + { path: 'config/:appId', element: }, + // Legacy redirects — Sidebar uses hardcoded /apps/... and /agents/... paths { path: 'apps', element: }, { path: 'apps/:appId', element: }, @@ -84,19 +89,21 @@ export const router = createBrowserRouter([ { path: 'agents/:appId', element: }, { path: 'agents/:appId/:instanceId', element: }, - // Admin (unchanged) + // Admin (ADMIN role required) { - path: 'admin', - element: , - children: [ - { index: true, element: }, - { path: 'rbac', element: }, - { path: 'audit', element: }, - { path: 'oidc', element: }, - { path: 'appconfig', element: }, - { path: 'database', element: }, - { path: 'clickhouse', element: }, - ], + element: , + children: [{ + path: 'admin', + element: , + children: [ + { index: true, element: }, + { path: 'rbac', element: }, + { path: 'audit', element: }, + { path: 'oidc', element: }, + { path: 'database', element: }, + { path: 'clickhouse', element: }, + ], + }], }, { path: 'api-docs', element: }, ],