diff --git a/frontend/.gitignore b/frontend/.gitignore index 6f4fe2226e..391a029afe 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -162,3 +162,4 @@ dist .gemini/settings.json tests/**/playwright-report/ +tests/**/*.js \ No newline at end of file diff --git a/frontend/src/components/constants.ts b/frontend/src/components/constants.ts index 3d44558f95..089a04fe3d 100644 --- a/frontend/src/components/constants.ts +++ b/frontend/src/components/constants.ts @@ -16,6 +16,7 @@ export const FEATURE_FLAGS = { enableNewPipelineLogs: false, enablePipelineDiagrams: false, enableConnectSlashMenu: false, + enableNewSecurityPage: false, }; // Cloud-managed tag keys for service account integration diff --git a/frontend/src/components/layout/header.tsx b/frontend/src/components/layout/header.tsx index 8d4a7bf354..db5af09e45 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..6ac052da9c 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,7 @@ 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'; // biome-ignore lint/nursery/useMaxParams: Refactoring to options object would require updating all call sites const getLicenseAlertContentForFeature = ( @@ -36,7 +36,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 +47,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 +76,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 +101,14 @@ const getLicenseAlertContentForFeature = ( contact us . - - +

+
- - +
+
), - status: 'warning', + variant: 'destructive', }; } } else { @@ -117,37 +117,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 +157,14 @@ const getLicenseAlertContentForFeature = ( contact us . - - +

+
- - +
+
), - status: 'warning', + variant: 'destructive', }; } } @@ -172,7 +172,9 @@ const getLicenseAlertContentForFeature = ( return null; }; -export const FeatureLicenseNotification: FC<{ featureName: 'reassignPartitions' | 'rbac' }> = ({ featureName }) => { +export const FeatureLicenseNotification: FC<{ + featureName: 'reassignPartitions' | 'rbac'; +}> = ({ featureName }) => { const [registerModalOpen, setIsRegisterModalOpen] = useState(false); useEffect(() => { @@ -220,16 +222,15 @@ export const FeatureLicenseNotification: FC<{ featureName: 'reassignPartitions' return null; } - const { message, status } = alertContent; + const { message, variant } = alertContent; return ( - - - + <> + {message} setIsRegisterModalOpen(false)} /> - + ); }; diff --git a/frontend/src/components/pages/connect/overview.tsx b/frontend/src/components/pages/connect/overview.tsx index 4594d4d1d4..dfaa7df02e 100644 --- a/frontend/src/components/pages/connect/overview.tsx +++ b/frontend/src/components/pages/connect/overview.tsx @@ -106,7 +106,7 @@ class KafkaConnectOverview extends PageComponent<{ isLoadingKafkaConnectors: boolean; }> { initPage(p: PageInitHelper): void { - p.title = 'Overview'; + p.title = 'Connect'; p.addBreadcrumb('Connect', '/connect-clusters'); this.initializeData(); 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-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 index 609d5c42a1..61292bb6f6 100644 --- a/frontend/src/components/pages/security/acls/acl-update-page.tsx +++ b/frontend/src/components/pages/security/acls/acl-update-page.tsx @@ -51,7 +51,6 @@ const AclUpdatePage = () => { { title: principalName, linkTo: `/security/acls/${aclName}/details` }, ]); - // Fetch existing ACL data const { data, isLoading } = useGetAclsByPrincipal(`${principalType}:${principalName}`, host); const { applyUpdates } = useUpdateAclMutation(); @@ -93,18 +92,15 @@ const AclUpdatePage = () => { ); } - // 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; @@ -120,6 +116,7 @@ const AclUpdatePage = () => { return (
+ {/* allow: react-rules [restoring master component, heading upgrade deferred] */}

Update ACL: {principalName}

> = { + [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 roleMembers = useStore(useRolesStore, (s) => s.roleMembers); + + 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 roleMembers) { + if (members.some((m: RolePrincipal) => 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, roleMembers]); + + 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 index 14e3bea32d..5733a495ba 100644 --- a/frontend/src/components/pages/security/hooks/use-security-breadcrumbs.ts +++ b/frontend/src/components/pages/security/hooks/use-security-breadcrumbs.ts @@ -34,5 +34,6 @@ export function useSecurityBreadcrumbs(trail: { title: string; linkTo: string }[ useLayoutEffect(() => { uiState.pageBreadcrumbs = [...trail, { title: 'Access Control', linkTo: '/security' }]; uiState.pageTitle = 'Access Control'; - }, [key]); // eslint-disable-line react-hooks/exhaustive-deps + // biome-ignore lint/correctness/useExhaustiveDependencies: key is a stable serialized representation of trail + }, [key]); } 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-new.tsx b/frontend/src/components/pages/security/roles/role-detail-page-new.tsx new file mode 100644 index 0000000000..3a3d06ac4a --- /dev/null +++ b/frontend/src/components/pages/security/roles/role-detail-page-new.tsx @@ -0,0 +1,208 @@ +/** + * 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 { 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, useState } from 'react'; +import { toast } from 'sonner'; + +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 { 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 { Heading } from '../../../redpanda-ui/components/typography'; +import { parsePrincipal } from '../shared/acl-model'; +import { AclsCard } from '../shared/acls-card'; + +const routeApi = getRouteApi('/security/roles/$roleName/details'); + +export const RoleDetailPageNew = () => { + 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]); + + 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 allMembers = membersData?.members ?? []; + + const assignedPrincipals = new Set(allMembers.map((m) => m.principal)); + + const availablePrincipalOptions = (usersData?.users ?? []) + .filter((u) => !assignedPrincipals.has(`User:${u.name}`)) + .map((u) => ({ value: u.name, label: u.name })); + + 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 + } + }; + + 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 ( +
+ + + {/* Principals */} + + + } + > + + Principals + + + + + + + Name + Actions + + + + {membersLoading ? ( + + + Loading members... + + + ) : allMembers.length === 0 ? ( + + + + + + + + No principals assigned + + Assign users to this role to grant them its permissions. Use the dropdown above to add a + principal. + + + + + + + + + ) : ( + allMembers.map((member) => { + const parsed = parsePrincipal(member.principal); + const displayName = parsed.name || member.principal; + return ( + + {displayName} + + + + + ); + }) + )} + +
+
+
+
+ ); +}; 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..fedfa1f0ea 100644 --- a/frontend/src/components/pages/security/roles/role-detail-page.tsx +++ b/frontend/src/components/pages/security/roles/role-detail-page.tsx @@ -9,19 +9,17 @@ * by the Apache License, Version 2.0 */ -import { getRouteApi, useNavigate } from '@tanstack/react-router'; - -const routeApi = getRouteApi('/security/roles/$roleName/details'); - +import { useNavigate, useParams, useSearch } from '@tanstack/react-router'; import { Eye, Pencil } from 'lucide-react'; -import { useMemo } from 'react'; import { MatchingUsersCard } from './matching-users-card'; +import { RoleDetailPageNew } from './role-detail-page-new'; +import { isFeatureFlagEnabled } from '../../../../config'; import { useGetAclsByPrincipal } from '../../../../react-query/api/acl'; import { Button } from '../../../redpanda-ui/components/button'; import { Card, CardContent, CardHeader } from '../../../redpanda-ui/components/card'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../../redpanda-ui/components/table'; -import { Text } from '../../../redpanda-ui/components/typography'; +import { Heading, 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'; @@ -31,14 +29,15 @@ type SecurityAclRulesTableProps = { roleName: string; }; -// Table to display multiple ACL rules for a role const SecurityAclRulesTable = ({ data, roleName }: SecurityAclRulesTableProps) => { - const navigate = useNavigate(); + const navigate = useNavigate({ from: '/security/roles/$roleName/details' }); return ( -

Security ACL rules

+ + Security ACL rules +
@@ -66,7 +65,8 @@ const SecurityAclRulesTable = ({ data, roleName }: SecurityAclRulesTableProps) = + + + + + ); + } + 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..42dabc28e5 --- /dev/null +++ b/frontend/src/components/pages/security/shared/security-tabs-nav.tsx @@ -0,0 +1,122 @@ +/** + * 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 { isFeatureFlagEnabled, 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, + }); + } + + if (!isFeatureFlagEnabled('enableNewSecurityPage')) { + result.push({ + key: 'acls', + label: 'ACLs', + path: '/security/acls', + disabled: userData?.canListAcls === 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 index c92b12d02e..e8190cdbcf 100644 --- a/frontend/src/components/pages/security/tabs/acls-tab.tsx +++ b/frontend/src/components/pages/security/tabs/acls-tab.tsx @@ -13,6 +13,7 @@ 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 { isFeatureFlagEnabled } from 'config'; import { InfoIcon } from 'lucide-react'; import { ACL_Operation, @@ -47,9 +48,9 @@ import { import { useSecurityBreadcrumbs } from '../hooks/use-security-breadcrumbs'; import { AlertDeleteFailed } from '../shared/alert-delete-failed'; import { filterByName } from '../shared/filter-by-name'; +import { SecurityTabsNav } from '../shared/security-tabs-nav'; -export const AclsTab: FC = () => { - useSecurityBreadcrumbs([]); +const AclsTabContent: FC = () => { const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); const featureDeleteUser = useSupportedFeaturesStore((s) => s.deleteUser); const { data: redpandaInfo, isSuccess: isRedpandaInfoSuccess } = useGetRedpandaInfoQuery(); @@ -213,7 +214,7 @@ export const AclsTab: FC = () => { return ( - @@ -265,3 +266,17 @@ export const AclsTab: FC = () => {
); }; + +const AclsTabLegacy: FC = () => { + useSecurityBreadcrumbs([]); + return ; +}; + +const AclsTabNew: FC = () => ( + <> + + + +); + +export const AclsTab: FC = () => (isFeatureFlagEnabled('enableNewSecurityPage') ? : ); diff --git a/frontend/src/components/pages/security/tabs/permissions-list-tab-new.tsx b/frontend/src/components/pages/security/tabs/permissions-list-tab-new.tsx new file mode 100644 index 0000000000..8ab150c421 --- /dev/null +++ b/frontend/src/components/pages/security/tabs/permissions-list-tab-new.tsx @@ -0,0 +1,467 @@ +/** + * 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 { Link } from '@tanstack/react-router'; +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, + ACL_ResourcePatternType, + ACL_ResourceType, + DeleteACLsRequestSchema, +} from 'protogen/redpanda/api/dataplane/v1/acl_pb'; +import type { FC } 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 { 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 { Alert, AlertDescription, AlertTitle } 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 { 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 { DescriptionWithHelp } from '../shared/description-with-help'; +import { SecurityTabsNav } from '../shared/security-tabs-nav'; +import { AddAclDialog } from '../users/add-acl-dialog'; + +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 && ( + + + + )} + + +); + +type PrincipalRowProps = { + group: PrincipalPermissionGroup; + isExpanded: boolean; + onToggle: () => void; + onDelete: (deleteUser: boolean, deleteAcls: boolean) => void; + canDeleteUser: boolean; +}; + +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 (pendingDelete === 'user-and-acls') onDelete(true, true); + if (pendingDelete === 'user-only') onDelete(true, false); + }} + onOpenChange={(open) => { + if (!open) setPendingDelete(null); + }} + open={pendingDelete !== null} + userName={group.principalName} + /> + +
+ {/* Principal header row */} +
e.key === 'Enter' && onToggle()} + role="button" + tabIndex={0} + > + {isExpanded ? ( + + ) : ( + + )} + +
+ + {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" + > + {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 PermissionsListTabNew: FC = () => { + useLayoutEffect(() => { + setPageHeader('Security', [ + { title: 'Security', linkTo: '/security/users' }, + { title: 'Permissions', linkTo: '/security/permissions-list' }, + ]); + }, []); + const [aclFailed, setAclFailed] = useState<{ err: unknown } | null>(null); + 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 { mutateAsync: deleteACLMutation } = useDeleteAclMutation(); + const { mutateAsync: deleteUserMutation } = useDeleteUserMutation(); + const invalidateUsersCache = useInvalidateUsersCache(); + + 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, { + filter: { + principal: `${principalType}:${principalName}`, + resourceType: ACL_ResourceType.ANY, + resourceName: undefined, + host: undefined, + operation: ACL_Operation.ANY, + permissionType: ACL_PermissionType.ANY, + resourcePatternType: ACL_ResourcePatternType.ANY, + }, + }); + await deleteACLMutation(deleteRequest); + toast.success( + + Deleted ACLs for {principalName} + + ); + }; + + const onDelete = async (group: PrincipalPermissionGroup, deleteUser: boolean, deleteAcls: boolean) => { + if (deleteAcls) { + try { + await deleteACLsForPrincipal(group.principalName, group.principalType); + } catch (err: unknown) { + setAclFailed({ err }); + } + } + if (deleteUser) { + try { + await deleteUserMutation({ name: group.principalName }); + toast.success( + + Deleted user {group.principalName} + + ); + } catch (err: unknown) { + setAclFailed({ err }); + } + } + 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 ( + + Failed to load users + {usersError.message} + + ); + } + + if (isAclsError && aclsError) { + return ; + } + + 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. + + + + + 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/permissions-list-tab.test.tsx b/frontend/src/components/pages/security/tabs/permissions-list-tab.test.tsx index 294f1e5e17..a5d28447c9 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,69 @@ vi.mock('../../../../state/rest-interfaces', () => ({ AclRequestDefault: {}, })); -vi.mock('../../../misc/section', () => ({ - default: ({ children }: { children?: ReactNode }) =>
{children}
, -})); +vi.mock('../../../../config', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isFeatureFlagEnabled: (flag: string) => + flag === 'enableNewSecurityPage' ? true : actual.isFeatureFlagEnabled(flag), + }; +}); -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 +188,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 +203,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 +222,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..b7d0b34b6b 100644 --- a/frontend/src/components/pages/security/tabs/permissions-list-tab.tsx +++ b/frontend/src/components/pages/security/tabs/permissions-list-tab.tsx @@ -24,7 +24,9 @@ import type { FC } from 'react'; import { useState } from 'react'; import { toast } from 'sonner'; +import { PermissionsListTabNew } from './permissions-list-tab-new'; import ErrorResult from '../../../../components/misc/error-result'; +import { isFeatureFlagEnabled } from '../../../../config'; import { useDeleteAclMutation } from '../../../../react-query/api/acl'; import { useDeleteUserMutation, useInvalidateUsersCache } from '../../../../react-query/api/user'; import { appGlobal } from '../../../../state/app-global'; @@ -95,8 +97,10 @@ const PermissionsListActions = ({ /> - @@ -139,7 +143,7 @@ const PermissionsListActions = ({ ); }; -export const PermissionsListTab: FC = () => { +const PermissionsListTabOriginal: FC = () => { useSecurityBreadcrumbs([]); const [searchQuery, setSearchQuery] = useState(''); const [aclFailed, setAclFailed] = useState<{ err: unknown } | null>(null); @@ -321,3 +325,6 @@ export const PermissionsListTab: FC = () => { ); }; + +export const PermissionsListTab: FC = () => + isFeatureFlagEnabled('enableNewSecurityPage') ? : ; diff --git a/frontend/src/components/pages/security/tabs/roles-tab-new.tsx b/frontend/src/components/pages/security/tabs/roles-tab-new.tsx new file mode 100644 index 0000000000..3892e39dc2 --- /dev/null +++ b/frontend/src/components/pages/security/tabs/roles-tab-new.tsx @@ -0,0 +1,378 @@ +/** + * 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 { Link } from '@tanstack/react-router'; +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 { useEffect, useLayoutEffect, useState } from 'react'; +import { useStore } from 'zustand'; + +import ErrorResult from '../../../../components/misc/error-result'; +import { useDeleteRoleMutation, useListRolesQuery } from '../../../../react-query/api/security'; +import { rolesApi, useApiStoreHook, useRolesStore } 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 { Button } from '../../../redpanda-ui/components/button'; +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 RolesTabNew: FC = () => { + useLayoutEffect(() => { + setPageHeader('Security', [ + { title: 'Security', linkTo: '/security/users' }, + { title: 'Roles', linkTo: '/security/roles' }, + ]); + }, []); + + useEffect(() => { + rolesApi.refreshRoleMembers().catch(() => {}); + }, []); + const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); + const userData = useApiStoreHook((s) => s.userData); + const roleMembers = useStore(useRolesStore, (s) => s.roleMembers); + 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 rolesWithMembers: RoleEntry[] = (rolesData?.roles ?? []).map((r) => ({ + name: r.name, + members: 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 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) { + return ; + } + + const createRoleDisabled = userData?.canCreateRoles === false || !featureRolesApi; + const createRoleTooltip = [ + userData?.canCreateRoles === false && + 'You need KafkaAclOperation.KAFKA_ACL_OPERATION_ALTER and RedpandaCapability.MANAGE_RBAC permissions.', + !featureRolesApi && 'This feature is not enabled.', + ] + .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())} + + ))} + + )); + } + const isFiltered = columnFilters.length > 0; + return ( + + + + + + + + {isFiltered ? 'No roles match your search' : 'No roles yet'} + + {isFiltered + ? 'Try adjusting your filters.' + : 'Roles are groups of ACLs that can be assigned to principals. Create one to start managing access control.'} + + + {!isFiltered && ( + +
+ + +
+
+ )} +
+
+
+ ); + }; + + return ( + <> + + + + +
+ +
+
+ + + 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/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..e726d6485c 100644 --- a/frontend/src/components/pages/security/tabs/roles-tab.tsx +++ b/frontend/src/components/pages/security/tabs/roles-tab.tsx @@ -17,7 +17,9 @@ import { DeleteRoleRequestSchema } from 'protogen/redpanda/api/dataplane/v1/secu import type { FC } from 'react'; import { useState } from 'react'; +import { RolesTabNew } from './roles-tab-new'; import ErrorResult from '../../../../components/misc/error-result'; +import { isFeatureFlagEnabled } from '../../../../config'; import { useDeleteRoleMutation, useListRolesQuery } from '../../../../react-query/api/security'; import { appGlobal } from '../../../../state/app-global'; import { rolesApi, useApiStoreHook } from '../../../../state/backend-api'; @@ -31,7 +33,7 @@ import { useSecurityBreadcrumbs } from '../hooks/use-security-breadcrumbs'; import { DeleteRoleConfirmModal } from '../shared/delete-role-confirm-modal'; import { filterByName } from '../shared/filter-by-name'; -export const RolesTab: FC = () => { +const RolesTabOriginal: FC = () => { useSecurityBreadcrumbs([]); const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); const userData = useApiStoreHook((s) => s.userData); @@ -170,3 +172,6 @@ export const RolesTab: FC = () => { ); }; + +export const RolesTab: FC = () => + isFeatureFlagEnabled('enableNewSecurityPage') ? : ; diff --git a/frontend/src/components/pages/security/tabs/users-tab-new.tsx b/frontend/src/components/pages/security/tabs/users-tab-new.tsx new file mode 100644 index 0000000000..481396ef25 --- /dev/null +++ b/frontend/src/components/pages/security/tabs/users-tab-new.tsx @@ -0,0 +1,546 @@ +/** + * 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 { 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 { 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 { useLayoutEffect, useState } from 'react'; + +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 { useListRolesQuery } from '../../../../react-query/api/security'; +import { useDeleteUserMutation, useInvalidateUsersCache, useListUsersQuery } from '../../../../react-query/api/user'; +import { rolesApi, useApiStoreHook } from '../../../../state/backend-api'; +import { useSupportedFeaturesStore } from '../../../../state/supported-features'; +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 { 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 { 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()); + } +}; + +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, + featureCreateUser: boolean, + canManageUsers: boolean | undefined +) => { + const hasRBAC = canManageUsers !== undefined; + + 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(' '), + }; +}; + +export const UsersTabNew: FC = () => { + 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 [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, { + enabled: isAdminApiConfigured, + }); + + const users: PrincipalEntry[] = (usersData?.users ?? []).map((u) => ({ + name: u.name, + principalType: 'User' as const, + isScramUser: true, + mechanism: u.mechanism, + })); + + 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 ( + + Failed to load users + {error.message} + + ); + } + + 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())} + + ))} + + )); + } + const isFiltered = columnFilters.length > 0; + return ( + + + + + + + + {isFiltered ? 'No users match your search' : 'No users yet'} + + {isFiltered + ? 'Try adjusting your filters.' + : 'SASL-SCRAM user accounts managed by your cluster. Create one to start managing access.'} + + + {!isFiltered && ( + +
+ + +
+
+ )} +
+
+
+ ); + }; + + 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. + + + + + + + + + } + > + 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); + const { data, isLoading } = useListRolesQuery( + { filter: { principal: `User:${userName}` } }, + { enabled: featureRolesApi } + ); + + if (!featureRolesApi) { + return ; + } + + if (isLoading) { + return ; + } + + const roles = data?.roles ?? []; + + if (roles.length === 0) { + return None; + } + + return ( +
+ {roles.map((r) => ( + + {r.name} + + ))} +
+ ); +}; + +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 [isChangePasswordModalOpen, setIsChangePasswordModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const invalidateUsersCache = useInvalidateUsersCache(); + const { mutateAsync: deleteUserMutation } = useDeleteUserMutation(); + + const onConfirmDelete = async () => { + try { + await deleteUserMutation({ name: user.name }); + } 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: { name: string }) => m.name === user.name)) { + promises.push(rolesApi.updateRoleMembership(roleName, [], [{ name: user.name, principalType: 'User' }])); + } + } + + const results = await Promise.allSettled(promises); + const failures = results.filter((r) => r.status === 'rejected'); + if (failures.length > 0) { + // biome-ignore lint/suspicious/noConsole: error logging + console.error(`Failed to remove user from ${failures.length} role(s)`, failures); + } + + await Promise.all([rolesApi.refreshRoleMembers(), invalidateUsersCache()]); + }; + + return ( + <> + + + + + + + + + { + e.stopPropagation(); + setIsChangePasswordModalOpen(true); + }} + > + Change password + + { + e.stopPropagation(); + setIsDeleteModalOpen(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..efa74b3542 100644 --- a/frontend/src/components/pages/security/tabs/users-tab.tsx +++ b/frontend/src/components/pages/security/tabs/users-tab.tsx @@ -16,6 +16,8 @@ import { parseAsString } from 'nuqs'; import type { FC } from 'react'; import { useState } from 'react'; +import { UsersTabNew } from './users-tab-new'; +import { isFeatureFlagEnabled } from '../../../../config'; import { useQueryStateWithCallback } from '../../../../hooks/use-query-state-with-callback'; import { useGetRedpandaInfoQuery } from '../../../../react-query/api/cluster-status'; import { useDeleteUserMutation, useInvalidateUsersCache, useListUsersQuery } from '../../../../react-query/api/user'; @@ -59,7 +61,7 @@ const getCreateUserButtonProps = ( }; }; -export const UsersTab: FC = () => { +const UsersTabOriginal: FC = () => { useSecurityBreadcrumbs([]); const { data: redpandaInfo, isSuccess: isRedpandaInfoSuccess } = useGetRedpandaInfoQuery(); const isAdminApiConfigured = isRedpandaInfoSuccess && Boolean(redpandaInfo); @@ -248,8 +250,10 @@ const UserActions = ({ user }: { user: PrincipalEntry }) => { - @@ -284,3 +288,6 @@ const UserActions = ({ user }: { user: PrincipalEntry }) => { ); }; + +export const UsersTab: FC = () => + isFeatureFlagEnabled('enableNewSecurityPage') ? : ; 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..52891c2529 --- /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.resourceType === ACL_ResourceType.CLUSTER ? 'kafka-cluster' : 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-new.tsx b/frontend/src/components/pages/security/users/user-acls-card-new.tsx new file mode 100644 index 0000000000..79a2a1c49b --- /dev/null +++ b/frontend/src/components/pages/security/users/user-acls-card-new.tsx @@ -0,0 +1,23 @@ +/** + * 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 type { AclDetail } from '../shared/acl-model'; +import { AclsCard } from '../shared/acls-card'; + +type UserAclsCardNewProps = { + acls?: AclDetail[]; + userName?: string; + isLoading?: boolean; +}; + +export const UserAclsCardNew = ({ acls, userName, isLoading }: UserAclsCardNewProps) => ( + +); 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..b9ed99eed4 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,7 +58,6 @@ 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(); }); @@ -66,25 +65,21 @@ describe('UserAclsCard', () => { 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(); }); - test('should render ACL table with rows, action buttons, and headers', () => { + test('should render ACL table grouped by principal and host', () => { renderWithFileRoutes(); - // Count, rows, action buttons, and headers all rendered together so we assert them once. - expect(screen.getByText('ACLs (2)')).toBeInTheDocument(); + // Table headers + expect(screen.getByRole('columnheader', { name: 'Name' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Hosts' })).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'); + // Two rows, one per ACL group (principal+host) + expect(screen.getAllByText('User:test-user')).toHaveLength(2); - // Action buttons per row - expect(screen.getByTestId('toggle-acl-User:test-user-*')).toBeInTheDocument(); - expect(screen.getByTestId('edit-acl-User:test-user-*')).toBeInTheDocument(); + // Host values + expect(screen.getByText('*')).toBeInTheDocument(); + 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..6295f1f98d 100644 --- a/frontend/src/components/pages/security/users/user-acls-card.tsx +++ b/frontend/src/components/pages/security/users/user-acls-card.tsx @@ -16,6 +16,7 @@ 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 { Text } from '../../../redpanda-ui/components/typography'; import { type AclDetail, getRuleDataTestId, parsePrincipal } from '../shared/acl-model'; import { OperationsBadge } from '../shared/operations-badge'; @@ -31,7 +32,7 @@ type AclTableRowProps = { const AclTableRow = ({ acl, isExpanded, onToggle }: AclTableRowProps) => { const rowKey = `${acl.sharedConfig.principal}-${acl.sharedConfig.host}`; - const navigate = useNavigate(); + const navigate = useNavigate({ from: '/security/users/$userName/details' }); return [ @@ -46,7 +47,8 @@ const AclTableRow = ({ acl, isExpanded, onToggle }: AclTableRowProps) => { onClick={(e) => { e.stopPropagation(); navigate({ - to: `/security/acls/${parsePrincipal(acl.sharedConfig.principal).name}/details`, + to: '/security/acls/$aclName/details', + params: { aclName: parsePrincipal(acl.sharedConfig.principal).name }, search: { host: acl.sharedConfig.host }, }); }} @@ -81,7 +83,7 @@ const AclTableRow = ({ acl, isExpanded, onToggle }: AclTableRowProps) => { }; export const UserAclsCard = ({ acls }: UserAclsCardProps) => { - const navigate = useNavigate(); + const navigate = useNavigate({ from: '/security/users/$userName/details' }); const [expandedRows, setExpandedRows] = useState>(new Set()); const toggleRow = (key: string) => { @@ -117,7 +119,7 @@ export const UserAclsCard = ({ acls }: UserAclsCardProps) => { -

No ACLs assigned to this user.

+ No ACLs assigned to this user.
); @@ -141,7 +143,6 @@ export const UserAclsCard = ({ acls }: UserAclsCardProps) => { {acls.flatMap((acl) => { const rowKey = `${acl.sharedConfig.principal}-${acl.sharedConfig.host}`; const isExpanded = expandedRows.has(rowKey); - return ( 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-new.tsx b/frontend/src/components/pages/security/users/user-details-new.tsx new file mode 100644 index 0000000000..cf36fe9b19 --- /dev/null +++ b/frontend/src/components/pages/security/users/user-details-new.tsx @@ -0,0 +1,153 @@ +/** + * 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 { Button } from 'components/redpanda-ui/components/button'; +import type { UpdateRoleMembershipResponse } from 'protogen/redpanda/api/console/v1alpha1/security_pb'; +import { SASLMechanism } from 'protogen/redpanda/api/dataplane/v1/user_pb'; +import { useEffect, useLayoutEffect, useState } from 'react'; + +import { UserAclsCardNew } from './user-acls-card-new'; +import { ChangePasswordModal } from './user-edit-modals'; +import { UserRolesCardNew } from './user-roles-card-new'; +import { useGetAclsByPrincipal } from '../../../../react-query/api/acl'; +import { useListRolesQuery } from '../../../../react-query/api/security'; +import { invalidateUsersCache, useDeleteUserMutation, useListUsersQuery } from '../../../../react-query/api/user'; +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 { 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; +}; + +export const UserDetailsPageNew = ({ userName }: UserDetailsPageProps) => { + const [isChangePasswordModalOpen, setIsChangePasswordModalOpen] = useState(false); + 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(); + + 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 () => { + if (api.userData !== null && api.userData !== undefined && !api.userData.canListAcls) { + return; + } + await Promise.allSettled([api.refreshAcls(AclRequestDefault, true), rolesApi.refreshRoles()]); + await rolesApi.refreshRoleMembers(); + }; + + // biome-ignore lint/suspicious/noConsole: error logging for unhandled promise rejections + refreshData().catch(console.error); + appGlobal.onRefresh = () => + // biome-ignore lint/suspicious/noConsole: error logging for unhandled promise rejections + refreshData().catch(console.error); + }, [userName]); + + if (isUsersLoading) { + return DefaultSkeleton; + } + + 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 ( +
+
+ + {Boolean(isServiceAccount) && ( + + )} +
+ + + + + + +
+ ); +}; + +const UserPermissionDetailsContent = ({ userName }: { userName: string }) => { + const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); + const { data: rolesData, isLoading: isRolesLoading } = useListRolesQuery({ filter: { principal: userName } }); + const { data: acls, isLoading: isAclsLoading } = useGetAclsByPrincipal(`User:${userName}`); + + const roles = featureRolesApi + ? (rolesData?.roles ?? []).map((r) => ({ + principalType: 'RedpandaRole', + principalName: r.name, + })) + : []; + + return ( +
+ + +
+ ); +}; diff --git a/frontend/src/components/pages/security/users/user-details.tsx b/frontend/src/components/pages/security/users/user-details.tsx index d22d51dff6..058b0d7e15 100644 --- a/frontend/src/components/pages/security/users/user-details.tsx +++ b/frontend/src/components/pages/security/users/user-details.tsx @@ -14,9 +14,11 @@ import type { UpdateRoleMembershipResponse } from 'protogen/redpanda/api/console import { useEffect, useState } from 'react'; import { UserAclsCard } from './user-acls-card'; +import { UserDetailsPageNew } from './user-details-new'; import { ChangePasswordModal, ChangeRolesModal } from './user-edit-modals'; import { UserInformationCard } from './user-information-card'; import { UserRolesCard } from './user-roles-card'; +import { isFeatureFlagEnabled } from '../../../../config'; import { useGetAclsByPrincipal } from '../../../../react-query/api/acl'; import { useListRolesQuery } from '../../../../react-query/api/security'; import { invalidateUsersCache, useDeleteUserMutation, useListUsersQuery } from '../../../../react-query/api/user'; @@ -25,6 +27,7 @@ import { api, rolesApi } from '../../../../state/backend-api'; import { AclRequestDefault } from '../../../../state/rest-interfaces'; import { useSupportedFeaturesStore } from '../../../../state/supported-features'; import { DefaultSkeleton } from '../../../../utils/tsx-utils'; +import { Heading } from '../../../redpanda-ui/components/typography'; import { useSecurityBreadcrumbs } from '../hooks/use-security-breadcrumbs'; import { DeleteUserConfirmModal } from '../shared/delete-user-confirm-modal'; @@ -70,7 +73,9 @@ const UserDetailsPage = ({ userName }: UserDetailsPageProps) => { return (
-

User: {userName}

+ + User: {userName} +
{ @@ -100,10 +105,9 @@ const UserDetailsPage = ({ userName }: UserDetailsPageProps) => { try { await deleteUserMutation({ name: userName }); } catch { - return; // Error toast shown by mutation's onError + return; } - // 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)) { @@ -135,8 +139,6 @@ const UserDetailsPage = ({ userName }: UserDetailsPageProps) => { ); }; -export default UserDetailsPage; - const UserPermissionDetailsContent = ({ userName, onChangeRoles, @@ -162,3 +164,12 @@ const UserPermissionDetailsContent = ({
); }; + +const UserDetailsPageDispatcher = ({ userName }: { userName: string }) => + isFeatureFlagEnabled('enableNewSecurityPage') ? ( + + ) : ( + + ); + +export default UserDetailsPageDispatcher; diff --git a/frontend/src/components/pages/security/users/user-roles-card-new.tsx b/frontend/src/components/pages/security/users/user-roles-card-new.tsx new file mode 100644 index 0000000000..a39f14ced5 --- /dev/null +++ b/frontend/src/components/pages/security/users/user-roles-card-new.tsx @@ -0,0 +1,209 @@ +/** + * 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 { 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 { useState } from 'react'; + +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 { Combobox } from '../../../redpanda-ui/components/combobox'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '../../../redpanda-ui/components/dialog'; +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 { Heading } from '../../../redpanda-ui/components/typography'; + +type Role = { + principalType: string; + principalName: string; +}; + +type UserRolesCardNewProps = { + roles: Role[]; + userName?: string; + isLoading?: boolean; +}; + +export const UserRolesCardNew = ({ roles, userName, isLoading }: UserRolesCardNewProps) => { + const { mutateAsync: updateRoleMembership, isPending } = useUpdateRoleMembershipMutation(); + const { data: rolesData } = useListRolesQuery(); + const [pendingRemoveRole, setPendingRemoveRole] = useState(null); + + const assignedRoleNames = new Set(roles.map((r) => r.principalName)); + + const availableRoleOptions = (rolesData?.roles ?? []) + .filter((r) => !assignedRoleNames.has(r.name)) + .map((r) => ({ value: r.name, label: r.name })); + + const removeFromRole = async (roleName: string) => { + if (!userName) return; + await updateRoleMembership( + create(UpdateRoleMembershipRequestSchema, { roleName, remove: [{ principal: userName }] }) + ); + await Promise.all([rolesApi.refreshRoles(), rolesApi.refreshRoleMembers()]); + setPendingRemoveRole(null); + }; + + const assignRole = async (roleName: string) => { + if (!(userName && roleName)) return; + await updateRoleMembership(create(UpdateRoleMembershipRequestSchema, { roleName, add: [{ principal: userName }] })); + await Promise.all([rolesApi.refreshRoles(), rolesApi.refreshRoleMembers()]); + }; + + const count = roles.length; + + const renderBody = () => { + if (isLoading) { + return [0, 1, 2].map((i) => ( + + + + + + + )); + } + if (count === 0) { + return ( + + + + + + + + No roles assigned + Assign a role to grant this user permissions on cluster resources. + + + + + + + + ); + } + return roles.map((r) => ( + + {r.principalName} + +
+ {Boolean(userName) && ( + + )} + +
+
+
+ )); + }; + + return ( + <> + + + ) : undefined + } + > + + Roles + + + + + + + Name + Actions + + + {renderBody()} +
+
+
+ + !open && setPendingRemoveRole(null)} open={pendingRemoveRole !== null}> + + + Remove role + + + Remove role {pendingRemoveRole} from user {userName}? The user will lose + all permissions granted by this role. + + + + + + + + + ); +}; 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..a57ca00a84 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 @@ -33,8 +33,7 @@ describe('UserRolesCard', () => { }); test('should render Assign Role button in empty state when onChangeRoles is provided', () => { - const mockOnChangeRoles = vi.fn(); - renderWithFileRoutes(); + renderWithFileRoutes(); expect(screen.getByTestId('assign-role-button')).toBeInTheDocument(); }); @@ -58,8 +57,7 @@ describe('UserRolesCard', () => { }); test('should render Change Role button when roles exist and onChangeRoles is provided', () => { - const mockOnChangeRoles = vi.fn(); - renderWithFileRoutes(); + renderWithFileRoutes(); expect(screen.getByTestId('change-role-button')).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..2467beab7f 100644 --- a/frontend/src/components/pages/security/users/user-roles-card.tsx +++ b/frontend/src/components/pages/security/users/user-roles-card.tsx @@ -18,6 +18,7 @@ import { Button } from '../../../redpanda-ui/components/button'; import { Card, CardAction, CardContent, CardHeader, CardTitle } from '../../../redpanda-ui/components/card'; 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 { AclDetail } from '../shared/acl-model'; import { getRuleDataTestId } from '../shared/acl-model'; import { OperationsBadge } from '../shared/operations-badge'; @@ -27,7 +28,7 @@ type Role = { principalName: string; }; -type UserRolesCardProps = { +export type UserRolesCardProps = { roles: Role[]; onChangeRoles?: () => void; }; @@ -39,14 +40,12 @@ type RoleTableRowProps = { }; const RoleTableRow = ({ role, isExpanded, onToggle }: RoleTableRowProps) => { - const navigate = useNavigate(); + const navigate = useNavigate({ from: '/security/users/$userName/details' }); const { data: acls, isLoading } = useGetAclsByPrincipal( `RedpandaRole:${role.principalName}`, undefined, undefined, - { - enabled: isExpanded, - } + { enabled: isExpanded } ); const rowKey = role.principalName; @@ -61,7 +60,7 @@ const RoleTableRow = ({ role, isExpanded, onToggle }: RoleTableRowProps) => {