diff --git a/frontend/src/components/layout/header.tsx b/frontend/src/components/layout/header.tsx index 8d4a7bf354..dd9d538ccf 100644 --- a/frontend/src/components/layout/header.tsx +++ b/frontend/src/components/layout/header.tsx @@ -9,10 +9,11 @@ * by the Apache License, Version 2.0 */ -import { Box, Button, ColorModeSwitch, CopyButton, Flex } from '@redpanda-data/ui'; +import { Button, ColorModeSwitch, CopyButton } from '@redpanda-data/ui'; import { Link, useLocation, useMatchRoute } from '@tanstack/react-router'; import { Heading } from 'components/redpanda-ui/components/typography'; import { cn } from 'components/redpanda-ui/lib/utils'; +import { ChevronLeft } from 'lucide-react'; import { Fragment, useMemo } from 'react'; import { isEmbedded, isFeatureFlagEnabled } from '../../config'; @@ -28,6 +29,7 @@ import { BreadcrumbList, BreadcrumbSeparator, } from '../redpanda-ui/components/breadcrumb'; +import { Button as RegistryButton } from '../redpanda-ui/components/button'; import { Separator } from '../redpanda-ui/components/separator'; import { SidebarTrigger } from '../redpanda-ui/components/sidebar'; @@ -38,8 +40,8 @@ type BreadcrumbHeaderRowProps = { function BreadcrumbHeaderRow({ useNewSidebar, breadcrumbItems }: BreadcrumbHeaderRowProps) { return ( - - +
+
{useNewSidebar ? ( <> @@ -50,7 +52,7 @@ function BreadcrumbHeaderRow({ useNewSidebar, breadcrumbItems }: BreadcrumbHeade {breadcrumbItems.map((item, index) => ( - + {index > 0 && } @@ -62,8 +64,8 @@ function BreadcrumbHeaderRow({ useNewSidebar, breadcrumbItems }: BreadcrumbHeade )} - - +
+
); } @@ -74,9 +76,10 @@ function AppPageHeader() { const useNewSidebar = !isEmbedded(); const pageBreadcrumbs = useUIStateStore((s) => s.pageBreadcrumbs); + const pageTitle = useUIStateStore((s) => s._pageTitle); + const backLink = useUIStateStore((s) => s.backLink); const selectedClusterName = useUIStateStore((s) => s.selectedClusterName); const shouldHidePageHeader = useUIStateStore((s) => s.shouldHidePageHeader); - const breadcrumbItems = useMemo(() => { const items: BreadcrumbEntry[] = [...pageBreadcrumbs]; @@ -92,38 +95,41 @@ function AppPageHeader() { }, [pageBreadcrumbs, selectedClusterName]); const lastBreadcrumb = breadcrumbItems.at(-1); - const breadcrumbsExceptLast = breadcrumbItems.slice(0, -1); if (shouldHideHeader || shouldHidePageHeader) { return null; } return ( - - {/* we need to refactor out #mainLayout > div rule, for now I've added this box as a workaround */} - - - - - {lastBreadcrumb ? ( - - {lastBreadcrumb.titleNode ?? lastBreadcrumb.title} - - ) : null} - {lastBreadcrumb ? ( - - {lastBreadcrumb.options?.canBeCopied ? ( - - ) : null} - - ) : null} - {Boolean(showRefresh) && } - - +
+ + +
+
+ {backLink && ( + + + + {backLink.title} + + + )} +
+ {pageTitle ? ( + + {pageTitle} + + ) : null} + {lastBreadcrumb?.options?.canBeCopied ? ( + + ) : null} + {Boolean(showRefresh) && } +
+
+
{!isEmbedded() && api.isRedpanda && (
+
+
); } @@ -165,10 +171,8 @@ function useShouldShowRefresh() { const getStartedApiMatch = matchRoute({ to: '/get-started/api' }); // matches acls - const aclCreateMatch = matchRoute({ to: '/security/acls/create' }); - const aclUpdateMatch = matchRoute({ to: '/security/acls/$aclName/update' }); const aclDetailMatch = matchRoute({ to: '/security/acls/$aclName/details' }); - const isACLRelated = aclCreateMatch || aclUpdateMatch || aclDetailMatch; + const isACLRelated = aclDetailMatch; // matches roles const roleCreateMatch = matchRoute({ to: '/security/roles/create' }); @@ -176,6 +180,9 @@ function useShouldShowRefresh() { const roleDetailMatch = matchRoute({ to: '/security/roles/$roleName/details' }); const isRoleRelated = roleCreateMatch || roleUpdateMatch || roleDetailMatch; + // matches user detail + const userDetailMatch = matchRoute({ to: '/security/users/$userName/details' }); + if (connectClusterMatch && connectClusterMatch.connector === 'create-connector') { return false; } @@ -194,6 +201,9 @@ function useShouldShowRefresh() { if (isRoleRelated) { return false; } + if (userDetailMatch) { + return false; + } if (connectWizardPagesMatch) { return false; } diff --git a/frontend/src/components/license/feature-license-notification.tsx b/frontend/src/components/license/feature-license-notification.tsx index daaa862026..d49f35ec8d 100644 --- a/frontend/src/components/license/feature-license-notification.tsx +++ b/frontend/src/components/license/feature-license-notification.tsx @@ -1,4 +1,3 @@ -import { Alert, AlertDescription, AlertIcon, Box, Flex, Text } from '@redpanda-data/ui'; import { Link } from 'components/redpanda-ui/components/typography'; import { type FC, type ReactElement, useEffect, useState } from 'react'; @@ -27,6 +26,8 @@ import { type ListEnterpriseFeaturesResponse_Feature, } from '../../protogen/redpanda/api/console/v1alpha1/license_pb'; import { api } from '../../state/backend-api'; +import { Alert, AlertDescription } from '../redpanda-ui/components/alert'; +import { Badge } from '../redpanda-ui/components/badge'; // biome-ignore lint/nursery/useMaxParams: Refactoring to options object would require updating all call sites const getLicenseAlertContentForFeature = ( @@ -36,7 +37,7 @@ const getLicenseAlertContentForFeature = ( bakedInTrial: boolean, onRegisterModalOpen: () => void // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complex business logic -): { message: ReactElement; status: 'warning' | 'info' } | null => { +): { message: ReactElement; variant: 'destructive' | 'info' } | null => { if (license === undefined) { return null; } @@ -47,23 +48,23 @@ const getLicenseAlertContentForFeature = ( if (bakedInTrial) { return { message: ( - - This is an enterprise feature. Register for an additional 30 days of enterprise features. - +
+

This is an enterprise feature. Register for an additional 30 days of enterprise features.

+
- - +
+
), - status: msToExpiration > WARNING_THRESHOLD_DAYS * MS_IN_DAY ? 'info' : 'warning', + variant: msToExpiration > WARNING_THRESHOLD_DAYS * MS_IN_DAY ? 'info' : 'destructive', }; } return { message: ( - - This is an enterprise feature. - +
+

This is an enterprise feature.

+
), - status: msToExpiration > WARNING_THRESHOLD_DAYS * MS_IN_DAY ? 'info' : 'warning', + variant: msToExpiration > WARNING_THRESHOLD_DAYS * MS_IN_DAY ? 'info' : 'destructive', }; } @@ -76,22 +77,22 @@ const getLicenseAlertContentForFeature = ( ) { return { message: ( - - This is an enterprise feature, active until {getPrettyExpirationDate(license)}. - +
+

This is an enterprise feature, active until {getPrettyExpirationDate(license)}.

+
- - +
+
), - status: 'info', + variant: 'info', }; } if (msToExpiration > -1 && msToExpiration < 15 * MS_IN_DAY && coreHasEnterpriseFeatures(enterpriseFeaturesUsed)) { return { message: ( - - +
+

Your Redpanda Enterprise trial is expiring in {getPrettyTimeToExpiration(license)}; at that point, your{' '} enterprise features @@ -101,14 +102,14 @@ const getLicenseAlertContentForFeature = ( contact us . - - +

+
- - +
+
), - status: 'warning', + variant: 'destructive', }; } } else { @@ -117,37 +118,37 @@ const getLicenseAlertContentForFeature = ( if (license.type === License_Type.TRIAL) { return { message: ( - - This is an enterprise feature. Your trial is active until {getPrettyExpirationDate(license)} - +
+

This is an enterprise feature. Your trial is active until {getPrettyExpirationDate(license)}

+
- - +
+
), - status: 'info', + variant: 'info', }; } return { message: ( - - +
+

This is a Redpanda Enterprise feature. Try it with our{' '} Redpanda Enterprise Trial . - - +

+
), - status: 'info', + variant: 'info', }; } if (msToExpiration > 0 && msToExpiration < 15 * MS_IN_DAY && license.type === License_Type.TRIAL) { return { message: ( - - +
+

Your Redpanda Enterprise trial is expiring in {getPrettyTimeToExpiration(license)}; at that point, your{' '} enterprise features @@ -157,14 +158,14 @@ const getLicenseAlertContentForFeature = ( contact us . - - +

+
- - +
+
), - status: 'warning', + variant: 'destructive', }; } } @@ -172,7 +173,10 @@ const getLicenseAlertContentForFeature = ( return null; }; -export const FeatureLicenseNotification: FC<{ featureName: 'reassignPartitions' | 'rbac' }> = ({ featureName }) => { +export const FeatureLicenseNotification: FC<{ + featureName: 'reassignPartitions' | 'rbac'; + as?: 'alert' | 'badge'; +}> = ({ featureName, as: renderAs = 'alert' }) => { const [registerModalOpen, setIsRegisterModalOpen] = useState(false); useEffect(() => { @@ -220,16 +224,19 @@ export const FeatureLicenseNotification: FC<{ featureName: 'reassignPartitions' return null; } - const { message, status } = alertContent; + const { message, variant } = alertContent; + + if (renderAs === 'badge') { + return {message}; + } return ( - - - + <> + {message} setIsRegisterModalOpen(false)} /> - + ); }; diff --git a/frontend/src/components/pages/observability/observability-page.test.tsx b/frontend/src/components/pages/observability/observability-page.test.tsx index 90d298d44c..aff8cba337 100644 --- a/frontend/src/components/pages/observability/observability-page.test.tsx +++ b/frontend/src/components/pages/observability/observability-page.test.tsx @@ -54,6 +54,7 @@ vi.mock('config', async (importOriginal) => { }); vi.mock('state/ui-state', () => ({ + setPageHeader: vi.fn(), uiState: { pageTitle: '', pageBreadcrumbs: [], diff --git a/frontend/src/components/pages/observability/observability-page.tsx b/frontend/src/components/pages/observability/observability-page.tsx index 9fe8744124..1c11f8c429 100644 --- a/frontend/src/components/pages/observability/observability-page.tsx +++ b/frontend/src/components/pages/observability/observability-page.tsx @@ -12,7 +12,7 @@ import { type FC, lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react'; import { useListQueries } from 'react-query/api/observability'; import { appGlobal } from 'state/app-global'; -import { uiState } from 'state/ui-state'; +import { setPageHeader } from 'state/ui-state'; const MetricChart = lazy(() => import('./metric-chart').then((m) => ({ default: m.MetricChart }))); @@ -46,7 +46,7 @@ const ObservabilityPage: FC = () => { }, [refetch]); useEffect(() => { - uiState.pageBreadcrumbs = [{ title: 'Metrics', linkTo: '/observability' }]; + setPageHeader('Metrics', [{ title: 'Metrics', linkTo: '/observability' }]); appGlobal.onRefresh = () => refreshData(); }, [refreshData]); diff --git a/frontend/src/components/pages/overview/overview.tsx b/frontend/src/components/pages/overview/overview.tsx index 00a53c050f..694be281c7 100644 --- a/frontend/src/components/pages/overview/overview.tsx +++ b/frontend/src/components/pages/overview/overview.tsx @@ -384,7 +384,7 @@ function ClusterDetails() {
+ {aclCount} , ], diff --git a/frontend/src/components/pages/page.ts b/frontend/src/components/pages/page.ts index c7992edc66..2e5d460f05 100644 --- a/frontend/src/components/pages/page.ts +++ b/frontend/src/components/pages/page.ts @@ -19,7 +19,7 @@ import { useRpcnSecretManagerStore, useTransformsStore, } from '../../state/backend-api'; -import { type BreadcrumbOptions, uiState } from '../../state/ui-state'; +import { type BreadcrumbEntry, type BreadcrumbOptions, setPageHeader } from '../../state/ui-state'; // // Page Types @@ -30,11 +30,17 @@ export type NoRouteParams = {}; export type PageProps = TRouteParams & { matchedPath: string }; export class PageInitHelper { + private pageTitle = ''; + private pageBreadcrumbs: BreadcrumbEntry[] = []; + set title(title: string) { - uiState.pageTitle = title; + this.pageTitle = title; } addBreadcrumb(title: string, to: string, heading?: string, options?: BreadcrumbOptions) { - uiState.pageBreadcrumbs.push({ title, linkTo: to, heading, options }); + this.pageBreadcrumbs.push({ title, linkTo: to, heading, options }); + } + _flush() { + setPageHeader(this.pageTitle, this.pageBreadcrumbs); } } export abstract class PageComponent extends React.Component> { @@ -43,9 +49,9 @@ export abstract class PageComponent extends React. constructor(props: Readonly>) { super(props); - uiState.pageBreadcrumbs = []; - - this.initPage(new PageInitHelper()); + const helper = new PageInitHelper(); + this.initPage(helper); + helper._flush(); } componentDidMount() { diff --git a/frontend/src/components/pages/rp-connect/onboarding/add-user-step.tsx b/frontend/src/components/pages/rp-connect/onboarding/add-user-step.tsx index e0045b3694..809cf4509f 100644 --- a/frontend/src/components/pages/rp-connect/onboarding/add-user-step.tsx +++ b/frontend/src/components/pages/rp-connect/onboarding/add-user-step.tsx @@ -698,8 +698,11 @@ export const AddUserStep = forwardRef You will need to configure{' '} - ACLs for custom - user permissions if you want the user to be able to read from the topic. + + Permissions + {' '} + for custom user permissions if you want the user to be able to read from the + topic. diff --git a/frontend/src/components/pages/schemas/schema-list.tsx b/frontend/src/components/pages/schemas/schema-list.tsx index d0b6d12a5b..4a2735b986 100644 --- a/frontend/src/components/pages/schemas/schema-list.tsx +++ b/frontend/src/components/pages/schemas/schema-list.tsx @@ -67,7 +67,7 @@ import { api } from '../../../state/backend-api'; import type { SchemaRegistrySubject } from '../../../state/rest-interfaces'; import { useSupportedFeaturesStore } from '../../../state/supported-features'; import { uiSettings } from '../../../state/ui'; -import { uiState } from '../../../state/ui-state'; +import { setPageHeader } from '../../../state/ui-state'; import { encodeURIComponentPercents } from '../../../utils/utils'; import PageContent from '../../misc/page-content'; import Section from '../../misc/section'; @@ -180,7 +180,7 @@ const SchemaList: FC = () => { }, [derivedContexts, selectedContext, schemaRegistryContextsSupported, schemaMode, schemaCompatibility]); useEffect(() => { - uiState.pageBreadcrumbs = [{ title: 'Schema Registry', linkTo: '/schema-registry' }]; + setPageHeader('Schema Registry', [{ title: 'Schema Registry', linkTo: '/schema-registry' }]); appGlobal.onRefresh = () => refreshData(); }, [refreshData]); diff --git a/frontend/src/components/pages/security/acls/acl-create-page.test.tsx b/frontend/src/components/pages/security/acls/acl-create-page.test.tsx deleted file mode 100644 index 23a4b0b2f9..0000000000 --- a/frontend/src/components/pages/security/acls/acl-create-page.test.tsx +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Copyright 2025 Redpanda Data, Inc. - * - * Use of this software is governed by the Business Source License - * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md - * - * As of the Change Date specified in that file, in accordance with - * the Business Source License, use of this software will be governed - * by the Apache License, Version 2.0 - */ - -import { renderWithFileRoutes, screen, waitFor } from 'test-utils'; - -// Mock getRouteApi to return controlled search params -let mockSearch: Record = {}; - -vi.mock('@tanstack/react-router', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getRouteApi: () => ({ - useSearch: () => mockSearch, - }), - useNavigate: () => vi.fn(), - }; -}); - -vi.mock('state/backend-api', () => ({ - useApiStoreHook: (selector: (s: { enterpriseFeaturesUsed: { name: string; enabled: boolean }[] }) => T) => - selector({ enterpriseFeaturesUsed: [] }), -})); - -vi.mock('../../../../state/supported-features', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useSupportedFeaturesStore: (selector: (s: Record) => T) => - selector({ createUser: true, deleteUser: true, rolesApi: true, schemaRegistryACLApi: false }), - }; -}); - -// Polyfills -global.ResizeObserver = class ResizeObserver { - observe() {} - unobserve() {} - disconnect() {} -}; -Element.prototype.scrollIntoView = vi.fn(); - -// Import after mocks -import AclCreatePage from './acl-create-page'; - -describe('AclCreatePage — search param → form population', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockSearch = {}; - }); - - test('principal input is pre-populated from principalType=User&principalName=my-user', async () => { - mockSearch = { principalType: 'User', principalName: 'my-user' }; - - renderWithFileRoutes(); - - const principalInput = await screen.findByTestId('shared-principal-input'); - await waitFor(() => { - expect(principalInput).toHaveValue('my-user'); - }); - - const typeSelect = screen.getByTestId('shared-principal-type-select'); - expect(typeSelect).toHaveTextContent('User'); - }); - - test('principal input is empty when no search params', async () => { - mockSearch = {}; - - renderWithFileRoutes(); - - const principalInput = await screen.findByTestId('shared-principal-input'); - await waitFor(() => { - expect(principalInput).toHaveValue(''); - }); - }); - - test('principal input is editable (not locked) with search params', async () => { - mockSearch = { principalType: 'User', principalName: 'editable-user' }; - - renderWithFileRoutes(); - - const principalInput = await screen.findByTestId('shared-principal-input'); - expect(principalInput).not.toBeDisabled(); - }); - - test('principal input updates when search params arrive after initial render', async () => { - // Simulate: first render has no params (route loading), then params arrive - mockSearch = {}; - - const { rerender } = renderWithFileRoutes(); - - const principalInput = await screen.findByTestId('shared-principal-input'); - expect(principalInput).toHaveValue(''); - - // Params arrive (route finishes loading) - mockSearch = { principalType: 'User', principalName: 'late-user' }; - rerender(); - - await waitFor(() => { - expect(principalInput).toHaveValue('late-user'); - }); - }); -}); diff --git a/frontend/src/components/pages/security/acls/acl-create-page.tsx b/frontend/src/components/pages/security/acls/acl-create-page.tsx deleted file mode 100644 index a09db59092..0000000000 --- a/frontend/src/components/pages/security/acls/acl-create-page.tsx +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Copyright 2022 Redpanda Data, Inc. - * - * Use of this software is governed by the Business Source License - * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md - * - * As of the Change Date specified in that file, in accordance with - * the Business Source License, use of this software will be governed - * by the Apache License, Version 2.0 - */ - -import { getRouteApi, useNavigate } from '@tanstack/react-router'; - -const routeApi = getRouteApi('/security/acls/create'); - -import CreateACL from './create-acl'; -import { useCreateAcls } from '../../../../react-query/api/acl'; -import { useSecurityBreadcrumbs } from '../hooks/use-security-breadcrumbs'; -import { convertRulesToCreateACLRequests, handleResponses, type Rule } from '../shared/acl-model'; -import { parsePrincipalFromParam, resolveAclSearchParams } from '../shared/principal-utils'; - -const AclCreatePage = () => { - const navigate = useNavigate({ from: '/security/acls/create' }); - const search = routeApi.useSearch(); - - const { sharedConfig, principalType } = resolveAclSearchParams(search); - - useSecurityBreadcrumbs([{ title: 'ACLs', linkTo: '/security/acls' }]); - - const { createAcls } = useCreateAcls(); - - const createAclMutation = async (principal: string, host: string, rules: Rule[]) => { - const result = convertRulesToCreateACLRequests(rules, principal, host); - const applyResult = await createAcls(result); - handleResponses(applyResult.errors, applyResult.created); - - const { principalType: parsedType, principalName } = parsePrincipalFromParam(principal); - const aclName = parsedType === 'User' ? principalName : principal; - navigate({ - to: '/security/acls/$aclName/details', - params: { aclName }, - search: { host: undefined }, - }); - }; - - return ( -
-

Create ACL

- navigate({ to: '/security/acls' })} - onSubmit={createAclMutation} - principalType={principalType} - sharedConfig={sharedConfig} - /> -
- ); -}; - -export default AclCreatePage; diff --git a/frontend/src/components/pages/security/acls/acl-detail-page.test.tsx b/frontend/src/components/pages/security/acls/acl-detail-page.test.tsx index c24d22d2b6..7d2a47b46f 100644 --- a/frontend/src/components/pages/security/acls/acl-detail-page.test.tsx +++ b/frontend/src/components/pages/security/acls/acl-detail-page.test.tsx @@ -10,7 +10,6 @@ */ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import AclDetailPage from './acl-detail-page'; @@ -47,6 +46,7 @@ vi.mock('@tanstack/react-router', async (importOriginal) => { }); vi.mock('state/ui-state', () => ({ + setPageHeader: vi.fn(), uiState: { pageBreadcrumbs: [] }, })); @@ -124,13 +124,6 @@ describe('AclDetailPage — principal URL encoding', () => { render(); const editButton = await screen.findByTestId('update-acl-button'); - await userEvent.click(editButton); - - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith({ - to: '/security/acls/Group:mygroup/update', - search: { host: '*' }, - }); - }); + expect(editButton).toHaveAttribute('href', '/security/acls/Group:mygroup/update?host=*'); }); }); diff --git a/frontend/src/components/pages/security/acls/acl-detail-page.tsx b/frontend/src/components/pages/security/acls/acl-detail-page.tsx index 0c5c7c6978..429db70096 100644 --- a/frontend/src/components/pages/security/acls/acl-detail-page.tsx +++ b/frontend/src/components/pages/security/acls/acl-detail-page.tsx @@ -9,23 +9,22 @@ * by the Apache License, Version 2.0 */ -import { getRouteApi, useNavigate } from '@tanstack/react-router'; +import { getRouteApi } from '@tanstack/react-router'; const routeApi = getRouteApi('/security/acls/$aclName/details'); -import { Pencil } from 'lucide-react'; +import { useLayoutEffect } from 'react'; import { HostSelector } from './host-selector'; import { useGetAclsByPrincipal } from '../../../../react-query/api/acl'; +import { setPageHeader } from '../../../../state/ui-state'; import { Button } from '../../../redpanda-ui/components/button'; import { Text } from '../../../redpanda-ui/components/typography'; -import { useSecurityBreadcrumbs } from '../hooks/use-security-breadcrumbs'; import { ACLDetails } from '../shared/acl-details'; import { parsePrincipalFromParam } from '../shared/principal-utils'; const AclDetailPage = () => { const { aclName } = routeApi.useParams(); - const navigate = useNavigate({ from: '/security/acls/$aclName/details' }); const search = routeApi.useSearch(); const host = search.host || undefined; @@ -34,10 +33,13 @@ const AclDetailPage = () => { const [acls, ...hosts] = data || []; - useSecurityBreadcrumbs([ - { title: 'ACLs', linkTo: '/security/acls' }, - { title: principalName, linkTo: `/security/acls/${aclName}/details` }, - ]); + useLayoutEffect(() => { + setPageHeader(principalName, [ + { title: 'Security', linkTo: '/security/users' }, + { title: 'Permissions', linkTo: '/security/permissions-list' }, + { title: principalName, linkTo: `/security/acls/${aclName}/details` }, + ]); + }, [principalName, aclName]); if (isLoading) { return
Loading...
; @@ -51,25 +53,17 @@ const AclDetailPage = () => { return ; } + const editHref = `/security/acls/${aclName}/update${host ? `?host=${host}` : ''}`; + return (

ACL: {principalName}

-
- Configuration details - -
+ +
); diff --git a/frontend/src/components/pages/security/acls/acl-update-page.tsx b/frontend/src/components/pages/security/acls/acl-update-page.tsx deleted file mode 100644 index 609d5c42a1..0000000000 --- a/frontend/src/components/pages/security/acls/acl-update-page.tsx +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Copyright 2025 Redpanda Data, Inc. - * - * Use of this software is governed by the Business Source License - * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md - * - * As of the Change Date specified in that file, in accordance with - * the Business Source License, use of this software will be governed - * by the Apache License, Version 2.0 - */ - -import { getRouteApi, useNavigate } from '@tanstack/react-router'; - -const routeApi = getRouteApi('/security/acls/$aclName/update'); - -import CreateACL from './create-acl'; -import { HostSelector } from './host-selector'; -import { useGetAclsByPrincipal, useUpdateAclMutation } from '../../../../react-query/api/acl'; -import { useSecurityBreadcrumbs } from '../hooks/use-security-breadcrumbs'; -import { - getOperationsForResourceType, - handleResponses, - ModeAllowAll, - ModeDenyAll, - OperationTypeAllow, - OperationTypeDeny, - type PrincipalType, - PrincipalTypeGroup, - PrincipalTypeRedpandaRole, - PrincipalTypeUser, - type Rule, -} from '../shared/acl-model'; -import { parsePrincipalFromParam } from '../shared/principal-utils'; - -const VALID_PRINCIPAL_TYPES: Record = { - User: PrincipalTypeUser, - Group: PrincipalTypeGroup, - RedpandaRole: PrincipalTypeRedpandaRole, -}; - -const AclUpdatePage = () => { - const navigate = useNavigate({ from: '/security/acls/$aclName/update' }); - const { aclName } = routeApi.useParams(); - const search = routeApi.useSearch(); - const host = search.host ?? undefined; - - const { principalType, principalName } = parsePrincipalFromParam(aclName); - - useSecurityBreadcrumbs([ - { title: 'ACLs', linkTo: '/security/acls' }, - { title: principalName, linkTo: `/security/acls/${aclName}/details` }, - ]); - - // Fetch existing ACL data - const { data, isLoading } = useGetAclsByPrincipal(`${principalType}:${principalName}`, host); - - const { applyUpdates } = useUpdateAclMutation(); - - const [acls, ...hosts] = data || []; - - const handleUpdate = async (_principal: string, _host: string, rules: Rule[]) => { - if (!acls) { - return; - } - const applyResult = await applyUpdates(acls.rules, acls.sharedConfig, rules); - handleResponses(applyResult.errors, applyResult.created); - - navigate({ - to: `/security/acls/${aclName}/details`, - search: { host }, - }); - }; - - if (isLoading) { - return ( -
-
-
Loading ACL configuration...
-
-
- ); - } - - if (!(acls && data)) { - return
No ACL data found
; - } - - if (hosts.length > 1) { - return ( -
- -
- ); - } - - // Ensure all operations are present for each rule - const rulesWithAllOperations = acls.rules.map((rule) => { - const allOperations = getOperationsForResourceType(rule.resourceType); - let mergedOperations = { ...allOperations }; - - // If mode is AllowAll or DenyAll, set all operations accordingly - if (rule.mode === ModeAllowAll) { - mergedOperations = Object.fromEntries(Object.keys(allOperations).map((op) => [op, OperationTypeAllow])); - } else if (rule.mode === ModeDenyAll) { - mergedOperations = Object.fromEntries(Object.keys(allOperations).map((op) => [op, OperationTypeDeny])); - } else { - // For custom mode, override with the actual values from the fetched rule - for (const [op, value] of Object.entries(rule.operations)) { - if (op in mergedOperations) { - mergedOperations[op] = value; - } - } - } - - return { - ...rule, - operations: mergedOperations, - }; - }); - - return ( -
-

Update ACL: {principalName}

- - navigate({ - to: `/security/acls/${aclName}/details`, - search: { host }, - }) - } - onSubmit={handleUpdate} - principalType={VALID_PRINCIPAL_TYPES[principalType] ?? PrincipalTypeUser} - rules={rulesWithAllOperations} - sharedConfig={acls.sharedConfig} - /> -
- ); -}; - -export default AclUpdatePage; diff --git a/frontend/src/components/pages/security/hooks/use-principal-permissions.ts b/frontend/src/components/pages/security/hooks/use-principal-permissions.ts new file mode 100644 index 0000000000..ad086cfb47 --- /dev/null +++ b/frontend/src/components/pages/security/hooks/use-principal-permissions.ts @@ -0,0 +1,142 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { useQuery } from '@connectrpc/connect-query'; +import { useMemo } from 'react'; + +import { usePrincipalList } from './use-principal-list'; +import { + ACL_PermissionType, + ACL_ResourceType, + type ListACLsRequest, +} from '../../../../protogen/redpanda/api/dataplane/v1/acl_pb'; +import { listACLs } from '../../../../protogen/redpanda/api/dataplane/v1/acl-ACLService_connectquery'; +import { getACLOperation } from '../../../../react-query/api/acl'; +import { rolesApi } from '../../../../state/backend-api'; + +export type FlatAclEntry = { + resourceType: string; + resourceName: string; + operation: string; + permissionType: 'Allow' | 'Deny'; + host: string; +}; + +export type RoleAclGroup = { + roleName: string; + acls: FlatAclEntry[]; +}; + +export type PrincipalPermissionGroup = { + principal: string; + principalType: 'User' | 'Group'; + principalName: string; + isScramUser: boolean; + directAcls: FlatAclEntry[]; + roleAclGroups: RoleAclGroup[]; + directAclCount: number; + inheritedAclCount: number; + denyCount: number; +}; + +const RESOURCE_TYPE_LABELS: Partial> = { + [ACL_ResourceType.TOPIC]: 'Topic', + [ACL_ResourceType.GROUP]: 'Consumer Group', + [ACL_ResourceType.CLUSTER]: 'Cluster', + [ACL_ResourceType.TRANSACTIONAL_ID]: 'Transactional ID', + [ACL_ResourceType.SUBJECT]: 'Subject', + [ACL_ResourceType.REGISTRY]: 'Schema Registry', +}; + +export function usePrincipalPermissions() { + const { + data: allAclsData, + isLoading: isAclsLoading, + isError: isAclsError, + error: aclsError, + } = useQuery(listACLs, {} as ListACLsRequest); + + const { principals, isUsersError, usersError } = usePrincipalList(); + + const principalGroups = useMemo(() => { + if (!allAclsData) return []; + + // Build flat ACL list per principal + const aclsByPrincipal = new Map(); + for (const resource of allAclsData.resources) { + for (const acl of resource.acls) { + const key = acl.principal; + if (!aclsByPrincipal.has(key)) { + aclsByPrincipal.set(key, []); + } + aclsByPrincipal.get(key)!.push({ + resourceType: RESOURCE_TYPE_LABELS[resource.resourceType] ?? String(resource.resourceType), + resourceName: resource.resourceName || '*', + operation: getACLOperation(acl.operation), + permissionType: acl.permissionType === ACL_PermissionType.DENY ? 'Deny' : 'Allow', + host: acl.host || '*', + }); + } + } + + // Extract role ACLs: principal = "RedpandaRole:roleName" + const roleAcls = new Map(); + for (const [principal, acls] of aclsByPrincipal) { + if (principal.startsWith('RedpandaRole:')) { + roleAcls.set(principal.slice('RedpandaRole:'.length), acls); + } + } + + return principals + .filter((p) => p.principalType === 'User' || p.principalType === 'Group') + .map((p) => { + const principalKey = `${p.principalType}:${p.name}`; + const directAcls = aclsByPrincipal.get(principalKey) ?? []; + + const belongsToRoles: string[] = []; + for (const [roleName, members] of rolesApi.roleMembers) { + if (members.some((m) => m.name === p.name && m.principalType === p.principalType)) { + belongsToRoles.push(roleName); + } + } + + const roleAclGroups: RoleAclGroup[] = belongsToRoles + .map((roleName) => ({ roleName, acls: roleAcls.get(roleName) ?? [] })) + .filter((g) => g.acls.length > 0); + + const inheritedAclCount = roleAclGroups.reduce((sum, g) => sum + g.acls.length, 0); + const denyCount = [...directAcls, ...roleAclGroups.flatMap((g) => g.acls)].filter( + (e) => e.permissionType === 'Deny' + ).length; + + return { + principal: principalKey, + principalType: p.principalType, + principalName: p.name, + isScramUser: p.isScramUser, + directAcls, + roleAclGroups, + directAclCount: directAcls.length, + inheritedAclCount, + denyCount, + }; + }); + }, [allAclsData, principals]); + + return { + principalGroups, + isAclsLoading, + isAclsError, + aclsError, + isUsersError, + usersError, + }; +} diff --git a/frontend/src/components/pages/security/hooks/use-security-breadcrumbs.test.tsx b/frontend/src/components/pages/security/hooks/use-security-breadcrumbs.test.tsx deleted file mode 100644 index 1c4fc3c839..0000000000 --- a/frontend/src/components/pages/security/hooks/use-security-breadcrumbs.test.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Copyright 2026 Redpanda Data, Inc. - * - * Use of this software is governed by the Business Source License - * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md - * - * As of the Change Date specified in that file, in accordance with - * the Business Source License, use of this software will be governed - * by the Apache License, Version 2.0 - */ - -import { renderHook } from '@testing-library/react'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; - -const { mockUiState } = vi.hoisted(() => ({ - mockUiState: { - pageBreadcrumbs: [] as { title: string; linkTo: string }[], - pageTitle: '', - }, -})); - -vi.mock('../../../../state/ui-state', () => ({ - uiState: mockUiState, -})); - -import { useSecurityBreadcrumbs } from './use-security-breadcrumbs'; - -describe('useSecurityBreadcrumbs', () => { - beforeEach(() => { - mockUiState.pageBreadcrumbs = []; - mockUiState.pageTitle = ''; - }); - - test('sets "Access Control" as the last breadcrumb (becomes H1)', () => { - renderHook(() => - useSecurityBreadcrumbs([ - { title: 'Users', linkTo: '/security/users' }, - { title: 'alice', linkTo: '/security/users/alice/details' }, - ]) - ); - - const crumbs = mockUiState.pageBreadcrumbs; - expect(crumbs.at(-1)).toEqual({ title: 'Access Control', linkTo: '/security' }); - }); - - test('prepends trail entries before "Access Control"', () => { - renderHook(() => - useSecurityBreadcrumbs([ - { title: 'Roles', linkTo: '/security/roles' }, - { title: 'my-role', linkTo: '/security/roles/my-role/details' }, - ]) - ); - - expect(mockUiState.pageBreadcrumbs).toEqual([ - { title: 'Roles', linkTo: '/security/roles' }, - { title: 'my-role', linkTo: '/security/roles/my-role/details' }, - { title: 'Access Control', linkTo: '/security' }, - ]); - }); - - test('with empty trail, only "Access Control" is set', () => { - renderHook(() => useSecurityBreadcrumbs([])); - - expect(mockUiState.pageBreadcrumbs).toEqual([{ title: 'Access Control', linkTo: '/security' }]); - }); - - test('single trail entry for create pages', () => { - renderHook(() => useSecurityBreadcrumbs([{ title: 'ACLs', linkTo: '/security/acls' }])); - - expect(mockUiState.pageBreadcrumbs).toEqual([ - { title: 'ACLs', linkTo: '/security/acls' }, - { title: 'Access Control', linkTo: '/security' }, - ]); - }); - - test('sets pageTitle to "Access Control"', () => { - renderHook(() => - useSecurityBreadcrumbs([ - { title: 'Users', linkTo: '/security/users' }, - { title: 'alice', linkTo: '/security/users/alice/details' }, - ]) - ); - - expect(mockUiState.pageTitle).toBe('Access Control'); - }); - - test('sets pageTitle even with empty trail', () => { - renderHook(() => useSecurityBreadcrumbs([])); - - expect(mockUiState.pageTitle).toBe('Access Control'); - }); -}); diff --git a/frontend/src/components/pages/security/hooks/use-security-breadcrumbs.ts b/frontend/src/components/pages/security/hooks/use-security-breadcrumbs.ts deleted file mode 100644 index 14e3bea32d..0000000000 --- a/frontend/src/components/pages/security/hooks/use-security-breadcrumbs.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Copyright 2026 Redpanda Data, Inc. - * - * Use of this software is governed by the Business Source License - * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md - * - * As of the Change Date specified in that file, in accordance with - * the Business Source License, use of this software will be governed - * by the Apache License, Version 2.0 - */ - -import { useLayoutEffect } from 'react'; - -import { uiState } from '../../../../state/ui-state'; - -/** - * Sets breadcrumbs for security sub-pages while keeping the H1 as "Access Control". - * - * The header renders the last breadcrumb as H1. This hook always puts - * "Access Control" as the last entry so the H1 stays constant. - * The `trail` entries appear before it in the breadcrumb navigation. - * - * @example - * useSecurityBreadcrumbs([ - * { title: 'Users', linkTo: '/security/users' }, - * { title: 'alice', linkTo: '/security/users/alice/details' }, - * ]); - * // Breadcrumb trail: Users > alice - * // H1 heading: Access Control - */ -export function useSecurityBreadcrumbs(trail: { title: string; linkTo: string }[]) { - // Serialize trail for stable dependency comparison (avoids infinite re-renders from new array refs) - const key = JSON.stringify(trail); - useLayoutEffect(() => { - uiState.pageBreadcrumbs = [...trail, { title: 'Access Control', linkTo: '/security' }]; - uiState.pageTitle = 'Access Control'; - }, [key]); // eslint-disable-line react-hooks/exhaustive-deps -} diff --git a/frontend/src/components/pages/security/roles/role-create-dialog.tsx b/frontend/src/components/pages/security/roles/role-create-dialog.tsx new file mode 100644 index 0000000000..95efd193d7 --- /dev/null +++ b/frontend/src/components/pages/security/roles/role-create-dialog.tsx @@ -0,0 +1,94 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { create } from '@bufbuild/protobuf'; +import { ConnectError } from '@connectrpc/connect'; +import { useNavigate } from '@tanstack/react-router'; +import { CreateRoleRequestSchema } from 'protogen/redpanda/api/dataplane/v1/security_pb'; +import { useState } from 'react'; +import { toast } from 'sonner'; + +import { useCreateRoleMutation, useListRolesQuery } from '../../../../react-query/api/security'; +import { Button } from '../../../redpanda-ui/components/button'; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../../../redpanda-ui/components/dialog'; +import { FieldError } from '../../../redpanda-ui/components/field'; +import { Input } from '../../../redpanda-ui/components/input'; +import { Label } from '../../../redpanda-ui/components/label'; + +type RoleCreateDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +export const RoleCreateDialog = ({ open, onOpenChange }: RoleCreateDialogProps) => { + const [roleName, setRoleName] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitted, setSubmitted] = useState(false); + const navigate = useNavigate(); + const { mutateAsync: createRole } = useCreateRoleMutation(); + const { data: rolesData } = useListRolesQuery(); + + const existingNames = new Set((rolesData?.roles ?? []).map((r) => r.name)); + const trimmed = roleName.trim(); + const alreadyExists = trimmed !== '' && existingNames.has(trimmed); + + const handleClose = () => { + setRoleName(''); + setSubmitted(false); + onOpenChange(false); + }; + + const handleSubmit = async () => { + setSubmitted(true); + if (!trimmed || alreadyExists) return; + setIsSubmitting(true); + try { + await createRole(create(CreateRoleRequestSchema, { role: { name: trimmed } })); + toast.success(`Role "${trimmed}" created`); + handleClose(); + navigate({ to: '/security/roles/$roleName/details', params: { roleName: encodeURIComponent(trimmed) } }); + } catch (err) { + toast.error(`Failed to create role: ${ConnectError.from(err).message}`); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + + Create role + +
+ + setRoleName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSubmit()} + placeholder="analytics-writer" + value={roleName} + /> + {submitted && alreadyExists && A role with this name already exists.} +
+ + + + +
+
+ ); +}; diff --git a/frontend/src/components/pages/security/roles/role-create-page.tsx b/frontend/src/components/pages/security/roles/role-create-page.tsx index 4ec14abd12..168216c282 100644 --- a/frontend/src/components/pages/security/roles/role-create-page.tsx +++ b/frontend/src/components/pages/security/roles/role-create-page.tsx @@ -15,12 +15,13 @@ import { CardField } from 'components/redpanda-ui/components/card'; import { FieldError, FieldLabel } from 'components/redpanda-ui/components/field'; import { Input } from 'components/redpanda-ui/components/input'; import { CreateRoleRequestSchema } from 'protogen/redpanda/api/dataplane/v1/security_pb'; +import { useLayoutEffect } from 'react'; import { toast } from 'sonner'; import { useCreateAcls } from '../../../../react-query/api/acl'; import { useCreateRoleMutation } from '../../../../react-query/api/security'; +import { setPageHeader } from '../../../../state/ui-state'; import CreateACL from '../acls/create-acl'; -import { useSecurityBreadcrumbs } from '../hooks/use-security-breadcrumbs'; import { convertRulesToCreateACLRequests, handleResponses, @@ -32,7 +33,12 @@ import { const RoleCreatePage = () => { const navigate = useNavigate(); - useSecurityBreadcrumbs([{ title: 'Roles', linkTo: '/security/roles' }]); + useLayoutEffect(() => { + setPageHeader('Roles', [ + { title: 'Security', linkTo: '/security/users' }, + { title: 'Roles', linkTo: '/security/roles' }, + ]); + }, []); const { createAcls } = useCreateAcls(); const { mutateAsync: createRole } = useCreateRoleMutation(); diff --git a/frontend/src/components/pages/security/roles/role-detail-page.tsx b/frontend/src/components/pages/security/roles/role-detail-page.tsx index 3142b1a1da..c1517a8f6b 100644 --- a/frontend/src/components/pages/security/roles/role-detail-page.tsx +++ b/frontend/src/components/pages/security/roles/role-detail-page.tsx @@ -9,163 +9,201 @@ * by the Apache License, Version 2.0 */ -import { getRouteApi, useNavigate } from '@tanstack/react-router'; +import { create } from '@bufbuild/protobuf'; +import { getRouteApi } from '@tanstack/react-router'; +import { Trash2, Users2Icon } from 'lucide-react'; +import { + ListRoleMembersRequestSchema, + UpdateRoleMembershipRequestSchema, +} from 'protogen/redpanda/api/dataplane/v1/security_pb'; +import { ListUsersRequestSchema } from 'protogen/redpanda/api/dataplane/v1/user_pb'; +import { useLayoutEffect, useMemo, useState } from 'react'; +import { toast } from 'sonner'; -const routeApi = getRouteApi('/security/roles/$roleName/details'); - -import { Eye, Pencil } from 'lucide-react'; -import { useMemo } from 'react'; - -import { MatchingUsersCard } from './matching-users-card'; import { useGetAclsByPrincipal } from '../../../../react-query/api/acl'; +import { useListRoleMembersQuery, useUpdateRoleMembershipMutation } from '../../../../react-query/api/security'; +import { useListUsersQuery } from '../../../../react-query/api/user'; +import { setPageHeader } from '../../../../state/ui-state'; import { Button } from '../../../redpanda-ui/components/button'; -import { Card, CardContent, CardHeader } from '../../../redpanda-ui/components/card'; +import { Combobox } from '../../../redpanda-ui/components/combobox'; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '../../../redpanda-ui/components/empty'; +import { ListLayout, ListLayoutContent, ListLayoutFilters } from '../../../redpanda-ui/components/list-layout'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../../redpanda-ui/components/table'; -import { Text } from '../../../redpanda-ui/components/typography'; -import { useSecurityBreadcrumbs } from '../hooks/use-security-breadcrumbs'; -import { ACLDetails } from '../shared/acl-details'; -import type { AclDetail } from '../shared/acl-model'; +import { parsePrincipal } from '../shared/acl-model'; +import { AclsCard } from '../shared/acls-card'; -type SecurityAclRulesTableProps = { - data: AclDetail[]; - roleName: string; -}; +const routeApi = getRouteApi('/security/roles/$roleName/details'); -// Table to display multiple ACL rules for a role -const SecurityAclRulesTable = ({ data, roleName }: SecurityAclRulesTableProps) => { - const navigate = useNavigate(); +const RoleDetailPage = () => { + const { roleName } = routeApi.useParams(); + const [deletingPrincipal, setDeletingPrincipal] = useState(null); + + useLayoutEffect(() => { + setPageHeader( + roleName, + [ + { title: 'Security', linkTo: '/security/users' }, + { title: 'Roles', linkTo: '/security/roles' }, + { title: roleName, linkTo: `/security/roles/${roleName}/details` }, + ], + { title: 'Roles', linkTo: '/security/roles' } + ); + }, [roleName]); - return ( - - -

Security ACL rules

-
- - - - - Principal - Host - ACLs count - {''} - - - - {data.map((aclData) => ( - - - {aclData.sharedConfig.principal} - - {aclData.sharedConfig.host} - {aclData.rules.length} - -
- - -
-
-
- ))} -
-
-
-
+ const { data: aclData, isLoading: isAclsLoading } = useGetAclsByPrincipal(`RedpandaRole:${roleName}`); + + const { data: membersData, isLoading: membersLoading } = useListRoleMembersQuery( + create(ListRoleMembersRequestSchema, { roleName }) ); -}; + const { data: usersData } = useListUsersQuery(create(ListUsersRequestSchema)); + const { mutateAsync: updateMembership, isPending: isSubmitting } = useUpdateRoleMembershipMutation(); -const RoleDetailPage = () => { - const { roleName } = routeApi.useParams(); - const navigate = useNavigate({ from: '/security/roles/$roleName/details' }); - const search = routeApi.useSearch(); - const host = search.host ?? undefined; + const allMembers = membersData?.members ?? []; - useSecurityBreadcrumbs([ - { title: 'Roles', linkTo: '/security/roles' }, - { title: roleName, linkTo: `/security/roles/${roleName}/details` }, - ]); + const assignedPrincipals = useMemo(() => new Set(allMembers.map((m) => m.principal)), [allMembers]); - // Fetch ACLs for the role - const { data, isLoading } = useGetAclsByPrincipal(`RedpandaRole:${roleName}`, host); + const availablePrincipalOptions = useMemo( + () => + (usersData?.users ?? []) + .filter((u) => !assignedPrincipals.has(`User:${u.name}`)) + .map((u) => ({ value: u.name, label: u.name })), + [usersData, assignedPrincipals] + ); - const renderACLInformation = useMemo(() => { - if (!data || data.length === 0) { - return ( -
-
No Role data found.
-
+ const addMember = async (userName: string) => { + if (!userName) return; + try { + await updateMembership( + create(UpdateRoleMembershipRequestSchema, { + roleName, + add: [{ principal: `User:${userName}` }], + remove: [], + create: true, + }) ); + toast.success(`User "${userName}" added to role successfully`); + } catch { + // Error handled by onError in mutation } - - if (data.length === 1) { - const acl = data[0]; - return ; + }; + + const handleRemoveMember = async (memberPrincipal: string) => { + setDeletingPrincipal(memberPrincipal); + try { + await updateMembership( + create(UpdateRoleMembershipRequestSchema, { + roleName, + add: [], + remove: [{ principal: memberPrincipal }], + create: false, + }) + ); + toast.success(`User "${parsePrincipal(memberPrincipal).name}" removed from role`); + } catch { + // Error handled by onError in mutation + } finally { + setDeletingPrincipal(null); } - return ; - }, [data, roleName]); - - if (isLoading) { - return ( -
-
Loading role details...
-
- ); - } + }; return ( -
-

Role: {roleName}

-
- Configuration details - {(!!host || data?.length === 1) && ( -
- + + + + + ); + } + return rows.map((row) => ( + + + toggleRow(row.id)} /> + + {row.resourceType} + {row.resourceName} + {row.operation} + + {row.permissionType} + + {row.host} + + )); + }; + + return ( + <> + + + {someSelected && ( + + )} + {principal && ( + + )} + +
+ } + > + + ACLs + + + + + + + + + + Type + Resource + Operation + Permission + Host + + + {renderBody()} +
+
+ + + {principal && } + + + + + Allow all operations + + The following ACLs will be created for {principal}: + + + + + + + Resource Type + Resource Name + Operation + Permission + + + + {GRANT_ALL_RESOURCES.map((r) => ( + + {r.label} + {r.name} + All + Allow + + ))} + +
+ + + + + +
+
+ + ); +}; diff --git a/frontend/src/components/pages/security/shared/delete-role-confirm-modal.tsx b/frontend/src/components/pages/security/shared/delete-role-confirm-modal.tsx index ead3465707..aa686fd9b0 100644 --- a/frontend/src/components/pages/security/shared/delete-role-confirm-modal.tsx +++ b/frontend/src/components/pages/security/shared/delete-role-confirm-modal.tsx @@ -28,13 +28,19 @@ export const DeleteRoleConfirmModal: FC<{ roleName: string; numberOfPrincipals: number; onConfirm: () => Promise | void; - buttonEl: React.ReactElement; -}> = ({ roleName, numberOfPrincipals, onConfirm, buttonEl }) => { - const [open, setOpen] = useState(false); + buttonEl?: React.ReactElement; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}> = ({ roleName, numberOfPrincipals, onConfirm, buttonEl, open: openProp, onOpenChange: onOpenChangeProp }) => { + const [internalOpen, setInternalOpen] = useState(false); const [confirmText, setConfirmText] = useState(''); + const isControlled = openProp !== undefined; + const open = isControlled ? openProp : internalOpen; + const handleOpenChange = (o: boolean) => { - setOpen(o); + if (!isControlled) setInternalOpen(o); + onOpenChangeProp?.(o); if (!o) setConfirmText(''); }; @@ -45,7 +51,7 @@ export const DeleteRoleConfirmModal: FC<{ return ( - {buttonEl} + {buttonEl && {buttonEl}} Delete role {roleName} diff --git a/frontend/src/components/pages/security/shared/description-with-help.tsx b/frontend/src/components/pages/security/shared/description-with-help.tsx new file mode 100644 index 0000000000..45301f73b9 --- /dev/null +++ b/frontend/src/components/pages/security/shared/description-with-help.tsx @@ -0,0 +1,49 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { InfoIcon } from 'lucide-react'; +import { useState } from 'react'; + +import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '../../../redpanda-ui/components/drawer'; + +type Props = { + short: string; + title: string; + children: React.ReactNode; +}; + +export function DescriptionWithHelp({ short, title, children }: Props) { + const [open, setOpen] = useState(false); + + return ( + <> + + {short} + + + + + + {title} + +
{children}
+
+
+ + ); +} diff --git a/frontend/src/components/pages/security/shared/security-tabs-nav.tsx b/frontend/src/components/pages/security/shared/security-tabs-nav.tsx new file mode 100644 index 0000000000..715916a33f --- /dev/null +++ b/frontend/src/components/pages/security/shared/security-tabs-nav.tsx @@ -0,0 +1,113 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { useLocation, useNavigate } from '@tanstack/react-router'; +import { ListLayoutNavigation } from 'components/redpanda-ui/components/list-layout'; +import { isServerless } from 'config'; + +import { useApiStoreHook } from '../../../../state/backend-api'; +import { useSupportedFeaturesStore } from '../../../../state/supported-features'; +import { Tabs, TabsList, TabsTrigger } from '../../../redpanda-ui/components/tabs'; + +type TabConfig = { + key: string; + label: string; + path: string; + disabled: boolean; +}; + +function buildTabs( + isAdminApiConfigured: boolean, + featureCreateUser: boolean, + featureRolesApi: boolean, + userData: { canManageUsers?: boolean; canListAcls?: boolean; canViewPermissionsList?: boolean } | null | undefined +): TabConfig[] { + const result: TabConfig[] = [ + { + key: 'users', + label: 'Users', + path: '/security/users', + disabled: + !(isAdminApiConfigured && featureCreateUser) || + (userData?.canManageUsers !== undefined && userData?.canManageUsers === false), + }, + ]; + + if (!isServerless()) { + result.push({ + key: 'roles', + label: 'Roles', + path: '/security/roles', + disabled: !featureRolesApi || userData?.canManageUsers === false, + }); + } + + result.push({ + key: 'permissions-list', + label: 'Permissions', + path: '/security/permissions-list', + disabled: userData?.canViewPermissionsList === false, + }); + + return result; +} + +function deriveActiveTab(pathname: string, tabs: TabConfig[]): string { + for (const tab of tabs) { + if (pathname === tab.path || pathname.startsWith(`${tab.path}/`)) { + return tab.key; + } + } + return tabs[0]?.key ?? 'users'; +} + +export function SecurityTabsNav() { + const location = useLocation(); + const navigate = useNavigate(); + const userData = useApiStoreHook((s) => s.userData); + const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); + const featureCreateUser = useSupportedFeaturesStore((s) => s.createUser); + const redpandaOverview = useApiStoreHook((s) => s.clusterOverview?.redpanda); + const isAdminApiConfigured = Boolean(redpandaOverview); + + const tabs = buildTabs(isAdminApiConfigured, featureCreateUser, featureRolesApi, userData); + const activeTab = deriveActiveTab(location.pathname, tabs); + + const handleTabClick = (tabKey: string) => { + const tab = tabs.find((t) => t.key === tabKey); + if (tab && !tab.disabled) { + navigate({ to: tab.path }); + } + }; + + return ( + <> + + + + {tabs.map((tab) => ( + handleTabClick(tab.key)} + value={tab.key} + variant="underline" + > + {tab.label} + + ))} + + + + + ); +} diff --git a/frontend/src/components/pages/security/tabs/acls-tab.tsx b/frontend/src/components/pages/security/tabs/acls-tab.tsx deleted file mode 100644 index c92b12d02e..0000000000 --- a/frontend/src/components/pages/security/tabs/acls-tab.tsx +++ /dev/null @@ -1,267 +0,0 @@ -/** - * Copyright 2022 Redpanda Data, Inc. - * - * Use of this software is governed by the Business Source License - * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md - * - * As of the Change Date specified in that file, in accordance with - * the Business Source License, use of this software will be governed - * by the Apache License, Version 2.0 - */ - -import { create } from '@bufbuild/protobuf'; -import { DataTable, SearchField } from '@redpanda-data/ui'; -import { Link, useNavigate } from '@tanstack/react-router'; -import { TrashIcon } from 'components/icons'; -import { InfoIcon } from 'lucide-react'; -import { - ACL_Operation, - ACL_PermissionType, - ACL_ResourcePatternType, - ACL_ResourceType, - type DeleteACLsRequest, - DeleteACLsRequestSchema, -} from 'protogen/redpanda/api/dataplane/v1/acl_pb'; -import type { FC } from 'react'; -import { useState } from 'react'; -import { toast } from 'sonner'; - -import ErrorResult from '../../../../components/misc/error-result'; -import { useDeleteAclMutation, useListACLAsPrincipalGroups } from '../../../../react-query/api/acl'; -import { useGetRedpandaInfoQuery } from '../../../../react-query/api/cluster-status'; -import { useDeleteUserMutation, useInvalidateUsersCache, useListUsersQuery } from '../../../../react-query/api/user'; -import { api } from '../../../../state/backend-api'; -import { AclRequestDefault } from '../../../../state/rest-interfaces'; -import { useSupportedFeaturesStore } from '../../../../state/supported-features'; -import { Code as CodeEl, DefaultSkeleton } from '../../../../utils/tsx-utils'; -import Section from '../../../misc/section'; -import { Alert, AlertDescription } from '../../../redpanda-ui/components/alert'; -import { Badge } from '../../../redpanda-ui/components/badge'; -import { Button } from '../../../redpanda-ui/components/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '../../../redpanda-ui/components/dropdown-menu'; -import { useSecurityBreadcrumbs } from '../hooks/use-security-breadcrumbs'; -import { AlertDeleteFailed } from '../shared/alert-delete-failed'; -import { filterByName } from '../shared/filter-by-name'; - -export const AclsTab: FC = () => { - useSecurityBreadcrumbs([]); - const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); - const featureDeleteUser = useSupportedFeaturesStore((s) => s.deleteUser); - const { data: redpandaInfo, isSuccess: isRedpandaInfoSuccess } = useGetRedpandaInfoQuery(); - const isAdminApiConfigured = isRedpandaInfoSuccess && Boolean(redpandaInfo); - const { data: usersData } = useListUsersQuery(undefined, { enabled: isAdminApiConfigured }); - const { data: principalGroups, isLoading, isError, error } = useListACLAsPrincipalGroups(); - const { mutateAsync: deleteACLMutation } = useDeleteAclMutation(); - const { mutateAsync: deleteUserMut } = useDeleteUserMutation(); - const invalidateUsersCache = useInvalidateUsersCache(); - - const [aclFailed, setAclFailed] = useState<{ err: unknown } | null>(null); - const [searchQuery, setSearchQuery] = useState(''); - - const navigate = useNavigate(); - - const deleteACLsForPrincipal = async (principal: string, host: string) => { - const deleteRequest: DeleteACLsRequest = create(DeleteACLsRequestSchema, { - filter: { - principal, - resourceType: ACL_ResourceType.ANY, - resourceName: undefined, - host, - operation: ACL_Operation.ANY, - permissionType: ACL_PermissionType.ANY, - resourcePatternType: ACL_ResourcePatternType.ANY, - }, - }); - await deleteACLMutation(deleteRequest); - toast.success( - - Deleted ACLs for {principal} - - ); - }; - - const aclPrincipalGroups = - principalGroups?.filter((g) => g.principalType === 'User' || g.principalType === 'Group') || []; - const groups = filterByName(aclPrincipalGroups, searchQuery, (g) => g.principalName); - - if (isError && error) { - return ; - } - - if (isLoading || !principalGroups) { - return DefaultSkeleton; - } - - return ( -
-
- This tab displays all access control lists (ACLs), grouped by principal and host. A principal represents any - entity that can be authenticated, such as a user, service, or system (for example, a SASL-SCRAM user, OIDC - identity, or mTLS client). The ACLs tab shows only the permissions directly granted to each principal. For a - complete view of all permissions, including permissions granted through roles, see the Permissions List tab. -
- {Boolean(featureRolesApi) && ( - } variant="warning"> - - Roles are a more flexible and efficient way to manage user permissions, especially with complex - organizational hierarchies or large numbers of users. - - - )} - -
- setAclFailed(null)} /> - - - -
- - columns={[ - { - size: Number.POSITIVE_INFINITY, - header: 'Principal', - accessorKey: 'principal', - cell: ({ row: { original: record } }) => ( - ({ ...prev, host: record.host })} - to="/security/acls/$aclName/details" - > - - - {record.principalName} - - {record.principalType === 'Group' && Group} - - - ), - }, - { - header: 'Host', - accessorKey: 'host', - cell: ({ - row: { - original: { host }, - }, - }) => (!host || host === '*' ? Any : host), - }, - { - size: 60, - id: 'menu', - header: '', - cell: ({ row: { original: record } }) => { - const userExists = usersData?.users?.some((u) => u.name === record.principalName) ?? false; - - const onDelete = async (user: boolean, acls: boolean) => { - if (acls) { - try { - await deleteACLsForPrincipal(record.principal, record.host); - } catch (err: unknown) { - // biome-ignore lint/suspicious/noConsole: error logging - console.error('failed to delete acls', { error: err }); - setAclFailed({ err }); - } - } - - if (user) { - try { - await deleteUserMut({ name: record.principalName }); - toast.success( - - Deleted user {record.principalName} - - ); - } catch (err: unknown) { - // biome-ignore lint/suspicious/noConsole: error logging - console.error('failed to delete user', { error: err }); - setAclFailed({ err }); - } - } - - await Promise.allSettled([api.refreshAcls(AclRequestDefault, true), invalidateUsersCache()]); - }; - - return ( - - - - - - { - onDelete(true, true).catch(() => { - // Error handling managed by API layer - }); - e.stopPropagation(); - }} - > - Delete (User and ACLs) - - { - onDelete(true, false).catch(() => { - // Error handling managed by API layer - }); - e.stopPropagation(); - }} - > - Delete (User only) - - { - onDelete(false, true).catch(() => { - // Error handling managed by API layer - }); - e.stopPropagation(); - }} - > - Delete (ACLs only) - - - - ); - }, - }, - ]} - data={groups} - pagination - sorting - /> -
-
-
- ); -}; diff --git a/frontend/src/components/pages/security/tabs/permissions-list-tab.test.tsx b/frontend/src/components/pages/security/tabs/permissions-list-tab.test.tsx index 294f1e5e17..b9e1c39685 100644 --- a/frontend/src/components/pages/security/tabs/permissions-list-tab.test.tsx +++ b/frontend/src/components/pages/security/tabs/permissions-list-tab.test.tsx @@ -11,6 +11,7 @@ import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { NuqsTestingAdapter } from 'nuqs/adapters/testing'; import type { ReactNode } from 'react'; import { beforeEach, describe, expect, test, vi } from 'vitest'; @@ -23,104 +24,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; * (they're ACL-only principals, not SASL-SCRAM accounts) */ -const { listACLsData } = vi.hoisted(() => ({ - listACLsData: { - // Return a SCRAM user, a non-SCRAM user (ACL-only), and a Group principal - data: [ - { host: '*', principal: 'User:scram-admin', principalType: 'User', principalName: 'scram-admin', hasAcl: true }, - { - host: '*', - principal: 'User:acl-only-user', - principalType: 'User', - principalName: 'acl-only-user', - hasAcl: true, - }, - { host: '*', principal: 'Group:engineering', principalType: 'Group', principalName: 'engineering', hasAcl: true }, - ], - error: null, - isError: false, - isLoading: false, - }, -})); - -vi.mock('@redpanda-data/ui', () => { - const Div = ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => ( -
{children}
- ); - - return { - Alert: Div, - AlertDescription: Div, - AlertIcon: () => , - AlertTitle: Div, - Badge: Div, - Box: Div, - Button: ({ - children, - isDisabled, - onClick, - ...props - }: { - children?: ReactNode; - isDisabled?: boolean; - onClick?: () => void; - [key: string]: unknown; - }) => ( - - ), - createStandaloneToast: () => ({ - ToastContainer: () => null, - toast: vi.fn(), - }), - DataTable: ({ - columns, - data, - emptyText, - }: { - columns: Array<{ - cell?: (ctx: { row: { original: Record } }) => ReactNode; - header?: ReactNode; - id?: string; - }>; - data: Record[]; - emptyText?: ReactNode; - }) => - data.length > 0 ? ( - - - {data.map((row, rowIndex) => ( - - {columns.map((column, colIndex) => ( - - ))} - - ))} - -
{column.cell?.({ row: { original: row } }) ?? null}
- ) : ( -
{emptyText}
- ), - Flex: Div, - SearchField: ({ - placeholderText, - searchText, - setSearchText, - }: { - placeholderText?: string; - searchText?: string; - setSearchText?: (value: string) => void; - }) => ( - setSearchText?.(e.target.value)} placeholder={placeholderText} value={searchText ?? ''} /> - ), - Skeleton: Div, - Text: Div, - Tooltip: ({ children }: { children?: ReactNode }) => <>{children}, - redpandaTheme: {}, - redpandaToastOptions: { defaultOptions: {} }, - }; -}); +const NuqsWrapper = ({ children }: { children: ReactNode }) => {children}; vi.mock('@tanstack/react-router', async (importOriginal) => { const actual = await importOriginal(); @@ -135,6 +39,10 @@ vi.mock('@tanstack/react-router', async (importOriginal) => { }; }); +vi.mock('../shared/security-tabs-nav', () => ({ + SecurityTabsNav: () => null, +})); + vi.mock('../shared/delete-user-confirm-modal', () => ({ DeleteUserConfirmModal: ({ open, @@ -155,10 +63,6 @@ vi.mock('../shared/delete-user-confirm-modal', () => ({ ) : null, })); -vi.mock('../shared/user-role-tags', () => ({ - UserRoleTags: () => null, -})); - vi.mock('../../../../components/misc/error-result', () => ({ default: () => null, })); @@ -209,27 +113,60 @@ vi.mock('../../../../state/rest-interfaces', () => ({ AclRequestDefault: {}, })); -vi.mock('../../../misc/section', () => ({ - default: ({ children }: { children?: ReactNode }) =>
{children}
, -})); - -vi.mock('react-query/api/cluster-status', () => ({ - useGetRedpandaInfoQuery: () => ({ data: {}, isSuccess: true }), +vi.mock('../../../../react-query/api/acl', () => ({ + useCreateACLMutation: () => ({ mutateAsync: vi.fn() }), + useDeleteAclMutation: () => ({ mutateAsync: vi.fn() }), })); -vi.mock('react-query/api/user', () => ({ +vi.mock('../../../../react-query/api/user', () => ({ useInvalidateUsersCache: () => vi.fn(), useDeleteUserMutation: () => ({ mutateAsync: vi.fn().mockResolvedValue(undefined) }), - // "scram-admin" is a SCRAM user; "acl-only-user" is NOT (only has ACLs) - useListUsersQuery: () => ({ - data: { users: [{ name: 'scram-admin' }] }, - isLoading: false, - }), + useListUsersQuery: () => ({ data: { users: [] }, isLoading: false }), })); -vi.mock('react-query/api/acl', () => ({ - useDeleteAclMutation: () => ({ mutateAsync: vi.fn() }), - useListACLAsPrincipalGroups: () => listACLsData, +vi.mock('../hooks/use-principal-permissions', () => ({ + usePrincipalPermissions: () => ({ + principalGroups: [ + { + principal: 'User:scram-admin', + principalType: 'User', + principalName: 'scram-admin', + isScramUser: true, + directAcls: [], + roleAclGroups: [], + directAclCount: 0, + inheritedAclCount: 0, + denyCount: 0, + }, + { + principal: 'User:acl-only-user', + principalType: 'User', + principalName: 'acl-only-user', + isScramUser: false, + directAcls: [], + roleAclGroups: [], + directAclCount: 0, + inheritedAclCount: 0, + denyCount: 0, + }, + { + principal: 'Group:engineering', + principalType: 'Group', + principalName: 'engineering', + isScramUser: false, + directAcls: [], + roleAclGroups: [], + directAclCount: 0, + inheritedAclCount: 0, + denyCount: 0, + }, + ], + isAclsLoading: false, + isAclsError: false, + aclsError: null, + isUsersError: false, + usersError: null, + }), })); import { PermissionsListTab } from './permissions-list-tab'; @@ -242,11 +179,11 @@ describe('Permissions List - delete dropdown for different principal types', () test('Group principal does not show "Delete User" options in dropdown', async () => { const user = userEvent.setup(); - render(); + render(, { wrapper: NuqsWrapper }); const groupRow = await screen.findByTestId('row-engineering'); - const deleteButton = within(groupRow).getByRole('button'); - await user.click(deleteButton); + const actionsDiv = within(groupRow).getByTestId('actions-engineering'); + await user.click(within(actionsDiv).getByRole('button')); // Group should only have "Delete (ACLs only)", not user-delete options expect(screen.queryByText('Delete (User and ACLs)')).not.toBeInTheDocument(); @@ -257,12 +194,11 @@ describe('Permissions List - delete dropdown for different principal types', () test('SCRAM user principal has "Delete User" options enabled', async () => { const user = userEvent.setup(); - // "scram-admin" exists in usersData.users — it's a real SCRAM user - render(); + render(, { wrapper: NuqsWrapper }); const scramRow = await screen.findByTestId('row-scram-admin'); - const deleteButton = within(scramRow).getByRole('button'); - await user.click(deleteButton); + const actionsDiv = within(scramRow).getByTestId('actions-scram-admin'); + await user.click(within(actionsDiv).getByRole('button')); // SCRAM user should have all delete options available and enabled const deleteUserAndAcls = screen.getByText('Delete (User and ACLs)'); @@ -277,13 +213,12 @@ describe('Permissions List - delete dropdown for different principal types', () test('Group principal has "Delete (ACLs only)" available', async () => { const user = userEvent.setup(); - render(); + render(, { wrapper: NuqsWrapper }); const groupRow = await screen.findByTestId('row-engineering'); - const deleteButton = within(groupRow).getByRole('button'); - await user.click(deleteButton); + const actionsDiv = within(groupRow).getByTestId('actions-engineering'); + await user.click(within(actionsDiv).getByRole('button')); - // Even though user-delete options are hidden, "Delete (ACLs only)" is always available expect(screen.getByText('Delete (ACLs only)')).toBeInTheDocument(); }); }); diff --git a/frontend/src/components/pages/security/tabs/permissions-list-tab.tsx b/frontend/src/components/pages/security/tabs/permissions-list-tab.tsx index dbd63c3a92..813a60d353 100644 --- a/frontend/src/components/pages/security/tabs/permissions-list-tab.tsx +++ b/frontend/src/components/pages/security/tabs/permissions-list-tab.tsx @@ -10,9 +10,19 @@ */ import { create } from '@bufbuild/protobuf'; -import { DataTable, SearchField } from '@redpanda-data/ui'; import { Link } from '@tanstack/react-router'; -import { TrashIcon } from 'components/icons'; +import { MoreHorizontalIcon } from 'components/icons'; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from 'components/redpanda-ui/components/empty'; +import { ListLayout, ListLayoutContent, ListLayoutFilters } from 'components/redpanda-ui/components/list-layout'; +import { ChevronDown, ChevronRight, ExternalLink, KeyRoundIcon, ShieldIcon } from 'lucide-react'; +import { parseAsString, useQueryState } from 'nuqs'; import { ACL_Operation, ACL_PermissionType, @@ -21,18 +31,18 @@ import { DeleteACLsRequestSchema, } from 'protogen/redpanda/api/dataplane/v1/acl_pb'; import type { FC } from 'react'; -import { useState } from 'react'; +import { useLayoutEffect, useState } from 'react'; import { toast } from 'sonner'; +import { pluralizeWithNumber } from 'utils/string'; import ErrorResult from '../../../../components/misc/error-result'; import { useDeleteAclMutation } from '../../../../react-query/api/acl'; import { useDeleteUserMutation, useInvalidateUsersCache } from '../../../../react-query/api/user'; -import { appGlobal } from '../../../../state/app-global'; -import { api, useApiStoreHook } from '../../../../state/backend-api'; +import { api } from '../../../../state/backend-api'; import { AclRequestDefault } from '../../../../state/rest-interfaces'; import { useSupportedFeaturesStore } from '../../../../state/supported-features'; +import { setPageHeader } from '../../../../state/ui-state'; import { Code as CodeEl } from '../../../../utils/tsx-utils'; -import Section from '../../../misc/section'; import { Alert, AlertDescription, AlertTitle } from '../../../redpanda-ui/components/alert'; import { Badge } from '../../../redpanda-ui/components/badge'; import { Button } from '../../../redpanda-ui/components/button'; @@ -42,115 +52,247 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '../../../redpanda-ui/components/dropdown-menu'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../../redpanda-ui/components/tooltip'; -import { type PrincipalEntry, usePrincipalList } from '../hooks/use-principal-list'; -import { useSecurityBreadcrumbs } from '../hooks/use-security-breadcrumbs'; +import { Skeleton } from '../../../redpanda-ui/components/skeleton'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../../redpanda-ui/components/table'; +import { Text } from '../../../redpanda-ui/components/typography'; +import { type PrincipalPermissionGroup, usePrincipalPermissions } from '../hooks/use-principal-permissions'; import { AlertDeleteFailed } from '../shared/alert-delete-failed'; import { DeleteUserConfirmModal } from '../shared/delete-user-confirm-modal'; -import { filterByName } from '../shared/filter-by-name'; -import { UserRoleTags } from '../shared/user-role-tags'; +import { DescriptionWithHelp } from '../shared/description-with-help'; +import { SecurityTabsNav } from '../shared/security-tabs-nav'; +import { AddAclDialog } from '../users/add-acl-dialog'; -const getCreateUserButtonProps = ( - isAdminApiConfigured: boolean, - featureCreateUser: boolean, - canManageUsers: boolean | undefined -) => { - const hasRBAC = canManageUsers !== undefined; +const AclTableRow: FC<{ + resourceType: string; + resourceName: string; + operation: string; + permissionType: 'Allow' | 'Deny'; + host: string; + editHref?: string; +}> = ({ resourceType, resourceName, operation, permissionType, host, editHref }) => ( + + {resourceType} + {resourceName} + {operation} + {permissionType} + {host} + + {editHref && ( + + + + )} + + +); - return { - disabled: !(isAdminApiConfigured && featureCreateUser) || (hasRBAC && canManageUsers === false), - tooltip: [ - !isAdminApiConfigured && 'The Redpanda Admin API is not configured.', - !featureCreateUser && "Your cluster doesn't support this feature.", - hasRBAC && canManageUsers === false && 'You need RedpandaCapability.MANAGE_REDPANDA_USERS permission.', - ] - .filter(Boolean) - .join(' '), - }; +type PrincipalRowProps = { + group: PrincipalPermissionGroup; + isExpanded: boolean; + onToggle: () => void; + onDelete: (deleteUser: boolean, deleteAcls: boolean) => void; + canDeleteUser: boolean; }; -const PermissionsListActions = ({ - entry, - canDeleteUser, - onDelete, -}: { - entry: PrincipalEntry; - canDeleteUser: boolean; - onDelete: (entry: PrincipalEntry, deleteUser: boolean, deleteAcls: boolean) => Promise; -}) => { - const [pendingAction, setPendingAction] = useState<'user-and-acls' | 'user-only' | null>(null); +const PrincipalRow: FC = ({ group, isExpanded, onToggle, onDelete, canDeleteUser }) => { + const [pendingDelete, setPendingDelete] = useState<'user-and-acls' | 'user-only' | null>(null); + + const summaryText = (() => { + if (group.directAclCount > 0 && group.inheritedAclCount > 0) { + return `${pluralizeWithNumber(group.directAclCount, 'direct ACL')}, ${pluralizeWithNumber(group.inheritedAclCount, 'ACL')} inherited from roles`; + } + if (group.inheritedAclCount > 0) { + return `${pluralizeWithNumber(group.inheritedAclCount, 'ACL')} inherited from roles`; + } + if (group.directAclCount > 0) { + return pluralizeWithNumber(group.directAclCount, 'direct ACL'); + } + return 'No ACLs'; + })(); + + const hasAcls = group.directAclCount + group.inheritedAclCount > 0; return ( <> { - if (pendingAction === 'user-and-acls') await onDelete(entry, true, true); - if (pendingAction === 'user-only') await onDelete(entry, true, false); + if (pendingDelete === 'user-and-acls') onDelete(true, true); + if (pendingDelete === 'user-only') onDelete(true, false); }} onOpenChange={(open) => { - if (!open) setPendingAction(null); + if (!open) setPendingDelete(null); }} - open={pendingAction !== null} - userName={entry.name} + open={pendingDelete !== null} + userName={group.principalName} /> - - - - - - {entry.principalType !== 'Group' && ( - <> - { - e.stopPropagation(); - setPendingAction('user-and-acls'); - }} - > - Delete (User and ACLs) - - { - e.stopPropagation(); - setPendingAction('user-only'); - }} - > - Delete (User only) - - + +
+ {/* Principal header row */} +
e.key === 'Enter' && onToggle()} + role="button" + tabIndex={0} + > + {isExpanded ? ( + + ) : ( + )} - { - e.stopPropagation(); - onDelete(entry, false, true).catch(() => {}); - }} + +
+ + {group.principalType === 'Group' ? 'Group:' : ''} + {group.principalName} + + {group.principalType === 'Group' && Group} + {summaryText} + {group.denyCount > 0 && {group.denyCount} deny} +
+ +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + role="presentation" > - Delete (ACLs only) - - - + {group.principalType === 'User' && ( + e.stopPropagation()} + params={{ userName: group.principalName }} + to="/security/users/$userName/details" + > + + + )} + + + + + + + {group.principalType === 'User' && ( + <> + { + e.stopPropagation(); + setPendingDelete('user-and-acls'); + }} + > + Delete (User and ACLs) + + { + e.stopPropagation(); + setPendingDelete('user-only'); + }} + > + Delete (User only) + + + )} + { + e.stopPropagation(); + onDelete(false, true); + }} + > + Delete (ACLs only) + + + +
+
+ + {/* Expanded content */} + {isExpanded && hasAcls && ( +
+ + + + Type + Resource + Operation + Permission + Host + + + + + {group.directAcls.map((acl, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: no stable key + + ))} + + {group.roleAclGroups.map((rg) => ( + + + +
+ + Via Role: {rg.roleName} +
+
+
+ {rg.acls.map((acl, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: no stable key + + ))} +
+ ))} +
+
+ )} + + {isExpanded && !hasAcls && ( + + + No ACLs assigned + + + )} +
); }; export const PermissionsListTab: FC = () => { - useSecurityBreadcrumbs([]); - const [searchQuery, setSearchQuery] = useState(''); + useLayoutEffect(() => { + setPageHeader('Security', [ + { title: 'Security', linkTo: '/security/users' }, + { title: 'Permissions', linkTo: '/security/permissions-list' }, + ]); + }, []); const [aclFailed, setAclFailed] = useState<{ err: unknown } | null>(null); - const featureCreateUser = useSupportedFeaturesStore((s) => s.createUser); + const [searchQuery, setSearchQuery] = useQueryState('search', parseAsString.withDefault('')); + const [expanded, setExpanded] = useState>(new Set()); + const [createAclOpen, setCreateAclOpen] = useState(false); const featureDeleteUser = useSupportedFeaturesStore((s) => s.deleteUser); - const userData = useApiStoreHook((s) => s.userData); const { mutateAsync: deleteACLMutation } = useDeleteAclMutation(); const { mutateAsync: deleteUserMutation } = useDeleteUserMutation(); const invalidateUsersCache = useInvalidateUsersCache(); - const { principals, isAdminApiConfigured, isUsersError, usersError, isAclsError, aclsError } = usePrincipalList(); + const { principalGroups, isAclsLoading, isAclsError, aclsError, isUsersError, usersError } = + usePrincipalPermissions(); + + const toggleExpanded = (principal: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(principal)) { + next.delete(principal); + } else { + next.add(principal); + } + return next; + }); + }; const deleteACLsForPrincipal = async (principalName: string, principalType: 'User' | 'Group' = 'User') => { const deleteRequest = create(DeleteACLsRequestSchema, { @@ -172,23 +314,20 @@ export const PermissionsListTab: FC = () => { ); }; - // Best-effort delete: ACL and user deletions are independent operations. - // If ACL deletion fails, we still attempt user deletion (and vice versa). - // Any failure is surfaced via the AlertDeleteFailed banner. - const onDelete = async (entry: PrincipalEntry, deleteUser: boolean, deleteAcls: boolean) => { + const onDelete = async (group: PrincipalPermissionGroup, deleteUser: boolean, deleteAcls: boolean) => { if (deleteAcls) { try { - await deleteACLsForPrincipal(entry.name, entry.principalType); + await deleteACLsForPrincipal(group.principalName, group.principalType); } catch (err: unknown) { setAclFailed({ err }); } } if (deleteUser) { try { - await deleteUserMutation({ name: entry.name }); + await deleteUserMutation({ name: group.principalName }); toast.success( - Deleted user {entry.name} + Deleted user {group.principalName} ); } catch (err: unknown) { @@ -198,6 +337,19 @@ export const PermissionsListTab: FC = () => { await Promise.allSettled([api.refreshAcls(AclRequestDefault, true), invalidateUsersCache()]); }; + const matchesSearch = (group: PrincipalPermissionGroup, query: string): boolean => { + if (!query) return true; + const q = query.toLowerCase(); + if (group.principalName.toLowerCase().includes(q)) return true; + if (group.principal.toLowerCase().includes(q)) return true; + if (group.directAcls.some((a) => a.resourceName.toLowerCase().includes(q))) return true; + if (group.roleAclGroups.some((rg) => rg.roleName.toLowerCase().includes(q))) return true; + if (group.roleAclGroups.some((rg) => rg.acls.some((a) => a.resourceName.toLowerCase().includes(q)))) return true; + return false; + }; + + const filteredGroups = principalGroups.filter((g) => matchesSearch(g, searchQuery)); + if (isUsersError && usersError) { return ( @@ -211,113 +363,103 @@ export const PermissionsListTab: FC = () => { return ; } - const usersFiltered = filterByName(principals, searchQuery, (u) => u.name); - - return ( -
-
- This page provides a detailed overview of all effective permissions for each principal, including those derived - from assigned roles. While the ACLs tab shows permissions directly granted to principals, this tab also - incorporates roles that may assign additional permissions to a principal. This gives you a complete picture of - what each principal can do within your cluster. + const renderContent = () => { + if (isAclsLoading) { + return ( +
+ {[0, 1, 2].map((i) => ( +
+ + + +
+ ))} +
+ ); + } + if (filteredGroups.length === 0) { + if (searchQuery) { + return
No principals match your search.
; + } + return ( +
+ + + + + + No permissions yet + + A unified view of all principal permissions across your cluster. Create an ACL to get started. + + + +
+ + +
+
+
+
+ ); + } + return ( +
+ {filteredGroups.map((group) => ( + { + onDelete(group, deleteUser, deleteAcls).catch(() => {}); + }} + onToggle={() => toggleExpanded(group.principal)} + /> + ))}
+ ); + }; - + return ( + <> + + + + + + A unified view of all principal permissions across your cluster, including direct ACLs and those inherited + from role bindings. Inherited ACLs are read-only here and must be edited on the respective role page. + + + -
- setAclFailed(null)} /> -
- - columns={[ - { - id: 'name', - size: Number.POSITIVE_INFINITY, - header: 'Principal', - cell: (ctx) => { - const entry = ctx.row.original; - if (entry.principalType === 'Group') { - return ( - - - {entry.name} - Group - - - ); - } - return ( - - {entry.name} - - ); - }, - }, - { - id: 'assignedRoles', - header: 'Permissions', - cell: (ctx) => { - const entry = ctx.row.original; - return ; - }, - }, - { - size: 60, - id: 'menu', - header: '', - cell: ({ row: { original: entry } }) => ( - - ), - }, - ]} - data={usersFiltered} - emptyAction={(() => { - const { disabled, tooltip } = getCreateUserButtonProps( - isAdminApiConfigured, - featureCreateUser, - userData?.canManageUsers - ); - return ( - - - - - - {tooltip && {tooltip}} - - - ); - })()} - emptyText="No principals yet" - pagination - sorting + setCreateAclOpen(true)}>Create ACL}> + setSearchQuery(e.target.value)} + placeholder="Search principals, resources, roles..." + value={searchQuery} /> -
-
-
+ + + {aclFailed !== null && setAclFailed(null)} />} + + {renderContent()} + + + + ); }; diff --git a/frontend/src/components/pages/security/tabs/roles-tab.test.tsx b/frontend/src/components/pages/security/tabs/roles-tab.test.tsx index 330746d015..41258d3526 100644 --- a/frontend/src/components/pages/security/tabs/roles-tab.test.tsx +++ b/frontend/src/components/pages/security/tabs/roles-tab.test.tsx @@ -11,9 +11,18 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { NuqsTestingAdapter } from 'nuqs/adapters/testing'; import type { ReactNode } from 'react'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { TooltipProvider } from '../../../redpanda-ui/components/tooltip'; + +const NuqsWrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +); + const { historyPushMock, refreshRoleMembersMock, refreshRolesMock, deleteRoleMutationMock } = vi.hoisted(() => ({ historyPushMock: vi.fn(), refreshRoleMembersMock: vi.fn().mockResolvedValue(undefined), @@ -206,20 +215,16 @@ vi.mock('@tanstack/react-router', async (importOriginal) => { vi.mock('../shared/delete-role-confirm-modal', () => ({ DeleteRoleConfirmModal: ({ - buttonEl, onConfirm, roleName, }: { - buttonEl: ReactNode; onConfirm: () => Promise | void; roleName: string; + [key: string]: unknown; }) => ( -
- {buttonEl} - -
+ ), })); @@ -290,7 +295,12 @@ vi.mock('../../../misc/section', () => ({ default: ({ children }: { children?: ReactNode }) =>
{children}
, })); +vi.mock('../shared/security-tabs-nav', () => ({ + SecurityTabsNav: () => null, +})); + vi.mock('react-query/api/security', () => ({ + useCreateRoleMutation: () => ({ mutateAsync: vi.fn().mockResolvedValue(undefined) }), useDeleteRoleMutation: () => ({ mutateAsync: deleteRoleMutationMock, }), @@ -310,18 +320,8 @@ describe('RolesTab role navigation', () => { vi.clearAllMocks(); }); - test('navigates role edit actions to the encoded update route', async () => { - const user = userEvent.setup(); - - render(); - - await user.click(await screen.findByLabelText('Edit role topic reader/qa')); - - expect(historyPushMock).toHaveBeenCalledWith('/security/roles/topic%20reader%2Fqa/update'); - }); - test('renders role list from useListRolesQuery', async () => { - render(); + render(, { wrapper: NuqsWrapper }); await expect(screen.findByTestId('role-list-item-topic reader/qa')).resolves.toBeInTheDocument(); }); @@ -329,7 +329,7 @@ describe('RolesTab role navigation', () => { test('delete role calls deleteRoleMutation with correct arguments', async () => { const user = userEvent.setup(); - render(); + render(, { wrapper: NuqsWrapper }); await user.click(await screen.findByTestId('mock-confirm-delete-topic reader/qa')); diff --git a/frontend/src/components/pages/security/tabs/roles-tab.tsx b/frontend/src/components/pages/security/tabs/roles-tab.tsx index db481761a2..f8bdae4771 100644 --- a/frontend/src/components/pages/security/tabs/roles-tab.tsx +++ b/frontend/src/components/pages/security/tabs/roles-tab.tsx @@ -10,40 +10,174 @@ */ import { create } from '@bufbuild/protobuf'; -import { DataTable, SearchField } from '@redpanda-data/ui'; import { Link } from '@tanstack/react-router'; -import { EditIcon, TrashIcon } from 'components/icons'; +import { + type ColumnDef, + type ColumnFiltersState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type Row, + type SortingState, + type Updater, + useReactTable, +} from '@tanstack/react-table'; +import { MoreHorizontalIcon } from 'components/icons'; +import { RoleCreateDialog } from 'components/pages/security/roles/role-create-dialog'; +import { DeleteRoleConfirmModal } from 'components/pages/security/shared/delete-role-confirm-modal'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from 'components/redpanda-ui/components/dropdown-menu'; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from 'components/redpanda-ui/components/empty'; +import { + ListLayout, + ListLayoutContent, + ListLayoutFilters, + ListLayoutPagination, + ListLayoutSearchInput, +} from 'components/redpanda-ui/components/list-layout'; +import { Skeleton } from 'components/redpanda-ui/components/skeleton'; +import { Text } from 'components/redpanda-ui/components/typography'; +import { ShieldCheckIcon } from 'lucide-react'; +import { parseAsString, useQueryStates } from 'nuqs'; import { DeleteRoleRequestSchema } from 'protogen/redpanda/api/dataplane/v1/security_pb'; import type { FC } from 'react'; -import { useState } from 'react'; +import { useLayoutEffect, useState } from 'react'; import ErrorResult from '../../../../components/misc/error-result'; import { useDeleteRoleMutation, useListRolesQuery } from '../../../../react-query/api/security'; -import { appGlobal } from '../../../../state/app-global'; import { rolesApi, useApiStoreHook } from '../../../../state/backend-api'; import { useSupportedFeaturesStore } from '../../../../state/supported-features'; +import { setPageHeader } from '../../../../state/ui-state'; import { FeatureLicenseNotification } from '../../../license/feature-license-notification'; import { NullFallbackBoundary } from '../../../misc/null-fallback-boundary'; -import Section from '../../../misc/section'; import { Button } from '../../../redpanda-ui/components/button'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../../redpanda-ui/components/tooltip'; -import { useSecurityBreadcrumbs } from '../hooks/use-security-breadcrumbs'; -import { DeleteRoleConfirmModal } from '../shared/delete-role-confirm-modal'; -import { filterByName } from '../shared/filter-by-name'; +import { DataTableColumnHeader, DataTablePagination } from '../../../redpanda-ui/components/data-table'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../../redpanda-ui/components/table'; +import { Tooltip, TooltipContent, TooltipTrigger } from '../../../redpanda-ui/components/tooltip'; +import { DescriptionWithHelp } from '../shared/description-with-help'; +import { SecurityTabsNav } from '../shared/security-tabs-nav'; + +type RoleEntry = { + name: string; + members: unknown[]; +}; + +const nameFilterFn = (row: Row, columnId: string, filterValue: string) => { + if (!filterValue) return true; + try { + return new RegExp(filterValue, 'i').test(String(row.getValue(columnId))); + } catch { + return String(row.getValue(columnId)).toLowerCase().includes(filterValue.toLowerCase()); + } +}; export const RolesTab: FC = () => { - useSecurityBreadcrumbs([]); + useLayoutEffect(() => { + setPageHeader('Security', [ + { title: 'Security', linkTo: '/security/users' }, + { title: 'Roles', linkTo: '/security/roles' }, + ]); + }, []); const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); const userData = useApiStoreHook((s) => s.userData); - const [searchQuery, setSearchQuery] = useState(''); - const { data: rolesData, isError: rolesIsError, error: rolesError } = useListRolesQuery(); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [sorting, setSorting] = useState([]); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + + const [urlFilterParams, setUrlFilterParams] = useQueryStates({ + name: parseAsString, + }); + + const columnFilters: ColumnFiltersState = urlFilterParams.name ? [{ id: 'name', value: urlFilterParams.name }] : []; + + const handleColumnFiltersChange = (updater: Updater) => { + const next = typeof updater === 'function' ? updater(columnFilters) : updater; + const nameFilter = next.find((f) => f.id === 'name'); + setUrlFilterParams({ name: (nameFilter?.value as string) || null }); + }; + + const { data: rolesData, isLoading: rolesLoading, isError: rolesIsError, error: rolesError } = useListRolesQuery(); const { mutateAsync: deleteRoleMutation } = useDeleteRoleMutation(); - const roles = filterByName(rolesData?.roles ?? [], searchQuery, (r) => r.name); + const rolesWithMembers: RoleEntry[] = (rolesData?.roles ?? []).map((r) => ({ + name: r.name, + members: rolesApi.roleMembers.get(r.name) ?? [], + })); + + const pagination: PaginationState = { pageIndex, pageSize }; + + const handlePaginationChange = (updater: Updater) => { + const next = typeof updater === 'function' ? updater(pagination) : updater; + setPageIndex(next.pageIndex); + setPageSize(next.pageSize); + }; + + const columns: ColumnDef[] = [ + { + accessorKey: 'name', + header: ({ column }) => , + cell: ({ row: { original: entry } }) => ( + + {entry.name} + + ), + filterFn: nameFilterFn, + }, + { + id: 'assignedPrincipals', + header: 'Assigned principals', + enableSorting: false, + cell: ({ row: { original: entry } }) => entry.members.length, + }, + { + id: 'menu', + header: '', + enableSorting: false, + meta: { align: 'right' as const }, + cell: ({ row: { original: entry } }) => ( + { + await deleteRoleMutation(create(DeleteRoleRequestSchema, { roleName: entry.name, deleteAcls: true })); + }} + roleName={entry.name} + /> + ), + }, + ]; - const rolesWithMembers = roles.map((r) => { - const members = rolesApi.roleMembers.get(r.name) ?? []; - return { name: r.name, members }; + const table = useReactTable({ + data: rolesWithMembers, + columns, + state: { sorting, pagination, columnFilters }, + onSortingChange: setSorting, + onPaginationChange: handlePaginationChange, + onColumnFiltersChange: handleColumnFiltersChange, + autoResetPageIndex: false, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), }); if (rolesIsError) { @@ -59,114 +193,172 @@ export const RolesTab: FC = () => { .filter(Boolean) .join(' '); + const renderBody = () => { + if (rolesLoading) { + return [0, 1, 2].map((i) => ( + + + + + + + + + + )); + } + if (table.getRowModel().rows.length) { + return table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )); + } + return ( + + + + + + + + No roles yet + + Roles are groups of ACLs that can be assigned to principals. Create one to start managing access + control. + + + +
+ + +
+
+
+
+
+ ); + }; + return ( -
-
- This tab displays all roles. Roles are groups of access control lists (ACLs) that can be assigned to principals. - A principal represents any entity that can be authenticated, such as a user, service, or system (for example, a - SASL-SCRAM user, OIDC identity, or mTLS client). -
- - - - -
- - - - - - {createRoleTooltip && {createRoleTooltip}} - - - -
- { - const entry = ctx.row.original; - return ( - - {entry.name} - - ); - }, - }, - { - id: 'assignedPrincipals', - header: 'Assigned principals', - cell: (ctx) => <>{ctx.row.original.members.length}, - }, - { - size: 60, - id: 'menu', - header: '', - cell: (ctx) => { - const entry = ctx.row.original; - return ( -
- - - - - } - numberOfPrincipals={entry.members.length} - onConfirm={async () => { - await deleteRoleMutation( - create(DeleteRoleRequestSchema, { roleName: entry.name, deleteAcls: true }) - ); - }} - roleName={entry.name} - /> -
- ); - }, - }, - ]} - data={rolesWithMembers} - pagination - sorting + <> + + + + + + Roles are groups of access control lists (ACLs) that can be assigned to principals. A principal represents + any entity that can be authenticated, such as a user, service, or system (for example, a SASL-SCRAM user, + OIDC identity, or mTLS client). + + {' '} + + + + + + + + + + {createRoleTooltip && {createRoleTooltip}} + + } + > + table.getColumn('name')?.setFilterValue(e.target.value || undefined)} + placeholder="Filter by name (regexp)..." + value={(table.getColumn('name')?.getFilterValue() as string) ?? ''} /> -
-
-
+ + + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + {renderBody()} +
+
+ + + + + + + + + ); +}; + +const RoleActions = ({ + roleName, + memberCount, + onDelete, +}: { + roleName: string; + memberCount: number; + onDelete: () => Promise; +}) => { + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + + return ( + <> + + + + + + + { + e.stopPropagation(); + setIsDeleteOpen(true); + }} + > + Delete + + + + ); }; diff --git a/frontend/src/components/pages/security/tabs/users-tab.tsx b/frontend/src/components/pages/security/tabs/users-tab.tsx index d5de970702..c43208a245 100644 --- a/frontend/src/components/pages/security/tabs/users-tab.tsx +++ b/frontend/src/components/pages/security/tabs/users-tab.tsx @@ -9,36 +9,109 @@ * by the Apache License, Version 2.0 */ -import { DataTable, SearchField } from '@redpanda-data/ui'; +import { useQuery } from '@connectrpc/connect-query'; import { Link } from '@tanstack/react-router'; +import { + type ColumnDef, + type ColumnFiltersState, + flexRender, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type Row, + type SortingState, + type Updater, + useReactTable, +} from '@tanstack/react-table'; import { MoreHorizontalIcon } from 'components/icons'; -import { parseAsString } from 'nuqs'; +import { DescriptionWithHelp } from 'components/pages/security/shared/description-with-help'; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from 'components/redpanda-ui/components/empty'; +import { + ListLayout, + ListLayoutContent, + ListLayoutFilters, + ListLayoutPagination, + ListLayoutSearchInput, +} from 'components/redpanda-ui/components/list-layout'; +import { UsersIcon } from 'lucide-react'; +import { parseAsArrayOf, parseAsString, useQueryStates } from 'nuqs'; import type { FC } from 'react'; -import { useState } from 'react'; +import { useLayoutEffect, useState } from 'react'; -import { useQueryStateWithCallback } from '../../../../hooks/use-query-state-with-callback'; +import type { ListACLsRequest } from '../../../../protogen/redpanda/api/dataplane/v1/acl_pb'; +import { listACLs } from '../../../../protogen/redpanda/api/dataplane/v1/acl-ACLService_connectquery'; +import { SASLMechanism } from '../../../../protogen/redpanda/api/dataplane/v1/user_pb'; import { useGetRedpandaInfoQuery } from '../../../../react-query/api/cluster-status'; import { useDeleteUserMutation, useInvalidateUsersCache, useListUsersQuery } from '../../../../react-query/api/user'; -import { appGlobal } from '../../../../state/app-global'; import { rolesApi, useApiStoreHook } from '../../../../state/backend-api'; import { useSupportedFeaturesStore } from '../../../../state/supported-features'; -import Section from '../../../misc/section'; +import { setPageHeader } from '../../../../state/ui-state'; import { Alert, AlertDescription, AlertTitle } from '../../../redpanda-ui/components/alert'; +import { Badge } from '../../../redpanda-ui/components/badge'; import { Button } from '../../../redpanda-ui/components/button'; +import { + DataTableColumnHeader, + DataTableFacetedFilter, + DataTablePagination, +} from '../../../redpanda-ui/components/data-table'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '../../../redpanda-ui/components/dropdown-menu'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../../redpanda-ui/components/tooltip'; -import { useSecurityBreadcrumbs } from '../hooks/use-security-breadcrumbs'; +import { Skeleton } from '../../../redpanda-ui/components/skeleton'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../../redpanda-ui/components/table'; +import { TagsValue } from '../../../redpanda-ui/components/tags'; +import { Tooltip, TooltipTrigger } from '../../../redpanda-ui/components/tooltip'; +import { Text } from '../../../redpanda-ui/components/typography'; import { DeleteUserConfirmModal } from '../shared/delete-user-confirm-modal'; -import { filterByName } from '../shared/filter-by-name'; -import { UserRoleTags } from '../shared/user-role-tags'; -import { ChangePasswordModal, ChangeRolesModal } from '../users/user-edit-modals'; +import { SecurityTabsNav } from '../shared/security-tabs-nav'; +import { CreateUserDialog } from '../users/user-create-dialog'; +import { ChangePasswordModal } from '../users/user-edit-modals'; + +type PrincipalEntry = { + name: string; + principalType: 'User' | 'Group'; + isScramUser: boolean; + mechanism?: SASLMechanism; +}; + +const mechanismLabel = (mechanism?: SASLMechanism) => { + if (mechanism === SASLMechanism.SASL_MECHANISM_SCRAM_SHA_512) return 'SCRAM-SHA-512'; + if (mechanism === SASLMechanism.SASL_MECHANISM_SCRAM_SHA_256) return 'SCRAM-SHA-256'; + return null; +}; + +const nameFilterFn = (row: Row, columnId: string, filterValue: string) => { + if (!filterValue) return true; + try { + return new RegExp(filterValue, 'i').test(String(row.getValue(columnId))); + } catch { + return String(row.getValue(columnId)).toLowerCase().includes(filterValue.toLowerCase()); + } +}; -type PrincipalEntry = { name: string; principalType: 'User' | 'Group'; isScramUser: boolean }; +const mechanismFilterFn = (row: Row, columnId: string, filterValues: string[]) => { + if (!filterValues?.length) return true; + return filterValues.includes(String(row.getValue(columnId))); +}; + +const mechanismOptions = [ + { label: 'SCRAM-SHA-256', value: 'scram-sha-256' }, + { label: 'SCRAM-SHA-512', value: 'scram-sha-512' }, +]; const getCreateUserButtonProps = ( isAdminApiConfigured: boolean, @@ -60,24 +133,46 @@ const getCreateUserButtonProps = ( }; export const UsersTab: FC = () => { - useSecurityBreadcrumbs([]); + useLayoutEffect(() => { + setPageHeader('Security', [ + { title: 'Security', linkTo: '/security' }, + { title: 'Users', linkTo: '/security/users' }, + ]); + }, []); const { data: redpandaInfo, isSuccess: isRedpandaInfoSuccess } = useGetRedpandaInfoQuery(); const isAdminApiConfigured = isRedpandaInfoSuccess && Boolean(redpandaInfo); const featureCreateUser = useSupportedFeaturesStore((s) => s.createUser); const userData = useApiStoreHook((s) => s.userData); - const [searchQuery, setSearchQuery] = useQueryStateWithCallback( - { - onUpdate: () => { - // Query state is managed by the URL - }, - getDefaultValue: () => '', - }, - 'q', - parseAsString.withDefault('') - ); + + const [sorting, setSorting] = useState([]); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [createDialogKey, setCreateDialogKey] = useState(0); + const [urlFilterParams, setUrlFilterParams] = useQueryStates({ + name: parseAsString, + mechanism: parseAsArrayOf(parseAsString), + }); + + const columnFilters: ColumnFiltersState = [ + ...(urlFilterParams.name ? [{ id: 'name', value: urlFilterParams.name }] : []), + ...(urlFilterParams.mechanism?.length ? [{ id: 'mechanism', value: urlFilterParams.mechanism }] : []), + ]; + + const handleColumnFiltersChange = (updater: Updater) => { + const next = typeof updater === 'function' ? updater(columnFilters) : updater; + const nameFilter = next.find((f) => f.id === 'name'); + const mechanismFilter = next.find((f) => f.id === 'mechanism'); + setUrlFilterParams({ + name: (nameFilter?.value as string) || null, + mechanism: (mechanismFilter?.value as string[])?.length ? (mechanismFilter?.value as string[]) : null, + }); + }; + const { data: usersData, + isLoading: usersLoading, isError, error, } = useListUsersQuery(undefined, { @@ -88,9 +183,83 @@ export const UsersTab: FC = () => { name: u.name, principalType: 'User' as const, isScramUser: true, + mechanism: u.mechanism, })); - const usersFiltered = filterByName(users, searchQuery, (u) => u.name); + const pagination: PaginationState = { pageIndex, pageSize }; + + const handlePaginationChange = (updater: Updater) => { + const next = typeof updater === 'function' ? updater(pagination) : updater; + setPageIndex(next.pageIndex); + setPageSize(next.pageSize); + }; + + const columns: ColumnDef[] = [ + { + accessorKey: 'name', + header: ({ column }) => , + cell: ({ row: { original: entry } }) => ( + + {entry.name} + + ), + filterFn: nameFilterFn, + }, + { + id: 'mechanism', + accessorFn: (entry) => mechanismLabel(entry.mechanism)?.toLowerCase() ?? '', + header: 'Mechanism', + enableSorting: false, + filterFn: mechanismFilterFn, + cell: ({ row: { original: entry } }) => { + const label = mechanismLabel(entry.mechanism); + return label ? ( + {label} + ) : ( + + ); + }, + }, + { + id: 'roles', + header: 'Roles', + enableSorting: false, + cell: ({ row: { original: entry } }) => , + }, + { + id: 'acls', + header: 'ACLs', + enableSorting: false, + cell: ({ row: { original: entry } }) => , + }, + { + id: 'menu', + header: '', + enableSorting: false, + meta: { align: 'right' as const }, + cell: ({ row: { original: entry } }) => , + }, + ]; + + const table = useReactTable({ + data: users, + columns, + state: { sorting, pagination, columnFilters }, + onSortingChange: setSorting, + onPaginationChange: handlePaginationChange, + onColumnFiltersChange: handleColumnFiltersChange, + autoResetPageIndex: false, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + getPaginationRowModel: getPaginationRowModel(), + }); if (isError && error) { return ( @@ -101,105 +270,197 @@ export const UsersTab: FC = () => { ); } - const { disabled: createDisabled, tooltip: createTooltip } = getCreateUserButtonProps( + const { disabled: createDisabled } = getCreateUserButtonProps( isAdminApiConfigured, featureCreateUser, userData?.canManageUsers ); + const renderBody = () => { + if (usersLoading) { + return [0, 1, 2].map((i) => ( + + + + + + + + + + + + + + + + )); + } + if (table.getRowModel().rows.length) { + return table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )); + } + return ( + + + + + + + + No users yet + + SASL-SCRAM user accounts managed by your cluster. Create one to start managing access. + + + +
+ + +
+
+
+
+
+ ); + }; + return ( -
-
- These users are SASL-SCRAM users managed by your cluster. View permissions for other authentication identities - (for example, OIDC, mTLS) on the Permissions List page. -
- - setSearchQuery(x)} - width="300px" - /> + <> + + + + + + These users are SASL-SCRAM users managed by your cluster. View permissions for other authentication + identities (for example, OIDC, mTLS) on the Permissions List page. + + -
- - - - - - {createTooltip && {createTooltip}} - - - -
- - columns={[ - { - id: 'name', - size: Number.POSITIVE_INFINITY, - header: 'User', - cell: (ctx) => { - const entry = ctx.row.original; - return ( - - {entry.name} - - ); - }, - }, - { - id: 'assignedRoles', - header: 'Permissions', - cell: (ctx) => { - const entry = ctx.row.original; - return ; - }, - }, - { - size: 60, - id: 'menu', - header: '', - cell: (ctx) => { - const entry = ctx.row.original; - return ; - }, - }, - ]} - data={usersFiltered} - emptyAction={ - - } - emptyText="No users yet" - pagination - sorting + + + + + + } + > + table.getColumn('name')?.setFilterValue(e.target.value || undefined)} + placeholder="Filter by name (regexp)..." + value={(table.getColumn('name')?.getFilterValue() as string) ?? ''} /> -
-
+ + + + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + {renderBody()} +
+
+ + + + +
+ + ); +}; + +const UserRolesCell = ({ userName }: { userName: string }) => { + const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); + + if (!featureRolesApi) { + return ; + } + + const roles: string[] = []; + for (const [roleName, members] of rolesApi.roleMembers) { + if ( + members.any((m: { name: string; principalType: string }) => m.name === userName && m.principalType === 'User') + ) { + roles.push(roleName); + } + } + + if (roles.length === 0) { + return None; + } + + return ( +
+ {roles.map((r) => ( + + {r} + + ))}
); }; +const UserAclsCell = ({ userName }: { userName: string }) => { + const { data: aclCount } = useQuery(listACLs, { filter: { principal: `User:${userName}` } } as ListACLsRequest, { + enabled: !!userName, + select: (r) => r.resources.length, + }); + + if (!aclCount) { + return None; + } + + return ( + + {`${aclCount} ACL${aclCount !== 1 ? 's' : ''}`} + + ); +}; + const UserActions = ({ user }: { user: PrincipalEntry }) => { - const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); const [isChangePasswordModalOpen, setIsChangePasswordModalOpen] = useState(false); - const [isChangeRolesModalOpen, setIsChangeRolesModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const invalidateUsersCache = useInvalidateUsersCache(); const { mutateAsync: deleteUserMutation } = useDeleteUserMutation(); @@ -236,9 +497,6 @@ const UserActions = ({ user }: { user: PrincipalEntry }) => { setIsOpen={setIsChangePasswordModalOpen} userName={user.name} /> - {Boolean(featureRolesApi) && ( - - )} { > Change password - {Boolean(featureRolesApi) && ( - { - e.stopPropagation(); - setIsChangeRolesModalOpen(true); - }} - > - Change roles - - )} { e.stopPropagation(); diff --git a/frontend/src/components/pages/security/users/add-acl-dialog.tsx b/frontend/src/components/pages/security/users/add-acl-dialog.tsx new file mode 100644 index 0000000000..3ef6616193 --- /dev/null +++ b/frontend/src/components/pages/security/users/add-acl-dialog.tsx @@ -0,0 +1,348 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { create } from '@bufbuild/protobuf'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { + ACL_Operation, + ACL_PermissionType, + ACL_ResourcePatternType, + ACL_ResourceType, + CreateACLRequestSchema, +} from 'protogen/redpanda/api/dataplane/v1/acl_pb'; +import { useMemo, useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { useCreateACLMutation } from '../../../../react-query/api/acl'; +import { useListUsersQuery } from '../../../../react-query/api/user'; +import { Alert, AlertDescription } from '../../../redpanda-ui/components/alert'; +import { Button } from '../../../redpanda-ui/components/button'; +import { Combobox } from '../../../redpanda-ui/components/combobox'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '../../../redpanda-ui/components/dialog'; +import { Input } from '../../../redpanda-ui/components/input'; +import { Label } from '../../../redpanda-ui/components/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../../redpanda-ui/components/select'; + +const schema = z.object({ + resourceType: z.nativeEnum(ACL_ResourceType), + patternType: z.nativeEnum(ACL_ResourcePatternType), + resourceName: z.string(), + operation: z.nativeEnum(ACL_Operation), + permissionType: z.nativeEnum(ACL_PermissionType), + host: z.string(), +}); + +type FormValues = z.infer; + +const RESOURCE_TYPE_OPTIONS = [ + { value: ACL_ResourceType.TOPIC, label: 'Topic' }, + { value: ACL_ResourceType.GROUP, label: 'Consumer Group' }, + { value: ACL_ResourceType.CLUSTER, label: 'Cluster' }, + { value: ACL_ResourceType.TRANSACTIONAL_ID, label: 'Transactional ID' }, + { value: ACL_ResourceType.SUBJECT, label: 'Subject' }, + { value: ACL_ResourceType.REGISTRY, label: 'Schema Registry' }, +]; + +const OPERATION_OPTIONS = [ + { value: ACL_Operation.ALL, label: 'All' }, + { value: ACL_Operation.READ, label: 'Read' }, + { value: ACL_Operation.WRITE, label: 'Write' }, + { value: ACL_Operation.CREATE, label: 'Create' }, + { value: ACL_Operation.DELETE, label: 'Delete' }, + { value: ACL_Operation.ALTER, label: 'Alter' }, + { value: ACL_Operation.DESCRIBE, label: 'Describe' }, + { value: ACL_Operation.DESCRIBE_CONFIGS, label: 'Describe Configs' }, + { value: ACL_Operation.ALTER_CONFIGS, label: 'Alter Configs' }, + { value: ACL_Operation.IDEMPOTENT_WRITE, label: 'Idempotent Write' }, + { value: ACL_Operation.CLUSTER_ACTION, label: 'Cluster Action' }, +]; + +const PATTERN_TYPE_HELP: Partial> = { + [ACL_ResourcePatternType.LITERAL]: 'Matches the exact resource name.', + [ACL_ResourcePatternType.PREFIXED]: 'Matches any resource name starting with this prefix.', + [ACL_ResourcePatternType.ANY]: 'Matches any resource name.', +}; + +type AddAclDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + /** When provided the principal selector is hidden and this value is used directly. */ + principal?: string; +}; + +export const AddAclDialog = ({ open, onOpenChange, principal }: AddAclDialogProps) => { + const { mutateAsync: createACL, isPending } = useCreateACLMutation(); + const [submitError, setSubmitError] = useState(null); + const [principalType, setPrincipalType] = useState<'User' | 'Group'>('User'); + const [principalValue, setPrincipalValue] = useState(''); + + const { data: usersData } = useListUsersQuery(undefined, { enabled: !principal }); + const userOptions = useMemo( + () => (usersData?.users ?? []).map((u) => ({ value: u.name, label: u.name })), + [usersData] + ); + + const effectivePrincipal = principal ?? `${principalType}:${principalValue}`; + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + resourceType: ACL_ResourceType.TOPIC, + patternType: ACL_ResourcePatternType.LITERAL, + resourceName: '', + operation: ACL_Operation.ALL, + permissionType: ACL_PermissionType.ALLOW, + host: '*', + }, + }); + + const resourceType = form.watch('resourceType'); + const patternType = form.watch('patternType'); + + const showPatternAndName = resourceType !== ACL_ResourceType.CLUSTER && resourceType !== ACL_ResourceType.REGISTRY; + + const showResourceName = + showPatternAndName && + (patternType === ACL_ResourcePatternType.LITERAL || patternType === ACL_ResourcePatternType.PREFIXED); + + const resetPrincipalSelector = () => { + setPrincipalType('User'); + setPrincipalValue(''); + }; + + const onSubmit = async (values: FormValues) => { + setSubmitError(null); + try { + await createACL( + create(CreateACLRequestSchema, { + resourceType: values.resourceType, + resourceName: values.resourceName || '*', + resourcePatternType: values.patternType, + principal: effectivePrincipal, + host: values.host || '*', + operation: values.operation, + permissionType: values.permissionType, + }) + ); + onOpenChange(false); + form.reset(); + if (!principal) resetPrincipalSelector(); + } catch (err) { + setSubmitError(err instanceof Error ? err.message : String(err)); + } + }; + + const handleClose = () => { + setSubmitError(null); + onOpenChange(false); + form.reset(); + if (!principal) resetPrincipalSelector(); + }; + + return ( + + + + Add ACL + {principal && Define a new access control rule for {principal}.} + +
+
+ {!principal && ( +
+ +
+ + {principalType === 'User' ? ( + + ) : ( + setPrincipalValue(e.target.value)} + placeholder="Enter group name..." + value={principalValue} + /> + )} +
+
+ )} + +
+ + ( + + )} + /> +
+ + {showPatternAndName && ( +
+ + ( +
+
+ {[ + { value: ACL_ResourcePatternType.LITERAL, label: 'Literal' }, + { value: ACL_ResourcePatternType.PREFIXED, label: 'Prefixed' }, + { value: ACL_ResourcePatternType.ANY, label: 'Any' }, + ].map((opt) => ( + + ))} +
+ {PATTERN_TYPE_HELP[field.value] && ( +

{PATTERN_TYPE_HELP[field.value]}

+ )} +
+ )} + /> +
+ )} + + {showResourceName && ( +
+ + +
+ )} + +
+ + ( + + )} + /> +
+ +
+ + ( + + )} + /> +
+ +
+ +

+ Use * for all hosts, or an exact IP address. CIDR ranges are not supported by the Kafka + API. +

+ +
+ + {submitError && ( + + {submitError} + + )} +
+ + + + + +
+
+
+ ); +}; diff --git a/frontend/src/components/pages/security/users/user-acls-card.test.tsx b/frontend/src/components/pages/security/users/user-acls-card.test.tsx index ab789c9527..f144c0e4ae 100644 --- a/frontend/src/components/pages/security/users/user-acls-card.test.tsx +++ b/frontend/src/components/pages/security/users/user-acls-card.test.tsx @@ -58,33 +58,37 @@ describe('UserAclsCard', () => { test('should render empty state when no ACLs provided', () => { renderWithFileRoutes(); - expect(screen.getByText('ACLs (0)')).toBeInTheDocument(); - expect(screen.getByText('No ACLs assigned to this user.')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Create ACL' })).toBeInTheDocument(); + expect(screen.getByText('No ACLs assigned')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '+ Add ACL' })).toBeInTheDocument(); }); test('should render empty state when acls is undefined', () => { renderWithFileRoutes(); - expect(screen.getByText('ACLs (0)')).toBeInTheDocument(); - expect(screen.getByText('No ACLs assigned to this user.')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Create ACL' })).toBeInTheDocument(); + expect(screen.getByText('No ACLs assigned')).toBeInTheDocument(); }); - test('should render ACL table with rows, action buttons, and headers', () => { + test('should render flat ACL table with correct row count and data', () => { renderWithFileRoutes(); - // Count, rows, action buttons, and headers all rendered together so we assert them once. - expect(screen.getByText('ACLs (2)')).toBeInTheDocument(); + // Resource types + expect(screen.getAllByText('Topic')).toHaveLength(2); + expect(screen.getByText('Cluster')).toBeInTheDocument(); - // Principal and host values per row - expect(screen.getByTestId('acl-principal-User:test-user-*')).toHaveTextContent('User:test-user'); - expect(screen.getByTestId('acl-principal-User:test-user-192.168.1.1')).toHaveTextContent('User:test-user'); - expect(screen.getByTestId('acl-host-*')).toHaveTextContent('*'); - expect(screen.getByTestId('acl-host-192.168.1.1')).toHaveTextContent('192.168.1.1'); + // Resource names + expect(screen.getAllByText('test-topic')).toHaveLength(2); + expect(screen.getByText('kafka-cluster')).toBeInTheDocument(); - // Action buttons per row - expect(screen.getByTestId('toggle-acl-User:test-user-*')).toBeInTheDocument(); - expect(screen.getByTestId('edit-acl-User:test-user-*')).toBeInTheDocument(); + // Operations + expect(screen.getByText('Read')).toBeInTheDocument(); + expect(screen.getByText('Write')).toBeInTheDocument(); + expect(screen.getByText('Describe')).toBeInTheDocument(); + + // Permissions + expect(screen.getAllByText('Allow')).toHaveLength(3); + + // Hosts + expect(screen.getAllByText('*')).toHaveLength(2); + expect(screen.getByText('192.168.1.1')).toBeInTheDocument(); }); }); diff --git a/frontend/src/components/pages/security/users/user-acls-card.tsx b/frontend/src/components/pages/security/users/user-acls-card.tsx index 9793720633..f3a8600acb 100644 --- a/frontend/src/components/pages/security/users/user-acls-card.tsx +++ b/frontend/src/components/pages/security/users/user-acls-card.tsx @@ -9,151 +9,15 @@ * by the Apache License, Version 2.0 */ -import { useNavigate } from '@tanstack/react-router'; -import { Eye, EyeOff, Pencil } from 'lucide-react'; -import { useState } from 'react'; - -import { Button } from '../../../redpanda-ui/components/button'; -import { Card, CardAction, CardContent, CardHeader, CardTitle } from '../../../redpanda-ui/components/card'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../../redpanda-ui/components/table'; -import { type AclDetail, getRuleDataTestId, parsePrincipal } from '../shared/acl-model'; -import { OperationsBadge } from '../shared/operations-badge'; +import type { AclDetail } from '../shared/acl-model'; +import { AclsCard } from '../shared/acls-card'; type UserAclsCardProps = { acls?: AclDetail[]; + userName?: string; + isLoading?: boolean; }; -type AclTableRowProps = { - acl: AclDetail; - isExpanded: boolean; - onToggle: () => void; -}; - -const AclTableRow = ({ acl, isExpanded, onToggle }: AclTableRowProps) => { - const rowKey = `${acl.sharedConfig.principal}-${acl.sharedConfig.host}`; - const navigate = useNavigate(); - - return [ - - {acl.sharedConfig.principal} - {acl.sharedConfig.host} - -
- - -
-
-
, - isExpanded && ( - - -
-
ACL Rules ({acl.rules.length})
- {acl.rules.map((rule) => ( -
- -
- ))} -
-
-
- ), - ]; -}; - -export const UserAclsCard = ({ acls }: UserAclsCardProps) => { - const navigate = useNavigate(); - const [expandedRows, setExpandedRows] = useState>(new Set()); - - const toggleRow = (key: string) => { - setExpandedRows((prev) => { - const next = new Set(prev); - if (next.has(key)) { - next.delete(key); - } else { - next.add(key); - } - return next; - }); - }; - - if (!acls || acls.length === 0) { - return ( - - - ACLs (0) - - - - - -

No ACLs assigned to this user.

-
-
- ); - } - - return ( - - - ACLs ({acls.length}) - - - - - - Name - Hosts - Actions - - - - {acls.flatMap((acl) => { - const rowKey = `${acl.sharedConfig.principal}-${acl.sharedConfig.host}`; - const isExpanded = expandedRows.has(rowKey); - - return ( - toggleRow(rowKey)} - /> - ); - })} - -
-
-
- ); -}; +export const UserAclsCard = ({ acls, userName, isLoading }: UserAclsCardProps) => ( + +); diff --git a/frontend/src/components/pages/security/users/user-create-dialog.tsx b/frontend/src/components/pages/security/users/user-create-dialog.tsx new file mode 100644 index 0000000000..14f06c44f4 --- /dev/null +++ b/frontend/src/components/pages/security/users/user-create-dialog.tsx @@ -0,0 +1,121 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { create } from '@bufbuild/protobuf'; +import { useNavigate } from '@tanstack/react-router'; +import { CreateUserRequest_UserSchema } from 'protogen/redpanda/api/dataplane/v1/user_pb'; +import { useCallback, useState } from 'react'; +import { generatePassword } from 'utils/password'; + +import { CreateUserConfirmationModal, CreateUserModal } from './user-create'; +import { getSASLMechanism, useCreateUserMutation, useListUsersQuery } from '../../../../react-query/api/user'; +import { type SaslMechanism, validatePassword, validateUsername } from '../../../../utils/user'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../../redpanda-ui/components/dialog'; + +type CreateUserDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +export const CreateUserDialog = ({ open, onOpenChange }: CreateUserDialogProps) => { + const [formState, setFormState] = useState({ + username: '', + password: generatePassword(30, false), + mechanism: 'SCRAM-SHA-256' as SaslMechanism, + generateWithSpecialChars: false, + selectedRoles: [] as string[], + }); + const [step, setStep] = useState<'form' | 'confirmation'>('form'); + const [isSubmitting, setIsSubmitting] = useState(false); + + const navigate = useNavigate(); + const { mutateAsync: createUserMutate } = useCreateUserMutation(); + const { data: usersData } = useListUsersQuery(); + const users = usersData?.users?.map((u) => u.name) ?? []; + + const { username, password, mechanism, generateWithSpecialChars, selectedRoles } = formState; + const setUsername = (v: string) => setFormState((prev) => ({ ...prev, username: v })); + const setPassword = (v: string) => setFormState((prev) => ({ ...prev, password: v })); + const setMechanism = (v: SaslMechanism) => setFormState((prev) => ({ ...prev, mechanism: v })); + const setGenerateWithSpecialChars = (v: boolean) => + setFormState((prev) => ({ ...prev, generateWithSpecialChars: v })); + const setSelectedRoles = (v: string[]) => setFormState((prev) => ({ ...prev, selectedRoles: v })); + + const handleClose = useCallback(() => { + onOpenChange(false); + }, [onOpenChange]); + + const onCreateUser = useCallback(async (): Promise => { + setIsSubmitting(true); + try { + await createUserMutate({ + user: create(CreateUserRequest_UserSchema, { + name: username, + password, + mechanism: getSASLMechanism(mechanism), + }), + }); + } catch { + setIsSubmitting(false); + return false; + } + setIsSubmitting(false); + setStep('confirmation'); + return true; + }, [username, password, mechanism, createUserMutate]); + + const onGoToUserDetails = () => { + handleClose(); + navigate({ to: `/security/users/${username}/details` }); + }; + + const state = { + username, + setUsername, + password, + setPassword, + mechanism, + setMechanism, + generateWithSpecialChars, + setGenerateWithSpecialChars, + isCreating: isSubmitting, + isValidUsername: validateUsername(username), + isValidPassword: validatePassword(password), + users, + selectedRoles, + setSelectedRoles, + }; + + return ( + <> + + + {step === 'form' && ( + + Create user + + )} + {step === 'form' ? ( + + ) : ( + + )} + + + + ); +}; diff --git a/frontend/src/components/pages/security/users/user-create.test.tsx b/frontend/src/components/pages/security/users/user-create.test.tsx index f1a615ae89..57110de54d 100644 --- a/frontend/src/components/pages/security/users/user-create.test.tsx +++ b/frontend/src/components/pages/security/users/user-create.test.tsx @@ -46,6 +46,7 @@ vi.mock('config', () => ({ })); vi.mock('state/ui-state', () => ({ + setPageHeader: vi.fn(), uiState: { pageTitle: '', pageBreadcrumbs: [], @@ -59,8 +60,8 @@ vi.mock('utils/password', () => ({ let mockRolesApiEnabled = false; -vi.mock('../../../state/supported-features', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../../../../state/supported-features', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, Features: { ...actual.Features, createUser: true, deleteUser: true, rolesApi: true }, diff --git a/frontend/src/components/pages/security/users/user-create.tsx b/frontend/src/components/pages/security/users/user-create.tsx index dcb19e6b4a..3a225db8b5 100644 --- a/frontend/src/components/pages/security/users/user-create.tsx +++ b/frontend/src/components/pages/security/users/user-create.tsx @@ -14,12 +14,13 @@ import { useNavigate } from '@tanstack/react-router'; import { InfoIcon, LoaderCircleIcon, RotateCwIcon } from 'lucide-react'; import { UpdateRoleMembershipRequestSchema } from 'protogen/redpanda/api/dataplane/v1/security_pb'; import { CreateUserRequest_UserSchema } from 'protogen/redpanda/api/dataplane/v1/user_pb'; -import { useCallback, useState } from 'react'; +import { useCallback, useLayoutEffect, useState } from 'react'; import { generatePassword } from 'utils/password'; import { useListRolesQuery, useUpdateRoleMembershipMutation } from '../../../../react-query/api/security'; import { getSASLMechanism, useCreateUserMutation, useListUsersQuery } from '../../../../react-query/api/user'; import { useSupportedFeaturesStore } from '../../../../state/supported-features'; +import { setPageHeader } from '../../../../state/ui-state'; import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, @@ -37,7 +38,7 @@ import { Input } from '../../../redpanda-ui/components/input'; import { SimpleMultiSelect } from '../../../redpanda-ui/components/multi-select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../../redpanda-ui/components/select'; import { Tooltip, TooltipContent, TooltipTrigger } from '../../../redpanda-ui/components/tooltip'; -import { useSecurityBreadcrumbs } from '../hooks/use-security-breadcrumbs'; +import { Text } from '../../../redpanda-ui/components/typography'; const UserCreatePage = () => { const [formState, setFormState] = useState({ @@ -67,7 +68,12 @@ const UserCreatePage = () => { const isValidUsername = validateUsername(username); const isValidPassword = validatePassword(password); - useSecurityBreadcrumbs([{ title: 'Users', linkTo: '/security/users' }]); + useLayoutEffect(() => { + setPageHeader('Users', [ + { title: 'Security', linkTo: '/security' }, + { title: 'Users', linkTo: '/security/users' }, + ]); + }, []); const onCreateUser = useCallback(async (): Promise => { setIsSubmitting(true); @@ -103,11 +109,7 @@ const UserCreatePage = () => { const navigate = useNavigate(); const onCancel = () => navigate({ to: '/security/users' }); - const onCreateAcls = () => - navigate({ - to: '/security/acls/create', - search: { principalType: 'User', principalName: username }, - }); + const onGoToUserDetails = () => navigate({ to: `/security/users/${username}/details` }); const state = { username, @@ -136,7 +138,7 @@ const UserCreatePage = () => { @@ -161,16 +163,16 @@ type CreateUserModalProps = { isCreating: boolean; isValidUsername: boolean; isValidPassword: boolean; + users: string[]; selectedRoles: string[]; setSelectedRoles: (v: string[]) => void; - users: string[]; }; onCreateUser: () => Promise; onCancel: () => void; }; -const CreateUserModal = ({ state, onCreateUser, onCancel }: CreateUserModalProps) => { - const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); +export const CreateUserModal = ({ state, onCreateUser, onCancel }: CreateUserModalProps) => { + const rolesApiEnabled = useSupportedFeaturesStore((s) => s.rolesApi); const userAlreadyExists = state.users.includes(state.username); const hasError = (!state.isValidUsername || userAlreadyExists) && state.username.length > 0; @@ -194,6 +196,8 @@ const CreateUserModal = ({ state, onCreateUser, onCancel }: CreateUserModalProps state.setUsername(e.target.value)} placeholder="Username" @@ -276,16 +280,15 @@ const CreateUserModal = ({ state, onCreateUser, onCancel }: CreateUserModalProps - - {!!featureRolesApi && ( - - Assign roles - - Assign roles to this user. This is optional and can be changed later. - - )}
+ {rolesApiEnabled && ( +
+ Assign roles + +
+ )} +
-
- - +
+

Assign new user permissions

+

+ To grant access to clusters, assign a role to the user or create ACLs. +

+
+ + +
); diff --git a/frontend/src/components/pages/security/users/user-details.tsx b/frontend/src/components/pages/security/users/user-details.tsx index d22d51dff6..6314a5562e 100644 --- a/frontend/src/components/pages/security/users/user-details.tsx +++ b/frontend/src/components/pages/security/users/user-details.tsx @@ -11,11 +11,11 @@ import { Button } from 'components/redpanda-ui/components/button'; import type { UpdateRoleMembershipResponse } from 'protogen/redpanda/api/console/v1alpha1/security_pb'; -import { useEffect, useState } from 'react'; +import { SASLMechanism } from 'protogen/redpanda/api/dataplane/v1/user_pb'; +import { useEffect, useLayoutEffect, useState } from 'react'; import { UserAclsCard } from './user-acls-card'; -import { ChangePasswordModal, ChangeRolesModal } from './user-edit-modals'; -import { UserInformationCard } from './user-information-card'; +import { ChangePasswordModal } from './user-edit-modals'; import { UserRolesCard } from './user-roles-card'; import { useGetAclsByPrincipal } from '../../../../react-query/api/acl'; import { useListRolesQuery } from '../../../../react-query/api/security'; @@ -24,27 +24,42 @@ import { appGlobal } from '../../../../state/app-global'; import { api, rolesApi } from '../../../../state/backend-api'; import { AclRequestDefault } from '../../../../state/rest-interfaces'; import { useSupportedFeaturesStore } from '../../../../state/supported-features'; +import { setPageHeader } from '../../../../state/ui-state'; import { DefaultSkeleton } from '../../../../utils/tsx-utils'; -import { useSecurityBreadcrumbs } from '../hooks/use-security-breadcrumbs'; import { DeleteUserConfirmModal } from '../shared/delete-user-confirm-modal'; type UserDetailsPageProps = { userName: string; }; +const formatMechanism = (mechanism?: SASLMechanism): string | null => { + if (mechanism === SASLMechanism.SASL_MECHANISM_SCRAM_SHA_256) return 'SCRAM-SHA-256'; + if (mechanism === SASLMechanism.SASL_MECHANISM_SCRAM_SHA_512) return 'SCRAM-SHA-512'; + return null; +}; + const UserDetailsPage = ({ userName }: UserDetailsPageProps) => { const [isChangePasswordModalOpen, setIsChangePasswordModalOpen] = useState(false); - const [isChangeRolesModalOpen, setIsChangeRolesModalOpen] = useState(false); - const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const { data: usersData, isLoading: isUsersLoading } = useListUsersQuery(); const users = usersData?.users?.map((u) => u.name) ?? []; + const currentUser = usersData?.users?.find((u) => u.name === userName); + formatMechanism(currentUser?.mechanism); + const { mutateAsync: deleteUserMutation } = useDeleteUserMutation(); - useSecurityBreadcrumbs([ - { title: 'Users', linkTo: '/security/users' }, - { title: userName, linkTo: `/security/users/${userName}/details` }, - ]); + useLayoutEffect(() => { + setPageHeader( + userName, + [ + { title: 'Security', linkTo: '/security' }, + { title: 'Users', linkTo: '/security/users' }, + { title: userName, linkTo: `/security/users/${userName}/details` }, + ], + { title: 'Users', linkTo: '/security/users' } + ); + }, [userName]); useEffect(() => { const refreshData = async () => { @@ -68,85 +83,61 @@ const UserDetailsPage = ({ userName }: UserDetailsPageProps) => { const isServiceAccount = users.includes(userName); + const onConfirmDelete = async () => { + try { + await deleteUserMutation({ name: userName }); + } catch { + return; + } + + const promises: Promise[] = []; + for (const [roleName, members] of rolesApi.roleMembers) { + if (members.any((m) => m.name === userName)) { + promises.push(rolesApi.updateRoleMembership(roleName, [], [{ name: userName, principalType: 'User' }])); + } + } + await Promise.allSettled(promises); + await Promise.allSettled([invalidateUsersCache(), rolesApi.refreshRoleMembers()]); + appGlobal.historyPush('/security/users/'); + }; + return ( -
-

User: {userName}

-
- { - setIsChangePasswordModalOpen(true); - }} - username={userName} - /> - { - setIsChangeRolesModalOpen(true); - } - : undefined - } - userName={userName} - /> -
- {Boolean(isServiceAccount) && ( - - Delete user - - } - onConfirm={async () => { - try { - await deleteUserMutation({ name: userName }); - } catch { - return; // Error toast shown by mutation's onError - } - - // Remove user from all its roles (best-effort) - const promises: Promise[] = []; - for (const [roleName, members] of rolesApi.roleMembers) { - if (members.any((m) => m.name === userName)) { - promises.push( - rolesApi.updateRoleMembership(roleName, [], [{ name: userName, principalType: 'User' }]) - ); - } - } - await Promise.allSettled(promises); - await Promise.allSettled([invalidateUsersCache(), rolesApi.refreshRoleMembers()]); - appGlobal.historyPush('/security/users/'); - }} - userName={userName} - /> - )} -
- - - - {Boolean(featureRolesApi) && ( - +
+
+ + {Boolean(isServiceAccount) && ( + )}
+ + + + + +
); }; export default UserDetailsPage; -const UserPermissionDetailsContent = ({ - userName, - onChangeRoles, -}: { - userName: string; - onChangeRoles?: () => void; -}) => { +const UserPermissionDetailsContent = ({ userName }: { userName: string }) => { const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); - const { data: rolesData } = useListRolesQuery({ filter: { principal: userName } }); - const { data: acls } = useGetAclsByPrincipal(`User:${userName}`); + const { data: rolesData, isLoading: isRolesLoading } = useListRolesQuery({ filter: { principal: userName } }); + const { data: acls, isLoading: isAclsLoading } = useGetAclsByPrincipal(`User:${userName}`); const roles = featureRolesApi ? (rolesData?.roles ?? []).map((r) => ({ @@ -157,8 +148,8 @@ const UserPermissionDetailsContent = ({ return (
- - + +
); }; diff --git a/frontend/src/components/pages/security/users/user-roles-card.test.tsx b/frontend/src/components/pages/security/users/user-roles-card.test.tsx index 97261dc96b..69c368bf4f 100644 --- a/frontend/src/components/pages/security/users/user-roles-card.test.tsx +++ b/frontend/src/components/pages/security/users/user-roles-card.test.tsx @@ -29,14 +29,13 @@ describe('UserRolesCard', () => { renderWithFileRoutes(); expect(screen.getByText('Roles')).toBeInTheDocument(); - expect(screen.getByText('No permissions assigned to this user.')).toBeInTheDocument(); + expect(screen.getByText('No roles assigned')).toBeInTheDocument(); }); - test('should render Assign Role button in empty state when onChangeRoles is provided', () => { - const mockOnChangeRoles = vi.fn(); - renderWithFileRoutes(); + test('should render Assign Role combobox in empty state when userName is provided', () => { + renderWithFileRoutes(); - expect(screen.getByTestId('assign-role-button')).toBeInTheDocument(); + expect(screen.getByRole('combobox')).toBeInTheDocument(); }); test('should not render Assign Role button in empty state when onChangeRoles is not provided', () => { @@ -57,10 +56,9 @@ describe('UserRolesCard', () => { expect(screen.getByTestId('view-role-viewer')).toBeInTheDocument(); }); - test('should render Change Role button when roles exist and onChangeRoles is provided', () => { - const mockOnChangeRoles = vi.fn(); - renderWithFileRoutes(); + test('should render Assign Role combobox when roles exist and userName is provided', () => { + renderWithFileRoutes(); - expect(screen.getByTestId('change-role-button')).toBeInTheDocument(); + expect(screen.getByRole('combobox')).toBeInTheDocument(); }); }); diff --git a/frontend/src/components/pages/security/users/user-roles-card.tsx b/frontend/src/components/pages/security/users/user-roles-card.tsx index 8100f583b7..dab3a1440f 100644 --- a/frontend/src/components/pages/security/users/user-roles-card.tsx +++ b/frontend/src/components/pages/security/users/user-roles-card.tsx @@ -9,18 +9,28 @@ * by the Apache License, Version 2.0 */ -import { useNavigate } from '@tanstack/react-router'; -import { Eye, EyeOff, Pencil } from 'lucide-react'; -import { useState } from 'react'; +import { create } from '@bufbuild/protobuf'; +import { Link } from '@tanstack/react-router'; +import { ShieldIcon } from 'components/icons'; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from 'components/redpanda-ui/components/empty'; +import { ExternalLinkIcon, Trash2Icon } from 'lucide-react'; -import { useGetAclsByPrincipal } from '../../../../react-query/api/acl'; +import { UpdateRoleMembershipRequestSchema } from '../../../../protogen/redpanda/api/dataplane/v1/security_pb'; +import { useListRolesQuery, useUpdateRoleMembershipMutation } from '../../../../react-query/api/security'; +import { rolesApi } from '../../../../state/backend-api'; import { Button } from '../../../redpanda-ui/components/button'; -import { Card, CardAction, CardContent, CardHeader, CardTitle } from '../../../redpanda-ui/components/card'; +import { Combobox } from '../../../redpanda-ui/components/combobox'; +import { ListLayout, ListLayoutContent, ListLayoutFilters } from '../../../redpanda-ui/components/list-layout'; import { Skeleton } from '../../../redpanda-ui/components/skeleton'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../../redpanda-ui/components/table'; -import type { AclDetail } from '../shared/acl-model'; -import { getRuleDataTestId } from '../shared/acl-model'; -import { OperationsBadge } from '../shared/operations-badge'; +import { Heading } from '../../../redpanda-ui/components/typography'; type Role = { principalType: string; @@ -29,135 +39,123 @@ type Role = { type UserRolesCardProps = { roles: Role[]; - onChangeRoles?: () => void; + userName?: string; + isLoading?: boolean; }; -type RoleTableRowProps = { - role: Role; - isExpanded: boolean; - onToggle: () => void; -}; +export const UserRolesCard = ({ roles, userName, isLoading }: UserRolesCardProps) => { + const { mutateAsync: updateRoleMembership } = useUpdateRoleMembershipMutation(); + const { data: rolesData } = useListRolesQuery(); -const RoleTableRow = ({ role, isExpanded, onToggle }: RoleTableRowProps) => { - const navigate = useNavigate(); - const { data: acls, isLoading } = useGetAclsByPrincipal( - `RedpandaRole:${role.principalName}`, - undefined, - undefined, - { - enabled: isExpanded, - } - ); - const rowKey = role.principalName; + const assignedRoleNames = new Set(roles.map((r) => r.principalName)); - return [ - - {role.principalName} - -
- - -
-
-
, - isLoading && ( - - - - - - - - - ), - !isLoading && isExpanded && acls && acls.length > 0 && ( - - -
-
- ACL Rules ({acls.reduce((sum: number, acl: AclDetail) => sum + acl.rules.length, 0)}) -
- {acls.map((acl: AclDetail) => ( -
-
Host: {acl.sharedConfig.host}
- {acl.rules.map((rule) => ( -
- -
- ))} -
- ))} -
-
-
- ), - ]; -}; + const availableRoleOptions = (rolesData?.roles ?? []) + .filter((r) => !assignedRoleNames.has(r.name)) + .map((r) => ({ value: r.name, label: r.name })); -export const UserRolesCard = ({ roles, onChangeRoles }: UserRolesCardProps) => { - const [expandedRows, setExpandedRows] = useState>(new Set()); + const removeFromRole = async (roleName: string) => { + if (!userName) return; + await updateRoleMembership( + create(UpdateRoleMembershipRequestSchema, { roleName, remove: [{ principal: userName }] }) + ); + await Promise.all([rolesApi.refreshRoles(), rolesApi.refreshRoleMembers()]); + }; - const toggleRow = (key: string) => { - setExpandedRows((prev) => { - const next = new Set(prev); - if (next.has(key)) { - next.delete(key); - } else { - next.add(key); - } - return next; - }); + const assignRole = async (roleName: string) => { + if (!(userName && roleName)) return; + await updateRoleMembership(create(UpdateRoleMembershipRequestSchema, { roleName, add: [{ principal: userName }] })); + await Promise.all([rolesApi.refreshRoles(), rolesApi.refreshRoleMembers()]); }; - if (roles.length === 0) { - return ( - - - Roles - - {Boolean(onChangeRoles) && ( - + + + + + ); + } + return roles.map((r) => ( + + {r.principalName} + +
+ {Boolean(userName) && ( + )} - - - -

No permissions assigned to this user.

-
- - ); - } + +
+
+
+ )); + }; return ( - - - Roles - - {Boolean(onChangeRoles) && ( - - )} - - - + + + ) : undefined + } + > + + Roles + + + @@ -165,23 +163,9 @@ export const UserRolesCard = ({ roles, onChangeRoles }: UserRolesCardProps) => { Actions - - {roles.flatMap((r) => { - const rowKey = r.principalName; - const isExpanded = expandedRows.has(rowKey); - - return ( - toggleRow(rowKey)} - role={r} - /> - ); - })} - + {renderBody()}
-
-
+ + ); }; diff --git a/frontend/src/components/pages/topics/topic-list.tsx b/frontend/src/components/pages/topics/topic-list.tsx index 7a4461b700..e113615e94 100644 --- a/frontend/src/components/pages/topics/topic-list.tsx +++ b/frontend/src/components/pages/topics/topic-list.tsx @@ -45,7 +45,7 @@ import { appGlobal } from '../../../state/app-global'; import { api } from '../../../state/backend-api'; import { type Topic, TopicActions } from '../../../state/rest-interfaces'; import { uiSettings } from '../../../state/ui'; -import { uiState } from '../../../state/ui-state'; +import { setPageHeader } from '../../../state/ui-state'; import { onPaginationChange } from '../../../utils/pagination'; import { editQuery } from '../../../utils/query-helper'; import { Code, DefaultSkeleton, QuickTable } from '../../../utils/tsx-utils'; @@ -59,7 +59,7 @@ const QUICK_SEARCH_REGEX_CACHE = new Map(); const TopicList: FC = () => { useEffect(() => { - uiState.pageBreadcrumbs = [{ title: 'Topics', linkTo: '' }]; + setPageHeader('Topics', [{ title: 'Topics', linkTo: '/topics' }]); }, []); const [localSearchValue, setLocalSearchValue] = useQueryState('q', parseAsString.withDefault('')); diff --git a/frontend/src/components/redpanda-ui/components/list-layout.tsx b/frontend/src/components/redpanda-ui/components/list-layout.tsx index fb76b8cd0d..e17aa3b8d7 100644 --- a/frontend/src/components/redpanda-ui/components/list-layout.tsx +++ b/frontend/src/components/redpanda-ui/components/list-layout.tsx @@ -28,7 +28,7 @@ ListLayout.displayName = 'ListLayout'; interface ListLayoutHeaderProps extends React.HTMLAttributes { title: string; - description?: string; + description?: React.ReactNode; actions?: React.ReactNode; } diff --git a/frontend/src/components/redpanda-ui/components/multi-select.tsx b/frontend/src/components/redpanda-ui/components/multi-select.tsx index 1affbb1d7b..56829e68a0 100644 --- a/frontend/src/components/redpanda-ui/components/multi-select.tsx +++ b/frontend/src/components/redpanda-ui/components/multi-select.tsx @@ -341,7 +341,7 @@ const MultiSelectContent = React.forwardRef< { return useMutation(createACL, { onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: createConnectQueryKey({ - schema: ACLService.method.listACLs, - cardinality: 'infinite', + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: createConnectQueryKey({ + schema: ACLService.method.listACLs, + cardinality: 'infinite', + }), }), - }); + queryClient.invalidateQueries({ + queryKey: createConnectQueryKey({ + schema: ACLService.method.listACLs, + cardinality: 'finite', + }), + }), + ]); }, onError: (error) => formatToastErrorMessageGRPC({ diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index cbbdb8c792..aaf5618660 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -52,7 +52,6 @@ import { Route as ShadowlinksNameIndexRouteImport } from './routes/shadowlinks/$ import { Route as SecurityUsersIndexRouteImport } from './routes/security/users/index'; import { Route as SecurityRolesIndexRouteImport } from './routes/security/roles/index'; import { Route as SecurityPermissionsListIndexRouteImport } from './routes/security/permissions-list/index'; -import { Route as SecurityAclsIndexRouteImport } from './routes/security/acls/index'; import { Route as RpConnectPipelineIdIndexRouteImport } from './routes/rp-connect/$pipelineId/index'; import { Route as KnowledgebasesKnowledgebaseIdIndexRouteImport } from './routes/knowledgebases/$knowledgebaseId/index'; import { Route as ConnectClustersClusterNameIndexRouteImport } from './routes/connect-clusters/$clusterName/index'; @@ -74,7 +73,6 @@ import { Route as SecurityUsersUserNameDetailsRouteImport } from './routes/secur import { Route as SecurityRolesRoleNameUpdateRouteImport } from './routes/security/roles/$roleName/update'; import { Route as SecurityRolesRoleNameEditRouteImport } from './routes/security/roles/$roleName/edit'; import { Route as SecurityRolesRoleNameDetailsRouteImport } from './routes/security/roles/$roleName/details'; -import { Route as SecurityAclsAclNameUpdateRouteImport } from './routes/security/acls/$aclName/update'; import { Route as SecurityAclsAclNameDetailsRouteImport } from './routes/security/acls/$aclName/details'; import { Route as SchemaRegistrySubjectsSubjectNameEditModeRouteImport } from './routes/schema-registry/subjects/$subjectName/edit-mode'; import { Route as SchemaRegistrySubjectsSubjectNameEditCompatibilityRouteImport } from './routes/schema-registry/subjects/$subjectName/edit-compatibility'; @@ -303,11 +301,6 @@ const SecurityPermissionsListIndexRoute = path: '/permissions-list/', getParentRoute: () => SecurityRoute, } as any); -const SecurityAclsIndexRoute = SecurityAclsIndexRouteImport.update({ - id: '/acls/', - path: '/acls/', - getParentRoute: () => SecurityRoute, -} as any); const RpConnectPipelineIdIndexRoute = RpConnectPipelineIdIndexRouteImport.update({ id: '/rp-connect/$pipelineId/', @@ -425,12 +418,6 @@ const SecurityRolesRoleNameDetailsRoute = path: '/roles/$roleName/details', getParentRoute: () => SecurityRoute, } as any); -const SecurityAclsAclNameUpdateRoute = - SecurityAclsAclNameUpdateRouteImport.update({ - id: '/acls/$aclName/update', - path: '/acls/$aclName/update', - getParentRoute: () => SecurityRoute, - } as any); const SecurityAclsAclNameDetailsRoute = SecurityAclsAclNameDetailsRouteImport.update({ id: '/acls/$aclName/details', @@ -547,7 +534,6 @@ export interface FileRoutesByFullPath { '/connect-clusters/$clusterName/': typeof ConnectClustersClusterNameIndexRoute; '/knowledgebases/$knowledgebaseId/': typeof KnowledgebasesKnowledgebaseIdIndexRoute; '/rp-connect/$pipelineId/': typeof RpConnectPipelineIdIndexRoute; - '/security/acls/': typeof SecurityAclsIndexRoute; '/security/permissions-list/': typeof SecurityPermissionsListIndexRoute; '/security/roles/': typeof SecurityRolesIndexRoute; '/security/users/': typeof SecurityUsersIndexRoute; @@ -563,7 +549,6 @@ export interface FileRoutesByFullPath { '/schema-registry/subjects/$subjectName/edit-compatibility': typeof SchemaRegistrySubjectsSubjectNameEditCompatibilityRoute; '/schema-registry/subjects/$subjectName/edit-mode': typeof SchemaRegistrySubjectsSubjectNameEditModeRoute; '/security/acls/$aclName/details': typeof SecurityAclsAclNameDetailsRoute; - '/security/acls/$aclName/update': typeof SecurityAclsAclNameUpdateRoute; '/security/roles/$roleName/details': typeof SecurityRolesRoleNameDetailsRoute; '/security/roles/$roleName/edit': typeof SecurityRolesRoleNameEditRoute; '/security/roles/$roleName/update': typeof SecurityRolesRoleNameUpdateRoute; @@ -624,7 +609,6 @@ export interface FileRoutesByTo { '/connect-clusters/$clusterName': typeof ConnectClustersClusterNameIndexRoute; '/knowledgebases/$knowledgebaseId': typeof KnowledgebasesKnowledgebaseIdIndexRoute; '/rp-connect/$pipelineId': typeof RpConnectPipelineIdIndexRoute; - '/security/acls': typeof SecurityAclsIndexRoute; '/security/permissions-list': typeof SecurityPermissionsListIndexRoute; '/security/roles': typeof SecurityRolesIndexRoute; '/security/users': typeof SecurityUsersIndexRoute; @@ -640,7 +624,6 @@ export interface FileRoutesByTo { '/schema-registry/subjects/$subjectName/edit-compatibility': typeof SchemaRegistrySubjectsSubjectNameEditCompatibilityRoute; '/schema-registry/subjects/$subjectName/edit-mode': typeof SchemaRegistrySubjectsSubjectNameEditModeRoute; '/security/acls/$aclName/details': typeof SecurityAclsAclNameDetailsRoute; - '/security/acls/$aclName/update': typeof SecurityAclsAclNameUpdateRoute; '/security/roles/$roleName/details': typeof SecurityRolesRoleNameDetailsRoute; '/security/roles/$roleName/edit': typeof SecurityRolesRoleNameEditRoute; '/security/roles/$roleName/update': typeof SecurityRolesRoleNameUpdateRoute; @@ -703,7 +686,6 @@ export interface FileRoutesById { '/connect-clusters/$clusterName/': typeof ConnectClustersClusterNameIndexRoute; '/knowledgebases/$knowledgebaseId/': typeof KnowledgebasesKnowledgebaseIdIndexRoute; '/rp-connect/$pipelineId/': typeof RpConnectPipelineIdIndexRoute; - '/security/acls/': typeof SecurityAclsIndexRoute; '/security/permissions-list/': typeof SecurityPermissionsListIndexRoute; '/security/roles/': typeof SecurityRolesIndexRoute; '/security/users/': typeof SecurityUsersIndexRoute; @@ -719,7 +701,6 @@ export interface FileRoutesById { '/schema-registry/subjects/$subjectName/edit-compatibility': typeof SchemaRegistrySubjectsSubjectNameEditCompatibilityRoute; '/schema-registry/subjects/$subjectName/edit-mode': typeof SchemaRegistrySubjectsSubjectNameEditModeRoute; '/security/acls/$aclName/details': typeof SecurityAclsAclNameDetailsRoute; - '/security/acls/$aclName/update': typeof SecurityAclsAclNameUpdateRoute; '/security/roles/$roleName/details': typeof SecurityRolesRoleNameDetailsRoute; '/security/roles/$roleName/edit': typeof SecurityRolesRoleNameEditRoute; '/security/roles/$roleName/update': typeof SecurityRolesRoleNameUpdateRoute; @@ -783,7 +764,6 @@ export interface FileRouteTypes { | '/connect-clusters/$clusterName/' | '/knowledgebases/$knowledgebaseId/' | '/rp-connect/$pipelineId/' - | '/security/acls/' | '/security/permissions-list/' | '/security/roles/' | '/security/users/' @@ -799,7 +779,6 @@ export interface FileRouteTypes { | '/schema-registry/subjects/$subjectName/edit-compatibility' | '/schema-registry/subjects/$subjectName/edit-mode' | '/security/acls/$aclName/details' - | '/security/acls/$aclName/update' | '/security/roles/$roleName/details' | '/security/roles/$roleName/edit' | '/security/roles/$roleName/update' @@ -860,7 +839,6 @@ export interface FileRouteTypes { | '/connect-clusters/$clusterName' | '/knowledgebases/$knowledgebaseId' | '/rp-connect/$pipelineId' - | '/security/acls' | '/security/permissions-list' | '/security/roles' | '/security/users' @@ -876,7 +854,6 @@ export interface FileRouteTypes { | '/schema-registry/subjects/$subjectName/edit-compatibility' | '/schema-registry/subjects/$subjectName/edit-mode' | '/security/acls/$aclName/details' - | '/security/acls/$aclName/update' | '/security/roles/$roleName/details' | '/security/roles/$roleName/edit' | '/security/roles/$roleName/update' @@ -938,7 +915,6 @@ export interface FileRouteTypes { | '/connect-clusters/$clusterName/' | '/knowledgebases/$knowledgebaseId/' | '/rp-connect/$pipelineId/' - | '/security/acls/' | '/security/permissions-list/' | '/security/roles/' | '/security/users/' @@ -954,7 +930,6 @@ export interface FileRouteTypes { | '/schema-registry/subjects/$subjectName/edit-compatibility' | '/schema-registry/subjects/$subjectName/edit-mode' | '/security/acls/$aclName/details' - | '/security/acls/$aclName/update' | '/security/roles/$roleName/details' | '/security/roles/$roleName/edit' | '/security/roles/$roleName/update' @@ -1330,13 +1305,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SecurityPermissionsListIndexRouteImport; parentRoute: typeof SecurityRoute; }; - '/security/acls/': { - id: '/security/acls/'; - path: '/acls'; - fullPath: '/security/acls/'; - preLoaderRoute: typeof SecurityAclsIndexRouteImport; - parentRoute: typeof SecurityRoute; - }; '/rp-connect/$pipelineId/': { id: '/rp-connect/$pipelineId/'; path: '/rp-connect/$pipelineId'; @@ -1484,13 +1452,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SecurityRolesRoleNameDetailsRouteImport; parentRoute: typeof SecurityRoute; }; - '/security/acls/$aclName/update': { - id: '/security/acls/$aclName/update'; - path: '/acls/$aclName/update'; - fullPath: '/security/acls/$aclName/update'; - preLoaderRoute: typeof SecurityAclsAclNameUpdateRouteImport; - parentRoute: typeof SecurityRoute; - }; '/security/acls/$aclName/details': { id: '/security/acls/$aclName/details'; path: '/acls/$aclName/details'; @@ -1569,12 +1530,10 @@ interface SecurityRouteChildren { SecurityAclsCreateRoute: typeof SecurityAclsCreateRoute; SecurityRolesCreateRoute: typeof SecurityRolesCreateRoute; SecurityUsersCreateRoute: typeof SecurityUsersCreateRoute; - SecurityAclsIndexRoute: typeof SecurityAclsIndexRoute; SecurityPermissionsListIndexRoute: typeof SecurityPermissionsListIndexRoute; SecurityRolesIndexRoute: typeof SecurityRolesIndexRoute; SecurityUsersIndexRoute: typeof SecurityUsersIndexRoute; SecurityAclsAclNameDetailsRoute: typeof SecurityAclsAclNameDetailsRoute; - SecurityAclsAclNameUpdateRoute: typeof SecurityAclsAclNameUpdateRoute; SecurityRolesRoleNameDetailsRoute: typeof SecurityRolesRoleNameDetailsRoute; SecurityRolesRoleNameEditRoute: typeof SecurityRolesRoleNameEditRoute; SecurityRolesRoleNameUpdateRoute: typeof SecurityRolesRoleNameUpdateRoute; @@ -1586,12 +1545,10 @@ const SecurityRouteChildren: SecurityRouteChildren = { SecurityAclsCreateRoute: SecurityAclsCreateRoute, SecurityRolesCreateRoute: SecurityRolesCreateRoute, SecurityUsersCreateRoute: SecurityUsersCreateRoute, - SecurityAclsIndexRoute: SecurityAclsIndexRoute, SecurityPermissionsListIndexRoute: SecurityPermissionsListIndexRoute, SecurityRolesIndexRoute: SecurityRolesIndexRoute, SecurityUsersIndexRoute: SecurityUsersIndexRoute, SecurityAclsAclNameDetailsRoute: SecurityAclsAclNameDetailsRoute, - SecurityAclsAclNameUpdateRoute: SecurityAclsAclNameUpdateRoute, SecurityRolesRoleNameDetailsRoute: SecurityRolesRoleNameDetailsRoute, SecurityRolesRoleNameEditRoute: SecurityRolesRoleNameEditRoute, SecurityRolesRoleNameUpdateRoute: SecurityRolesRoleNameUpdateRoute, diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index b3e153923b..dc76c6333a 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -69,7 +69,7 @@ function SelfHostedLayout() { -
+
@@ -94,7 +94,9 @@ function AppContent() { - +
+ +
diff --git a/frontend/src/routes/security.tsx b/frontend/src/routes/security.tsx index fef2a3c177..084861614b 100644 --- a/frontend/src/routes/security.tsx +++ b/frontend/src/routes/security.tsx @@ -9,96 +9,29 @@ * by the Apache License, Version 2.0 */ -import { createFileRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router'; +import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'; import { ShieldCheckIcon } from 'components/icons'; -import { isServerless } from 'config'; import { useEffect } from 'react'; -import PageContent from '../components/misc/page-content'; import { Alert, AlertDescription } from '../components/redpanda-ui/components/alert'; -import { Tabs, TabsList, TabsTrigger } from '../components/redpanda-ui/components/tabs'; import { appGlobal } from '../state/app-global'; import { api, rolesApi, useApiStoreHook } from '../state/backend-api'; -import { useSupportedFeaturesStore } from '../state/supported-features'; export const Route = createFileRoute('/security')({ staticData: { title: 'Security', icon: ShieldCheckIcon, }, + beforeLoad: ({ location }) => { + if (location.pathname === '/security' || location.pathname === '/security/') { + throw redirect({ to: '/security/users' }); + } + }, component: SecurityLayout, }); -type TabConfig = { - key: string; - label: string; - path: string; - disabled: boolean; -}; - -function buildTabs( - isAdminApiConfigured: boolean, - featureCreateUser: boolean, - featureRolesApi: boolean, - userData: { canManageUsers?: boolean; canListAcls?: boolean; canViewPermissionsList?: boolean } | null | undefined -): TabConfig[] { - const result: TabConfig[] = [ - { - key: 'users', - label: 'Users', - path: '/security/users', - disabled: - !(isAdminApiConfigured && featureCreateUser) || - (userData?.canManageUsers !== undefined && userData?.canManageUsers === false), - }, - ]; - - if (!isServerless()) { - result.push({ - key: 'roles', - label: 'Roles', - path: '/security/roles', - disabled: !featureRolesApi || userData?.canManageUsers === false, - }); - } - - result.push( - { - key: 'acls', - label: 'ACLs', - path: '/security/acls', - disabled: userData?.canListAcls === false, - }, - { - key: 'permissions-list', - label: 'Permissions List', - path: '/security/permissions-list', - disabled: userData?.canViewPermissionsList === false, - } - ); - - return result; -} - -function deriveActiveTab(pathname: string, tabs: TabConfig[]): string { - for (const tab of tabs) { - if (pathname === tab.path || pathname.startsWith(`${tab.path}/`)) { - return tab.key; - } - } - return 'acls'; -} - function SecurityLayout() { - const location = useLocation(); - const navigate = useNavigate(); const acls = useApiStoreHook((s) => s.ACLs); - const userData = useApiStoreHook((s) => s.userData); - const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); - const featureCreateUser = useSupportedFeaturesStore((s) => s.createUser); - - const redpandaOverview = useApiStoreHook((s) => s.clusterOverview?.redpanda); - const isAdminApiConfigured = Boolean(redpandaOverview); useEffect(() => { const refreshData = async () => { @@ -115,9 +48,6 @@ function SecurityLayout() { }); }, []); - const tabs = buildTabs(isAdminApiConfigured, featureCreateUser, featureRolesApi, userData); - const activeTab = deriveActiveTab(location.pathname, tabs); - const warning = acls === null ? ( @@ -132,37 +62,11 @@ function SecurityLayout() { ) : null; - const handleTabClick = (tabKey: string) => { - const tab = tabs.find((t) => t.key === tabKey); - if (tab && !tab.disabled) { - navigate({ to: tab.path }); - } - }; - return ( <> {warning} {noAclAuthorizer} - - - - - {tabs.map((tab) => ( - handleTabClick(tab.key)} - value={tab.key} - variant="underline" - > - {tab.label} - - ))} - - - - + ); } diff --git a/frontend/src/routes/security/acls/$aclName/update.tsx b/frontend/src/routes/security/acls/$aclName/update.tsx deleted file mode 100644 index a78646b3b9..0000000000 --- a/frontend/src/routes/security/acls/$aclName/update.tsx +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Copyright 2026 Redpanda Data, Inc. - * - * Use of this software is governed by the Business Source License - * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md - * - * As of the Change Date specified in that file, in accordance with - * the Business Source License, use of this software will be governed - * by the Apache License, Version 2.0 - */ - -import { createFileRoute } from '@tanstack/react-router'; -import { fallback, zodValidator } from '@tanstack/zod-adapter'; -import { z } from 'zod'; - -import AclUpdatePage from '../../../../components/pages/security/acls/acl-update-page'; - -const searchSchema = z.object({ - host: fallback(z.string().optional(), undefined), -}); - -export const Route = createFileRoute('/security/acls/$aclName/update')({ - staticData: { - title: 'Update ACL', - }, - validateSearch: zodValidator(searchSchema), - component: AclUpdatePage, -}); diff --git a/frontend/src/routes/security/acls/create.tsx b/frontend/src/routes/security/acls/create.tsx deleted file mode 100644 index 8ccd3776be..0000000000 --- a/frontend/src/routes/security/acls/create.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Copyright 2026 Redpanda Data, Inc. - * - * Use of this software is governed by the Business Source License - * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md - * - * As of the Change Date specified in that file, in accordance with - * the Business Source License, use of this software will be governed - * by the Apache License, Version 2.0 - */ - -import { createFileRoute } from '@tanstack/react-router'; -import { fallback, zodValidator } from '@tanstack/zod-adapter'; -import { z } from 'zod'; - -import AclCreatePage from '../../../components/pages/security/acls/acl-create-page'; - -const searchSchema = z.object({ - principalType: fallback(z.string().optional(), undefined), - principalName: fallback(z.string().optional(), undefined), -}); - -export const Route = createFileRoute('/security/acls/create')({ - staticData: { - title: 'Create ACL', - }, - validateSearch: zodValidator(searchSchema), - component: AclCreatePage, -}); diff --git a/frontend/src/routes/security/acls/index.tsx b/frontend/src/routes/security/acls/index.tsx deleted file mode 100644 index c00192588b..0000000000 --- a/frontend/src/routes/security/acls/index.tsx +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Copyright 2026 Redpanda Data, Inc. - * - * Use of this software is governed by the Business Source License - * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md - * - * As of the Change Date specified in that file, in accordance with - * the Business Source License, use of this software will be governed - * by the Apache License, Version 2.0 - */ - -import { createFileRoute } from '@tanstack/react-router'; - -import { AclsTab } from '../../../components/pages/security/tabs/acls-tab'; - -export const Route = createFileRoute('/security/acls/')({ - staticData: { - title: 'Security', - }, - component: AclsTab, -}); diff --git a/frontend/src/routes/security/index.tsx b/frontend/src/routes/security/index.tsx index 5e527c1502..a4046a964c 100644 --- a/frontend/src/routes/security/index.tsx +++ b/frontend/src/routes/security/index.tsx @@ -13,12 +13,8 @@ import { createFileRoute, redirect } from '@tanstack/react-router'; export const Route = createFileRoute('/security/')({ beforeLoad: () => { - // Redirect /security/ to /security/acls at router level. - // This prevents the component-level useEffect redirect which can cause - // navigation loops in embedded mode where shell and console routers conflict. - // ACLs tab is always available regardless of admin API or serverless mode. throw redirect({ - to: '/security/acls', + to: '/security/users', replace: true, }); }, diff --git a/frontend/src/routes/security/roles/$roleName/update.tsx b/frontend/src/routes/security/roles/$roleName/update.tsx index c644f95537..7125bef250 100644 --- a/frontend/src/routes/security/roles/$roleName/update.tsx +++ b/frontend/src/routes/security/roles/$roleName/update.tsx @@ -9,20 +9,14 @@ * by the Apache License, Version 2.0 */ -import { createFileRoute } from '@tanstack/react-router'; -import { fallback, zodValidator } from '@tanstack/zod-adapter'; -import { z } from 'zod'; - -import RoleUpdatePage from '../../../../components/pages/security/roles/role-update-page'; - -const searchSchema = z.object({ - host: fallback(z.string().optional(), undefined), -}); +import { createFileRoute, redirect } from '@tanstack/react-router'; export const Route = createFileRoute('/security/roles/$roleName/update')({ - staticData: { - title: 'Update Role', + beforeLoad: ({ params }) => { + throw redirect({ + to: '/security/roles/$roleName/details', + params, + replace: true, + }); }, - validateSearch: zodValidator(searchSchema), - component: RoleUpdatePage, }); diff --git a/frontend/src/state/ui-state.ts b/frontend/src/state/ui-state.ts index 5dabaebbf9..418c80d2df 100644 --- a/frontend/src/state/ui-state.ts +++ b/frontend/src/state/ui-state.ts @@ -36,10 +36,16 @@ export type ServerVersionInfo = { branchBusiness?: string; }; +export type BackLink = { + title: string; + linkTo: string; +}; + type UIStateStore = { // Core state _pageTitle: string | React.ReactElement; pageBreadcrumbs: BreadcrumbEntry[]; + backLink: BackLink | null; shouldHidePageHeader: boolean; pathName: string; _currentTopicName: string | undefined; @@ -56,6 +62,12 @@ type UIStateStore = { // Actions (setters) setPageTitle: (title: string | React.ReactElement) => void; setPageBreadcrumbs: (breadcrumbs: BreadcrumbEntry[]) => void; + setPageState: ( + title: string | React.ReactElement, + breadcrumbs: BreadcrumbEntry[], + backLink?: BackLink | null + ) => void; + setBackLink: (backLink: BackLink | null) => void; setShouldHidePageHeader: (hide: boolean) => void; setPathName: (path: string) => void; setCurrentTopicName: (topicName: string | undefined) => void; @@ -68,6 +80,7 @@ export const useUIStateStore = create((set, get) => ({ // Initial state _pageTitle: ' ', pageBreadcrumbs: [], + backLink: null, shouldHidePageHeader: false, pathName: '', _currentTopicName: undefined, @@ -132,6 +145,19 @@ export const useUIStateStore = create((set, get) => ({ set({ pageBreadcrumbs: breadcrumbs }); }, + setPageState: (title: string | React.ReactElement, breadcrumbs: BreadcrumbEntry[], backLink?: BackLink | null) => { + if (typeof title === 'string') { + document.title = `${title} - Redpanda Console`; + } else { + document.title = 'Redpanda Console'; + } + set({ _pageTitle: title, pageBreadcrumbs: breadcrumbs, backLink: backLink ?? null }); + }, + + setBackLink: (backLink: BackLink | null) => { + set({ backLink }); + }, + setShouldHidePageHeader: (hide: boolean) => { set({ shouldHidePageHeader: hide }); }, @@ -174,6 +200,7 @@ export const uiState = new Proxy( {} as { pageTitle: string | React.ReactElement; pageBreadcrumbs: BreadcrumbEntry[]; + backLink: BackLink | null; shouldHidePageHeader: boolean; selectedClusterName: string | null; pathName: string; @@ -232,8 +259,20 @@ export const uiState = new Proxy( store.setServerBuildTimestamp(value as number | undefined); return true; } + if (prop === 'backLink') { + store.setBackLink(value as BackLink | null); + return true; + } return true; }, } ); + +export function setPageHeader( + title: string | React.ReactElement, + breadcrumbs: BreadcrumbEntry[], + backLink?: BackLink | null +) { + useUIStateStore.getState().setPageState(title, breadcrumbs, backLink); +} diff --git a/frontend/tests/shared/global-setup.mjs b/frontend/tests/shared/global-setup.mjs index 9b9c3ff065..76936cf7d7 100644 --- a/frontend/tests/shared/global-setup.mjs +++ b/frontend/tests/shared/global-setup.mjs @@ -439,11 +439,20 @@ export async function buildBackendImage(isEnterprise) { const modulePath = match[1]; if (modulePath === '.' || modulePath === 'use' || modulePath === '(' || modulePath === ')') continue; - // Resolve the actual path relative to backendDir - const absModulePath = resolve(backendDir, modulePath); + // Resolve the actual path relative to backendDir, or fall back to the + // repo root (parent of backendDir). go.work paths like ../console/backend + // are authored relative to the repo root, not the backend/ subdir. + let absModulePath = resolve(backendDir, modulePath); if (!existsSync(absModulePath)) { - console.warn(` Workspace module not found: ${absModulePath}, skipping`); - continue; + const parentDir = resolve(backendDir, '..'); + const fromParent = resolve(parentDir, modulePath); + if (existsSync(fromParent)) { + console.log(` Resolving ${modulePath} from repo root: ${fromParent}`); + absModulePath = fromParent; + } else { + console.warn(` Workspace module not found: ${absModulePath} (also tried ${fromParent}), skipping`); + continue; + } } // Create a sanitized directory name diff --git a/frontend/tests/test-variant-console-enterprise/users.spec.ts b/frontend/tests/test-variant-console-enterprise/users.spec.ts index bb09b51d9d..b6b18eaed0 100644 --- a/frontend/tests/test-variant-console-enterprise/users.spec.ts +++ b/frontend/tests/test-variant-console-enterprise/users.spec.ts @@ -29,9 +29,9 @@ test.describe('Users', () => { await page.goto('/security/users/', { waitUntil: 'domcontentloaded', }); - await page.getByPlaceholder('Filter by name').fill(`user-${r}-regexp-[1,2]`); + await page.getByPlaceholder('Filter by name (regexp)...').fill(`user-${r}-regexp-[1,2]`); // Wait for nuqs to push the filter into the URL (TanStack Router navigate is async) - await page.waitForURL(/[?&]q=/); + await page.waitForURL(/[?&]name=/); await expect( page.getByTestId('data-table-cell').locator(`a[href='/security/users/${userName1}/details']`) diff --git a/frontend/tests/test-variant-console/acls/user-management.spec.ts b/frontend/tests/test-variant-console/acls/user-management.spec.ts index 924b47c5b7..772f097e00 100644 --- a/frontend/tests/test-variant-console/acls/user-management.spec.ts +++ b/frontend/tests/test-variant-console/acls/user-management.spec.ts @@ -19,7 +19,7 @@ test.describe('ACL User Management', () => { test('should create a new user with special characters in password', async ({ page }) => { await test.step('1. Click Create user button to open user creation dialog', async () => { await page.getByTestId('create-user-button').click(); - await expect(page).toHaveURL('/security/users/create'); + await expect(page.getByTestId('create-user-name')).toBeVisible(); }); const timestamp = Date.now(); @@ -45,8 +45,9 @@ test.describe('ACL User Management', () => { await expect(page.getByText(username)).toBeVisible(); }); - await test.step('6. Return to users list', async () => { + await test.step('6. Close dialog', async () => { await page.getByTestId('done-button').click(); + await expect(page.getByTestId('create-user-name')).not.toBeVisible(); await expect(page).toHaveURL('/security/users'); }); @@ -61,7 +62,7 @@ test.describe('ACL User Management', () => { await test.step('1. Create a new user', async () => { await page.getByTestId('create-user-button').click(); - await expect(page).toHaveURL('/security/users/create'); + await expect(page.getByTestId('create-user-name')).toBeVisible(); await page.getByTestId('create-user-name').fill(username); await page.getByTestId('create-user-submit').click(); await expect(page.getByTestId('user-created-successfully')).toBeVisible(); @@ -91,8 +92,9 @@ test.describe('ACL User Management', () => { }); test('should toggle special characters checkbox and regenerate password', async ({ page }) => { - await test.step('1. Navigate to create user page', async () => { + await test.step('1. Open create user dialog', async () => { await page.getByTestId('create-user-button').click(); + await expect(page.getByTestId('create-user-name')).toBeVisible(); }); const passwordInput = page.getByTestId('create-user-password'); @@ -123,8 +125,9 @@ test.describe('ACL User Management', () => { expect(finalPassword).not.toBe(passwordAfterToggle); }); - await test.step('Cancel and return to list', async () => { + await test.step('Cancel and close dialog', async () => { await page.getByTestId('create-user-cancel').click(); + await expect(page.getByTestId('create-user-name')).not.toBeVisible(); await expect(page).toHaveURL('/security/users'); }); }); @@ -147,7 +150,7 @@ test.describe('ACL User Management', () => { await expect(table).toBeVisible(); }); - const filterInput = page.getByTestId('search-field-input').getByRole('textbox'); + const filterInput = page.getByPlaceholder('Filter by name (regexp)...'); await test.step('3. Get filter input', async () => { await expect(filterInput).toBeVisible(); @@ -157,8 +160,8 @@ test.describe('ACL User Management', () => { await filterInput.fill('test'); }); - await test.step('5. Verify URL contains query parameter q=test', async () => { - await expect(page).toHaveURL('/security/users/?q=test'); + await test.step('5. Verify URL contains query parameter name=test', async () => { + await expect(page).toHaveURL('/security/users/?name=test'); }); await test.step('6. Verify filtered results show only users with test in name', async () => { @@ -192,14 +195,14 @@ test.describe('ACL User Management', () => { await expect(page).toHaveURL('/security/users'); }); - const filterInput = page.getByTestId('search-field-input').getByRole('textbox'); + const filterInput = page.getByPlaceholder('Filter by name (regexp)...'); await test.step('2. Filter by e2e', async () => { await filterInput.fill('e2e'); }); - await test.step('3. Verify URL contains query parameter q=e2e', async () => { - await expect(page).toHaveURL('/security/users/?q=e2e'); + await test.step('3. Verify URL contains query parameter name=e2e', async () => { + await expect(page).toHaveURL('/security/users/?name=e2e'); }); await test.step('4. Verify only e2euser is visible', async () => { @@ -210,8 +213,8 @@ test.describe('ACL User Management', () => { await filterInput.fill('test'); }); - await test.step('6. Verify URL contains query parameter q=test', async () => { - await expect(page).toHaveURL('/security/users/?q=test'); + await test.step('6. Verify URL contains query parameter name=test', async () => { + await expect(page).toHaveURL('/security/users/?name=test'); }); await test.step('7. Verify test-user is visible', async () => { @@ -310,8 +313,9 @@ test.describe('ACL User Management', () => { }); test('should validate username format requirements', async ({ page }) => { - await test.step('1. Navigate to create user page', async () => { + await test.step('1. Open create user dialog', async () => { await page.getByTestId('create-user-button').click(); + await expect(page.getByTestId('create-user-name')).toBeVisible(); }); await test.step('2. Verify username input has help text', async () => { @@ -334,8 +338,9 @@ test.describe('ACL User Management', () => { }); test('should display password requirements', async ({ page }) => { - await test.step('1. Navigate to create user page', async () => { + await test.step('1. Open create user dialog', async () => { await page.getByTestId('create-user-button').click(); + await expect(page.getByTestId('create-user-name')).toBeVisible(); }); await test.step('2. Verify password requirements are displayed', async () => { diff --git a/frontend/tests/test-variant-console/utils/security-page.ts b/frontend/tests/test-variant-console/utils/security-page.ts index 5469fd8b62..e798f0848c 100644 --- a/frontend/tests/test-variant-console/utils/security-page.ts +++ b/frontend/tests/test-variant-console/utils/security-page.ts @@ -19,15 +19,12 @@ export class SecurityPage { await this.page.goto(`/security/users/${username}/details`); } - async goToCreateUser() { - await this.page.goto('/security/users/create'); - } - /** * User list operations */ async clickCreateUserButton() { await this.page.getByTestId('create-user-button').click(); + await this.page.getByTestId('create-user-name').waitFor({ state: 'visible' }); } /** @@ -65,10 +62,11 @@ export class SecurityPage { return await test.step('Create user', async () => { await this.goToUsersList(); await this.clickCreateUserButton(); - await this.page.waitForURL('/security/users/create'); await this.fillUsername(username); await this.submitUserCreation(); await this.page.getByTestId('user-created-successfully').waitFor({ state: 'visible' }); + await this.page.getByTestId('go-to-user-details-button').click(); + await this.page.waitForURL(`**/security/users/${username}/details`); }); }