From fe1faab312b8834bc888f06454d1f4ee639a63ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Thu, 23 Apr 2026 17:13:49 +0200 Subject: [PATCH 1/7] Move Security components to UI registry --- .../license/feature-license-notification.tsx | 95 ++-- .../security/tabs/permissions-list-tab.tsx | 331 ++++++++------ .../pages/security/tabs/roles-tab.tsx | 321 +++++++++----- .../pages/security/tabs/users-tab.tsx | 409 +++++++++++++----- .../security/users/user-acls-card.test.tsx | 35 +- .../pages/security/users/user-acls-card.tsx | 226 +++++----- .../security/users/user-create-dialog.tsx | 123 ++++++ .../pages/security/users/user-create.tsx | 145 ++++--- .../pages/security/users/user-details.tsx | 147 ++++--- .../pages/security/users/user-roles-card.tsx | 205 +++------ .../redpanda-ui/components/multi-select.tsx | 2 +- frontend/src/routes/security.tsx | 10 +- frontend/tests/shared/global-setup.mjs | 17 +- .../users.spec.ts | 4 +- .../acls/user-management.spec.ts | 35 +- .../utils/security-page.ts | 6 +- 16 files changed, 1286 insertions(+), 825 deletions(-) create mode 100644 frontend/src/components/pages/security/users/user-create-dialog.tsx diff --git a/frontend/src/components/license/feature-license-notification.tsx b/frontend/src/components/license/feature-license-notification.tsx index daaa862026..1bc647941d 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', }; } } @@ -220,16 +220,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/security/tabs/permissions-list-tab.tsx b/frontend/src/components/pages/security/tabs/permissions-list-tab.tsx index dbd63c3a92..e4478c21ee 100644 --- a/frontend/src/components/pages/security/tabs/permissions-list-tab.tsx +++ b/frontend/src/components/pages/security/tabs/permissions-list-tab.tsx @@ -10,9 +10,31 @@ */ import { create } from '@bufbuild/protobuf'; -import { DataTable, SearchField } from '@redpanda-data/ui'; 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 { TrashIcon } from 'components/icons'; +import { + ListLayout, + ListLayoutContent, + ListLayoutFilters, + ListLayoutHeader, + ListLayoutPagination, + ListLayoutSearchInput, +} from 'components/redpanda-ui/components/list-layout'; +import { parseAsString, useQueryStates } from 'nuqs'; import { ACL_Operation, ACL_PermissionType, @@ -21,52 +43,40 @@ import { DeleteACLsRequestSchema, } from 'protogen/redpanda/api/dataplane/v1/acl_pb'; import type { FC } from 'react'; -import { useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { toast } from 'sonner'; import ErrorResult from '../../../../components/misc/error-result'; import { useDeleteAclMutation } from '../../../../react-query/api/acl'; import { useDeleteUserMutation, useInvalidateUsersCache } from '../../../../react-query/api/user'; -import { appGlobal } from '../../../../state/app-global'; -import { api, useApiStoreHook } from '../../../../state/backend-api'; +import { api } from '../../../../state/backend-api'; import { AclRequestDefault } from '../../../../state/rest-interfaces'; import { useSupportedFeaturesStore } from '../../../../state/supported-features'; import { Code as CodeEl } from '../../../../utils/tsx-utils'; -import Section from '../../../misc/section'; import { Alert, AlertDescription, AlertTitle } from '../../../redpanda-ui/components/alert'; import { Badge } from '../../../redpanda-ui/components/badge'; import { Button } from '../../../redpanda-ui/components/button'; +import { DataTableColumnHeader, DataTablePagination } from '../../../redpanda-ui/components/data-table'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '../../../redpanda-ui/components/dropdown-menu'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../../redpanda-ui/components/tooltip'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../../redpanda-ui/components/table'; import { type PrincipalEntry, usePrincipalList } from '../hooks/use-principal-list'; import { useSecurityBreadcrumbs } from '../hooks/use-security-breadcrumbs'; import { AlertDeleteFailed } from '../shared/alert-delete-failed'; import { DeleteUserConfirmModal } from '../shared/delete-user-confirm-modal'; -import { filterByName } from '../shared/filter-by-name'; import { UserRoleTags } from '../shared/user-role-tags'; -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(' '), - }; +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 PermissionsListActions = ({ @@ -140,17 +150,40 @@ const PermissionsListActions = ({ }; export const PermissionsListTab: FC = () => { - useSecurityBreadcrumbs([]); - const [searchQuery, setSearchQuery] = useState(''); + useSecurityBreadcrumbs([{ title: 'Permissions', linkTo: '/security/permissions-list' }]); const [aclFailed, setAclFailed] = useState<{ err: unknown } | null>(null); - const featureCreateUser = useSupportedFeaturesStore((s) => s.createUser); const featureDeleteUser = useSupportedFeaturesStore((s) => s.deleteUser); - const userData = useApiStoreHook((s) => s.userData); const { mutateAsync: deleteACLMutation } = useDeleteAclMutation(); const { mutateAsync: deleteUserMutation } = useDeleteUserMutation(); const invalidateUsersCache = useInvalidateUsersCache(); + const [sorting, setSorting] = useState([]); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + + const [urlFilterParams, setUrlFilterParams] = useQueryStates({ + name: parseAsString, + }); - const { principals, isAdminApiConfigured, isUsersError, usersError, isAclsError, aclsError } = usePrincipalList(); + const columnFilters = useMemo(() => { + const result: ColumnFiltersState = []; + if (urlFilterParams.name) { + result.push({ id: 'name', value: urlFilterParams.name }); + } + return result; + }, [urlFilterParams]); + + const handleColumnFiltersChange = useCallback( + (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, + }); + }, + [columnFilters, setUrlFilterParams] + ); + + const { principals, isUsersError, usersError, isAclsError, aclsError } = usePrincipalList(); const deleteACLsForPrincipal = async (principalName: string, principalType: 'User' | 'Group' = 'User') => { const deleteRequest = create(DeleteACLsRequestSchema, { @@ -198,6 +231,91 @@ export const PermissionsListTab: FC = () => { await Promise.allSettled([api.refreshAcls(AclRequestDefault, true), invalidateUsersCache()]); }; + const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]); + + const handlePaginationChange = useCallback( + (updater: Updater) => { + const next = typeof updater === 'function' ? updater(pagination) : updater; + setPageIndex(next.pageIndex); + setPageSize(next.pageSize); + }, + [pagination] + ); + + const columns = useMemo[]>( + () => [ + { + accessorKey: 'name', + header: ({ column }) => , + cell: ({ row: { original: entry } }) => { + if (entry.principalType === 'Group') { + return ( + + + {entry.name} + Group + + + ); + } + return ( + + {entry.name} + + ); + }, + filterFn: nameFilterFn, + }, + { + id: 'assignedRoles', + header: 'Permissions', + enableSorting: false, + cell: ({ row: { original: entry } }) => ( + + ), + }, + { + id: 'menu', + header: '', + enableSorting: false, + meta: { align: 'right' as const }, + cell: ({ row: { original: entry } }) => ( + + ), + }, + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [featureDeleteUser] + ); + + const table = useReactTable({ + data: principals, + columns, + state: { sorting, pagination, columnFilters }, + onSortingChange: setSorting, + onPaginationChange: handlePaginationChange, + onColumnFiltersChange: handleColumnFiltersChange, + autoResetPageIndex: false, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + if (isUsersError && usersError) { return ( @@ -211,113 +329,58 @@ export const PermissionsListTab: FC = () => { return ; } - const usersFiltered = filterByName(principals, searchQuery, (u) => u.name); - return ( -
-
- This page provides a detailed overview of all effective permissions for each principal, including those derived - from assigned roles. While the ACLs tab shows permissions directly granted to principals, this tab also - incorporates roles that may assign additional permissions to a principal. This gives you a complete picture of - what each principal can do within your cluster. -
+ + - + + table.getColumn('name')?.setFilterValue(e.target.value || undefined)} + placeholder="Filter by name (regexp)..." + value={(table.getColumn('name')?.getFilterValue() as string) ?? ''} + /> + -
- setAclFailed(null)} /> -
- - columns={[ - { - id: 'name', - size: Number.POSITIVE_INFINITY, - header: 'Principal', - cell: (ctx) => { - const entry = ctx.row.original; - if (entry.principalType === 'Group') { - return ( - - - {entry.name} - Group - - - ); - } - return ( - - {entry.name} - - ); - }, - }, - { - id: 'assignedRoles', - header: 'Permissions', - cell: (ctx) => { - const entry = ctx.row.original; - return ; - }, - }, - { - size: 60, - id: 'menu', - header: '', - cell: ({ row: { original: entry } }) => ( - - ), - }, - ]} - data={usersFiltered} - emptyAction={(() => { - const { disabled, tooltip } = getCreateUserButtonProps( - isAdminApiConfigured, - featureCreateUser, - userData?.canManageUsers - ); - return ( - - - - - - {tooltip && {tooltip}} - - - ); - })()} - emptyText="No principals yet" - pagination - sorting - /> -
-
-
+ {aclFailed && setAclFailed(null)} />} + + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {table.getRowModel().rows.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No principals yet. + + + )} + +
+
+ + + + + ); }; diff --git a/frontend/src/components/pages/security/tabs/roles-tab.tsx b/frontend/src/components/pages/security/tabs/roles-tab.tsx index db481761a2..155a5021ab 100644 --- a/frontend/src/components/pages/security/tabs/roles-tab.tsx +++ b/frontend/src/components/pages/security/tabs/roles-tab.tsx @@ -10,42 +10,190 @@ */ import { create } from '@bufbuild/protobuf'; -import { DataTable, SearchField } from '@redpanda-data/ui'; -import { Link } from '@tanstack/react-router'; +import { Link, useNavigate } 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 { EditIcon, TrashIcon } from 'components/icons'; +import { + ListLayout, + ListLayoutContent, + ListLayoutFilters, + ListLayoutHeader, + ListLayoutPagination, + ListLayoutSearchInput, +} from 'components/redpanda-ui/components/list-layout'; +import { parseAsString, useQueryStates } from 'nuqs'; import { DeleteRoleRequestSchema } from 'protogen/redpanda/api/dataplane/v1/security_pb'; import type { FC } from 'react'; -import { useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import ErrorResult from '../../../../components/misc/error-result'; import { useDeleteRoleMutation, useListRolesQuery } from '../../../../react-query/api/security'; -import { appGlobal } from '../../../../state/app-global'; import { rolesApi, useApiStoreHook } from '../../../../state/backend-api'; import { useSupportedFeaturesStore } from '../../../../state/supported-features'; import { FeatureLicenseNotification } from '../../../license/feature-license-notification'; import { NullFallbackBoundary } from '../../../misc/null-fallback-boundary'; -import Section from '../../../misc/section'; import { Button } from '../../../redpanda-ui/components/button'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../../redpanda-ui/components/tooltip'; +import { 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 { useSecurityBreadcrumbs } from '../hooks/use-security-breadcrumbs'; import { DeleteRoleConfirmModal } from '../shared/delete-role-confirm-modal'; -import { filterByName } from '../shared/filter-by-name'; + +type RoleEntry = { + name: string; + members: unknown[]; +}; + +const nameFilterFn = (row: Row, columnId: string, filterValue: string) => { + if (!filterValue) return true; + try { + return new RegExp(filterValue, 'i').test(String(row.getValue(columnId))); + } catch { + return String(row.getValue(columnId)).toLowerCase().includes(filterValue.toLowerCase()); + } +}; export const RolesTab: FC = () => { - useSecurityBreadcrumbs([]); + useSecurityBreadcrumbs([{ title: 'Roles', linkTo: '/security/roles' }]); + const navigate = useNavigate(); const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); const userData = useApiStoreHook((s) => s.userData); - const [searchQuery, setSearchQuery] = useState(''); + const [sorting, setSorting] = useState([]); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + + const [urlFilterParams, setUrlFilterParams] = useQueryStates({ + name: parseAsString, + }); + + const columnFilters = useMemo(() => { + const result: ColumnFiltersState = []; + if (urlFilterParams.name) { + result.push({ id: 'name', value: urlFilterParams.name }); + } + return result; + }, [urlFilterParams]); + + const handleColumnFiltersChange = useCallback( + (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, + }); + }, + [columnFilters, setUrlFilterParams] + ); + const { data: rolesData, isError: rolesIsError, error: rolesError } = useListRolesQuery(); const { mutateAsync: deleteRoleMutation } = useDeleteRoleMutation(); - const roles = filterByName(rolesData?.roles ?? [], searchQuery, (r) => r.name); - - const rolesWithMembers = roles.map((r) => { + const rolesWithMembers: RoleEntry[] = (rolesData?.roles ?? []).map((r) => { const members = rolesApi.roleMembers.get(r.name) ?? []; return { name: r.name, members }; }); + const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]); + + const handlePaginationChange = useCallback( + (updater: Updater) => { + const next = typeof updater === 'function' ? updater(pagination) : updater; + setPageIndex(next.pageIndex); + setPageSize(next.pageSize); + }, + [pagination] + ); + + const columns = useMemo[]>( + () => [ + { + 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 } }) => ( +
+ + + + + } + numberOfPrincipals={entry.members.length} + onConfirm={async () => { + await deleteRoleMutation(create(DeleteRoleRequestSchema, { roleName: entry.name, deleteAcls: true })); + }} + roleName={entry.name} + /> +
+ ), + }, + ], + [navigate, deleteRoleMutation] + ); + + 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 ; } @@ -60,113 +208,74 @@ export const RolesTab: FC = () => { .join(' '); return ( -
-
- This tab displays all roles. Roles are groups of access control lists (ACLs) that can be assigned to principals. - A principal represents any entity that can be authenticated, such as a user, service, or system (for example, a - SASL-SCRAM user, OIDC identity, or mTLS client). -
+ + + - -
- + + {createRoleTooltip && {createRoleTooltip}} - - -
- { - const entry = ctx.row.original; - return ( - - {entry.name} - - ); - }, - }, - { - id: 'assignedPrincipals', - header: 'Assigned principals', - cell: (ctx) => <>{ctx.row.original.members.length}, - }, - { - size: 60, - id: 'menu', - header: '', - cell: (ctx) => { - const entry = ctx.row.original; - return ( -
- - - - - } - numberOfPrincipals={entry.members.length} - onConfirm={async () => { - await deleteRoleMutation( - create(DeleteRoleRequestSchema, { roleName: entry.name, deleteAcls: true }) - ); - }} - roleName={entry.name} - /> -
- ); - }, - }, - ]} - data={rolesWithMembers} - pagination - sorting - /> -
-
-
+ } + > + 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())} + + ))} + + ))} + + + {table.getRowModel().rows.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No roles yet. + + + )} + +
+
+ + + + + ); }; diff --git a/frontend/src/components/pages/security/tabs/users-tab.tsx b/frontend/src/components/pages/security/tabs/users-tab.tsx index d5de970702..9ec9846c35 100644 --- a/frontend/src/components/pages/security/tabs/users-tab.tsx +++ b/frontend/src/components/pages/security/tabs/users-tab.tsx @@ -9,36 +9,97 @@ * by the Apache License, Version 2.0 */ -import { DataTable, SearchField } from '@redpanda-data/ui'; -import { Link } from '@tanstack/react-router'; +import { useQuery } from '@connectrpc/connect-query'; +import { Link, useNavigate } from '@tanstack/react-router'; +import { + type ColumnDef, + type ColumnFiltersState, + flexRender, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type Row, + type SortingState, + type Updater, + useReactTable, +} from '@tanstack/react-table'; import { MoreHorizontalIcon } from 'components/icons'; -import { parseAsString } from 'nuqs'; +import { + ListLayout, + ListLayoutContent, + ListLayoutFilters, + ListLayoutHeader, + ListLayoutPagination, + ListLayoutSearchInput, +} from 'components/redpanda-ui/components/list-layout'; +import { parseAsArrayOf, parseAsString, useQueryStates } from 'nuqs'; import type { FC } from 'react'; -import { useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; -import { useQueryStateWithCallback } from '../../../../hooks/use-query-state-with-callback'; +import type { ListACLsRequest } from '../../../../protogen/redpanda/api/dataplane/v1/acl_pb'; +import { listACLs } from '../../../../protogen/redpanda/api/dataplane/v1/acl-ACLService_connectquery'; +import { SASLMechanism } from '../../../../protogen/redpanda/api/dataplane/v1/user_pb'; import { useGetRedpandaInfoQuery } from '../../../../react-query/api/cluster-status'; import { useDeleteUserMutation, useInvalidateUsersCache, useListUsersQuery } from '../../../../react-query/api/user'; -import { appGlobal } from '../../../../state/app-global'; import { rolesApi, useApiStoreHook } from '../../../../state/backend-api'; import { useSupportedFeaturesStore } from '../../../../state/supported-features'; -import Section from '../../../misc/section'; import { Alert, AlertDescription, AlertTitle } from '../../../redpanda-ui/components/alert'; +import { Badge } from '../../../redpanda-ui/components/badge'; import { Button } from '../../../redpanda-ui/components/button'; +import { + DataTableColumnHeader, + DataTableFacetedFilter, + DataTablePagination, +} from '../../../redpanda-ui/components/data-table'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '../../../redpanda-ui/components/dropdown-menu'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../../redpanda-ui/components/tooltip'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../../redpanda-ui/components/table'; +import { TagsValue } from '../../../redpanda-ui/components/tags'; +import { Tooltip, TooltipContent, TooltipTrigger } from '../../../redpanda-ui/components/tooltip'; import { useSecurityBreadcrumbs } from '../hooks/use-security-breadcrumbs'; import { DeleteUserConfirmModal } from '../shared/delete-user-confirm-modal'; -import { filterByName } from '../shared/filter-by-name'; -import { UserRoleTags } from '../shared/user-role-tags'; +import { CreateUserDialog } from '../users/user-create-dialog'; import { ChangePasswordModal, ChangeRolesModal } from '../users/user-edit-modals'; -type PrincipalEntry = { name: string; principalType: 'User' | 'Group'; isScramUser: boolean }; +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, @@ -60,22 +121,47 @@ const getCreateUserButtonProps = ( }; export const UsersTab: FC = () => { - useSecurityBreadcrumbs([]); + useSecurityBreadcrumbs([{ title: 'Users', linkTo: '/security/users' }]); const { data: redpandaInfo, isSuccess: isRedpandaInfoSuccess } = useGetRedpandaInfoQuery(); const isAdminApiConfigured = isRedpandaInfoSuccess && Boolean(redpandaInfo); const featureCreateUser = useSupportedFeaturesStore((s) => s.createUser); const userData = useApiStoreHook((s) => s.userData); - const [searchQuery, setSearchQuery] = useQueryStateWithCallback( - { - onUpdate: () => { - // Query state is managed by the URL - }, - getDefaultValue: () => '', + + 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 = useMemo(() => { + const result: ColumnFiltersState = []; + if (urlFilterParams.name) { + result.push({ id: 'name', value: urlFilterParams.name }); + } + if (urlFilterParams.mechanism?.length) { + result.push({ id: 'mechanism', value: urlFilterParams.mechanism }); + } + return result; + }, [urlFilterParams]); + + const handleColumnFiltersChange = useCallback( + (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, + }); }, - 'q', - parseAsString.withDefault('') + [columnFilters, setUrlFilterParams] ); + const { data: usersData, isError, @@ -88,9 +174,89 @@ export const UsersTab: FC = () => { name: u.name, principalType: 'User' as const, isScramUser: true, + mechanism: u.mechanism, })); - const usersFiltered = filterByName(users, searchQuery, (u) => u.name); + const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]); + + const handlePaginationChange = useCallback( + (updater: Updater) => { + const next = typeof updater === 'function' ? updater(pagination) : updater; + setPageIndex(next.pageIndex); + setPageSize(next.pageSize); + }, + [pagination] + ); + + const columns = useMemo[]>( + () => [ + { + 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 ( @@ -108,94 +274,131 @@ export const UsersTab: FC = () => { ); return ( -
-
- These users are SASL-SCRAM users managed by your cluster. View permissions for other authentication identities - (for example, OIDC, mTLS) on the Permissions List page. -
- - setSearchQuery(x)} - width="300px" - /> + <> + + + -
- - - - - - {createTooltip && {createTooltip}} - - - -
- - columns={[ - { - id: 'name', - size: Number.POSITIVE_INFINITY, - header: 'User', - cell: (ctx) => { - const entry = ctx.row.original; - return ( - - {entry.name} - - ); - }, - }, - { - id: 'assignedRoles', - header: 'Permissions', - cell: (ctx) => { - const entry = ctx.row.original; - return ; - }, - }, - { - size: 60, - id: 'menu', - header: '', - cell: (ctx) => { - const entry = ctx.row.original; - return ; - }, - }, - ]} - data={usersFiltered} - emptyAction={ - - } - emptyText="No users yet" - pagination - sorting + + + + + {createTooltip && {createTooltip}} + + } + > + 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())} + + ))} + + ))} + + + {table.getRowModel().rows.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No users yet. + + + )} + +
+
+ + + + +
+ + ); +}; + +const UserRolesCell = ({ userName }: { userName: string }) => { + const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); + const navigate = useNavigate(); + + if (!featureRolesApi) { + return ; + } + + const roles: string[] = []; + for (const [roleName, members] of rolesApi.roleMembers) { + if ( + members.any((m: { name: string; principalType: string }) => m.name === userName && m.principalType === 'User') + ) { + roles.push(roleName); + } + } + + if (roles.length === 0) { + return None; + } + + return ( +
+ {roles.map((r) => ( + navigate({ to: `/security/roles/${r}/details` })}> + {r} + + ))}
); }; +const UserAclsCell = ({ userName }: { userName: string }) => { + const navigate = useNavigate(); + const { data: aclCount } = useQuery(listACLs, { filter: { principal: `User:${userName}` } } as ListACLsRequest, { + enabled: !!userName, + select: (r) => r.resources.length, + }); + + if (!aclCount) { + return None; + } + + return ( + navigate({ to: `/security/acls/${userName}/details` })}> + {`${aclCount} ACL${aclCount !== 1 ? 's' : ''}`} + + ); +}; + const UserActions = ({ user }: { user: PrincipalEntry }) => { const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); const [isChangePasswordModalOpen, setIsChangePasswordModalOpen] = useState(false); 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..574cc6db32 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 @@ -60,7 +60,7 @@ describe('UserAclsCard', () => { expect(screen.getByText('ACLs (0)')).toBeInTheDocument(); expect(screen.getByText('No ACLs assigned to this user.')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Create ACL' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '+ Add ACL' })).toBeInTheDocument(); }); test('should render empty state when acls is undefined', () => { @@ -68,23 +68,32 @@ describe('UserAclsCard', () => { 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 flat ACL table with correct row count and data', () => { renderWithFileRoutes(); - // Count, rows, action buttons, and headers all rendered together so we assert them once. - expect(screen.getByText('ACLs (2)')).toBeInTheDocument(); + // 3 flat rows: READ + WRITE on test-topic, DESCRIBE on cluster + expect(screen.getByText('ACLs 3 rules')).toBeInTheDocument(); - // Principal and host values per row - expect(screen.getByTestId('acl-principal-User:test-user-*')).toHaveTextContent('User:test-user'); - expect(screen.getByTestId('acl-principal-User:test-user-192.168.1.1')).toHaveTextContent('User:test-user'); - expect(screen.getByTestId('acl-host-*')).toHaveTextContent('*'); - expect(screen.getByTestId('acl-host-192.168.1.1')).toHaveTextContent('192.168.1.1'); + // Resource types + expect(screen.getAllByText('Topic')).toHaveLength(2); + expect(screen.getByText('Cluster')).toBeInTheDocument(); - // Action buttons per row - expect(screen.getByTestId('toggle-acl-User:test-user-*')).toBeInTheDocument(); - expect(screen.getByTestId('edit-acl-User:test-user-*')).toBeInTheDocument(); + // Resource names + expect(screen.getAllByText('test-topic')).toHaveLength(2); + expect(screen.getByText('kafka-cluster')).toBeInTheDocument(); + + // Operations + expect(screen.getByText('Read')).toBeInTheDocument(); + expect(screen.getByText('Write')).toBeInTheDocument(); + expect(screen.getByText('Describe')).toBeInTheDocument(); + + // Permissions + expect(screen.getAllByText('Allow')).toHaveLength(3); + + // Hosts + expect(screen.getAllByText('*')).toHaveLength(2); + expect(screen.getByText('192.168.1.1')).toBeInTheDocument(); }); }); diff --git a/frontend/src/components/pages/security/users/user-acls-card.tsx b/frontend/src/components/pages/security/users/user-acls-card.tsx index 9793720633..df2a3494ea 100644 --- a/frontend/src/components/pages/security/users/user-acls-card.tsx +++ b/frontend/src/components/pages/security/users/user-acls-card.tsx @@ -10,149 +10,133 @@ */ import { useNavigate } from '@tanstack/react-router'; -import { Eye, EyeOff, Pencil } from 'lucide-react'; -import { useState } from 'react'; +import { MoreHorizontalIcon } from 'components/icons'; import { Button } from '../../../redpanda-ui/components/button'; import { Card, CardAction, CardContent, CardHeader, CardTitle } from '../../../redpanda-ui/components/card'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '../../../redpanda-ui/components/dropdown-menu'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../../redpanda-ui/components/table'; -import { type AclDetail, getRuleDataTestId, parsePrincipal } from '../shared/acl-model'; -import { OperationsBadge } from '../shared/operations-badge'; +import { + type AclDetail, + getResourceNameValue, + type OperationType, + OperationTypeNotSet, + type ResourceType, +} from '../shared/acl-model'; -type UserAclsCardProps = { - acls?: AclDetail[]; +type FlatAclRow = { + resourceType: ResourceType; + resourceName: string; + operation: string; + permission: OperationType; + host: string; + principal: string; }; -type AclTableRowProps = { - acl: AclDetail; - isExpanded: boolean; - onToggle: () => void; +const RESOURCE_TYPE_LABELS: Record = { + cluster: 'Cluster', + topic: 'Topic', + consumerGroup: 'Consumer Group', + transactionalId: 'Transactional ID', + subject: 'Subject', + schemaRegistry: 'Schema Registry', }; -const AclTableRow = ({ acl, isExpanded, onToggle }: AclTableRowProps) => { - const rowKey = `${acl.sharedConfig.principal}-${acl.sharedConfig.host}`; - const navigate = useNavigate(); +const flattenAcls = (acls: AclDetail[]): FlatAclRow[] => + acls.flatMap((detail) => + detail.rules.flatMap((rule) => + Object.entries(rule.operations) + .filter(([, perm]) => perm !== OperationTypeNotSet) + .map(([op, perm]) => ({ + resourceType: rule.resourceType, + resourceName: getResourceNameValue(rule), + operation: op.charAt(0) + op.slice(1).toLowerCase(), + permission: perm, + host: detail.sharedConfig.host, + principal: detail.sharedConfig.principal, + })) + ) + ); - return [ - - {acl.sharedConfig.principal} - {acl.sharedConfig.host} - -
- - -
-
-
, - isExpanded && ( - - -
-
ACL Rules ({acl.rules.length})
- {acl.rules.map((rule) => ( -
- -
- ))} -
-
-
- ), - ]; +type UserAclsCardProps = { + acls?: AclDetail[]; + userName?: string; }; -export const UserAclsCard = ({ acls }: UserAclsCardProps) => { +export const UserAclsCard = ({ acls, userName }: UserAclsCardProps) => { const navigate = useNavigate(); - const [expandedRows, setExpandedRows] = useState>(new Set()); + const rows = flattenAcls(acls ?? []); + const count = rows.length; - const toggleRow = (key: string) => { - setExpandedRows((prev) => { - const next = new Set(prev); - if (next.has(key)) { - next.delete(key); - } else { - next.add(key); - } - return next; - }); + const navigateToEdit = () => { + const name = userName ?? (acls?.[0] ? acls[0].sharedConfig.principal.replace(/^User:/, '') : ''); + navigate({ to: `/security/acls/${name}/details` }); }; - if (!acls || acls.length === 0) { - return ( - - - ACLs (0) - - - - - -

No ACLs assigned to this user.

-
-
- ); - } + const navigateToCreate = () => { + navigate({ to: '/security/acls/create', search: { principalType: undefined, principalName: undefined } }); + }; return ( - ACLs ({acls.length}) + {count === 0 ? 'ACLs (0)' : `ACLs ${count} ${count === 1 ? 'rule' : 'rules'}`} + + + - - - - Name - Hosts - Actions - - - - {acls.flatMap((acl) => { - const rowKey = `${acl.sharedConfig.principal}-${acl.sharedConfig.host}`; - const isExpanded = expandedRows.has(rowKey); - - return ( - toggleRow(rowKey)} - /> - ); - })} - -
+ {count === 0 ? ( +

No ACLs assigned to this user.

+ ) : ( + + + + Resource Type + Resource Name + Operation + Permission + Host + + + + + {rows.map((row, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: rows have no stable unique key + + {RESOURCE_TYPE_LABELS[row.resourceType] ?? row.resourceType} + {row.resourceName} + {row.operation} + + + {row.permission === 'allow' ? 'Allow' : 'Deny'} + + + {row.host} + + + + + + + Edit + + + + + ))} + +
+ )}
); diff --git a/frontend/src/components/pages/security/users/user-create-dialog.tsx b/frontend/src/components/pages/security/users/user-create-dialog.tsx new file mode 100644 index 0000000000..95dc373128 --- /dev/null +++ b/frontend/src/components/pages/security/users/user-create-dialog.tsx @@ -0,0 +1,123 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { create } from '@bufbuild/protobuf'; +import { useNavigate } from '@tanstack/react-router'; +import { CreateUserRequest_UserSchema } from 'protogen/redpanda/api/dataplane/v1/user_pb'; +import { useCallback, useState } from 'react'; +import { generatePassword } from 'utils/password'; + +import { CreateUserConfirmationModal, CreateUserModal } from './user-create'; +import { ChangeRolesModal } from './user-edit-modals'; +import { getSASLMechanism, useCreateUserMutation, useListUsersQuery } from '../../../../react-query/api/user'; +import { type SaslMechanism, validatePassword, validateUsername } from '../../../../utils/user'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../../redpanda-ui/components/dialog'; + +type CreateUserDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +export const CreateUserDialog = ({ open, onOpenChange }: CreateUserDialogProps) => { + const [formState, setFormState] = useState({ + username: '', + password: generatePassword(30, false), + mechanism: 'SCRAM-SHA-256' as SaslMechanism, + generateWithSpecialChars: false, + }); + const [step, setStep] = useState<'form' | 'confirmation'>('form'); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isAssignRolesOpen, setIsAssignRolesOpen] = 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 } = 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 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 onCreateAcls = () => { + handleClose(); + navigate({ to: '/security/acls/create', search: { principalType: 'User', principalName: username } }); + }; + + const state = { + username, + setUsername, + password, + setPassword, + mechanism, + setMechanism, + generateWithSpecialChars, + setGenerateWithSpecialChars, + isCreating: isSubmitting, + isValidUsername: validateUsername(username), + isValidPassword: validatePassword(password), + users, + }; + + return ( + <> + + + {step === 'form' && ( + + Create user + + )} + {step === 'form' ? ( + + ) : ( + setIsAssignRolesOpen(true)} + onCreateAcls={onCreateAcls} + password={password} + username={username} + /> + )} + + + {step === 'confirmation' && ( + + )} + + ); +}; diff --git a/frontend/src/components/pages/security/users/user-create.tsx b/frontend/src/components/pages/security/users/user-create.tsx index dcb19e6b4a..e2aac3309b 100644 --- a/frontend/src/components/pages/security/users/user-create.tsx +++ b/frontend/src/components/pages/security/users/user-create.tsx @@ -15,11 +15,11 @@ 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 { useSupportedFeaturesStore } from 'state/supported-features'; 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 { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, @@ -161,16 +161,13 @@ type CreateUserModalProps = { isCreating: boolean; isValidUsername: boolean; isValidPassword: boolean; - 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 userAlreadyExists = state.users.includes(state.username); const hasError = (!state.isValidUsername || userAlreadyExists) && state.username.length > 0; @@ -194,6 +191,8 @@ const CreateUserModal = ({ state, onCreateUser, onCancel }: CreateUserModalProps state.setUsername(e.target.value)} placeholder="Username" @@ -276,14 +275,6 @@ const CreateUserModal = ({ state, onCreateUser, onCancel }: CreateUserModalProps - - {!!featureRolesApi && ( - - Assign roles - - Assign roles to this user. This is optional and can be changed later. - - )}
@@ -309,71 +300,91 @@ type CreateUserConfirmationModalProps = { mechanism: SaslMechanism; closeModal: () => void; onCreateAcls: () => void; + onAssignRoles?: () => void; }; -const CreateUserConfirmationModal = ({ +export const CreateUserConfirmationModal = ({ username, password, mechanism, closeModal, onCreateAcls, -}: CreateUserConfirmationModalProps) => ( - <> -

- User created successfully -

- - } variant="info"> - - You will not be able to view this password again. Make sure that it is copied and saved. - - - -
-
- Username -
-
- {username} - - - - - Copy username - -
+ onAssignRoles, +}: CreateUserConfirmationModalProps) => { + const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); -
- Password -
-
- - - - - - Copy password - + return ( + <> +

+ User created successfully +

+ + } variant="info"> + + You will not be able to view this password again. Make sure that it is copied and saved. + + + +
+
+ Username +
+
+ {username} + + + + + Copy username + +
+ +
+ Password +
+
+ + + + + + Copy password + +
+ +
+ Mechanism +
+
+ {mechanism} +
-
- Mechanism +
+

What's next?

+

+ This user has no permissions yet. Assign roles or create ACLs to grant access to cluster resources. +

+
+ + {featureRolesApi && onAssignRoles && ( + + )} + +
-
- {mechanism} -
-
- -
- - -
- -); + + ); +}; export const StateRoleSelector = ({ roles, setRoles }: { roles: string[]; setRoles: (roles: string[]) => void }) => { const { diff --git a/frontend/src/components/pages/security/users/user-details.tsx b/frontend/src/components/pages/security/users/user-details.tsx index d22d51dff6..4cc637d226 100644 --- a/frontend/src/components/pages/security/users/user-details.tsx +++ b/frontend/src/components/pages/security/users/user-details.tsx @@ -11,11 +11,11 @@ import { Button } from 'components/redpanda-ui/components/button'; import type { UpdateRoleMembershipResponse } from 'protogen/redpanda/api/console/v1alpha1/security_pb'; +import { SASLMechanism } from 'protogen/redpanda/api/dataplane/v1/user_pb'; import { useEffect, useState } from 'react'; import { UserAclsCard } from './user-acls-card'; import { ChangePasswordModal, ChangeRolesModal } from './user-edit-modals'; -import { UserInformationCard } from './user-information-card'; import { UserRolesCard } from './user-roles-card'; import { useGetAclsByPrincipal } from '../../../../react-query/api/acl'; import { useListRolesQuery } from '../../../../react-query/api/security'; @@ -32,13 +32,23 @@ type UserDetailsPageProps = { userName: string; }; +const formatMechanism = (mechanism?: SASLMechanism): string | null => { + if (mechanism === SASLMechanism.SASL_MECHANISM_SCRAM_SHA_256) return 'SCRAM-SHA-256'; + if (mechanism === SASLMechanism.SASL_MECHANISM_SCRAM_SHA_512) return 'SCRAM-SHA-512'; + return null; +}; + const UserDetailsPage = ({ userName }: UserDetailsPageProps) => { const [isChangePasswordModalOpen, setIsChangePasswordModalOpen] = useState(false); const [isChangeRolesModalOpen, setIsChangeRolesModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); const { data: usersData, isLoading: isUsersLoading } = useListUsersQuery(); const users = usersData?.users?.map((u) => u.name) ?? []; + const currentUser = usersData?.users?.find((u) => u.name === userName); + const mechanism = formatMechanism(currentUser?.mechanism); + const { mutateAsync: deleteUserMutation } = useDeleteUserMutation(); useSecurityBreadcrumbs([ @@ -68,69 +78,84 @@ const UserDetailsPage = ({ userName }: UserDetailsPageProps) => { const isServiceAccount = users.includes(userName); + const onConfirmDelete = async () => { + try { + await deleteUserMutation({ name: userName }); + } catch { + return; + } + + const promises: Promise[] = []; + for (const [roleName, members] of rolesApi.roleMembers) { + if (members.any((m) => m.name === userName)) { + promises.push(rolesApi.updateRoleMembership(roleName, [], [{ name: userName, principalType: 'User' }])); + } + } + await Promise.allSettled(promises); + await Promise.allSettled([invalidateUsersCache(), rolesApi.refreshRoleMembers()]); + appGlobal.historyPush('/security/users/'); + }; + return ( -
-

User: {userName}

-
- { - setIsChangePasswordModalOpen(true); - }} - username={userName} - /> - { - setIsChangeRolesModalOpen(true); - } - : undefined - } - userName={userName} - /> -
+
+ {/* Header */} +
+
+

{userName}

+
+ + Principal: User:{userName} + + {mechanism && ( + <> + · + + Mechanism: {mechanism} + + + )} +
+
+ +
+ {Boolean(isServiceAccount) && ( - - Delete user - - } - onConfirm={async () => { - try { - await deleteUserMutation({ name: userName }); - } catch { - return; // Error toast shown by mutation's onError - } - - // Remove user from all its roles (best-effort) - const promises: Promise[] = []; - for (const [roleName, members] of rolesApi.roleMembers) { - if (members.any((m) => m.name === userName)) { - promises.push( - rolesApi.updateRoleMembership(roleName, [], [{ name: userName, principalType: 'User' }]) - ); - } - } - await Promise.allSettled(promises); - await Promise.allSettled([invalidateUsersCache(), rolesApi.refreshRoleMembers()]); - appGlobal.historyPush('/security/users/'); - }} - userName={userName} - /> + )}
- - - - {Boolean(featureRolesApi) && ( - - )}
+ + { + setIsChangeRolesModalOpen(true); + } + : undefined + } + userName={userName} + /> + + + + + + {Boolean(featureRolesApi) && ( + + )}
); }; @@ -157,8 +182,8 @@ const UserPermissionDetailsContent = ({ return (
- - + +
); }; 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..aa2e566b01 100644 --- a/frontend/src/components/pages/security/users/user-roles-card.tsx +++ b/frontend/src/components/pages/security/users/user-roles-card.tsx @@ -9,18 +9,16 @@ * by the Apache License, Version 2.0 */ +import { create } from '@bufbuild/protobuf'; import { useNavigate } from '@tanstack/react-router'; -import { Eye, EyeOff, Pencil } from 'lucide-react'; -import { useState } from 'react'; +import { ExternalLinkIcon, Trash2Icon } from 'lucide-react'; -import { useGetAclsByPrincipal } from '../../../../react-query/api/acl'; +import { UpdateRoleMembershipRequestSchema } from '../../../../protogen/redpanda/api/dataplane/v1/security_pb'; +import { useUpdateRoleMembershipMutation } from '../../../../react-query/api/security'; +import { rolesApi } from '../../../../state/backend-api'; import { Button } from '../../../redpanda-ui/components/button'; import { Card, CardAction, CardContent, CardHeader, CardTitle } from '../../../redpanda-ui/components/card'; -import { Skeleton } from '../../../redpanda-ui/components/skeleton'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../../redpanda-ui/components/table'; -import type { AclDetail } from '../shared/acl-model'; -import { getRuleDataTestId } from '../shared/acl-model'; -import { OperationsBadge } from '../shared/operations-badge'; type Role = { principalType: string; @@ -30,157 +28,84 @@ type Role = { type UserRolesCardProps = { roles: Role[]; onChangeRoles?: () => void; + userName?: string; }; -type RoleTableRowProps = { - role: Role; - isExpanded: boolean; - onToggle: () => void; -}; - -const RoleTableRow = ({ role, isExpanded, onToggle }: RoleTableRowProps) => { +export const UserRolesCard = ({ roles, onChangeRoles, userName }: UserRolesCardProps) => { const navigate = useNavigate(); - const { data: acls, isLoading } = useGetAclsByPrincipal( - `RedpandaRole:${role.principalName}`, - undefined, - undefined, - { - enabled: isExpanded, - } - ); - const rowKey = role.principalName; - - return [ - - {role.principalName} - -
- - -
-
-
, - isLoading && ( - - - - - - - - - ), - !isLoading && isExpanded && acls && acls.length > 0 && ( - - -
-
- ACL Rules ({acls.reduce((sum: number, acl: AclDetail) => sum + acl.rules.length, 0)}) -
- {acls.map((acl: AclDetail) => ( -
-
Host: {acl.sharedConfig.host}
- {acl.rules.map((rule) => ( -
- -
- ))} -
- ))} -
-
-
- ), - ]; -}; + const { mutateAsync: updateRoleMembership } = useUpdateRoleMembershipMutation(); -export const UserRolesCard = ({ roles, onChangeRoles }: UserRolesCardProps) => { - const [expandedRows, setExpandedRows] = useState>(new Set()); - - const toggleRow = (key: string) => { - setExpandedRows((prev) => { - const next = new Set(prev); - if (next.has(key)) { - next.delete(key); - } else { - next.add(key); - } - return next; + const removeFromRole = async (roleName: string) => { + if (!userName) return; + const membership = create(UpdateRoleMembershipRequestSchema, { + roleName, + remove: [{ principal: userName }], }); + await updateRoleMembership(membership); + await Promise.all([rolesApi.refreshRoles(), rolesApi.refreshRoleMembers()]); }; - if (roles.length === 0) { - return ( - - - Roles - - {Boolean(onChangeRoles) && ( - - )} - - - -

No permissions assigned to this user.

-
-
- ); - } + const count = roles.length; + const headerTitle = count > 0 ? `Roles ${count} assigned` : 'Roles'; return ( - Roles + {headerTitle} {Boolean(onChangeRoles) && ( - )} - - - - Name - Actions - - - - {roles.flatMap((r) => { - const rowKey = r.principalName; - const isExpanded = expandedRows.has(rowKey); - - return ( - toggleRow(rowKey)} - role={r} - /> - ); - })} - -
+ {count === 0 ? ( +

No permissions assigned to this user.

+ ) : ( + + + + Name + Actions + + + + {roles.map((r) => ( + + {r.principalName} + +
+ {Boolean(userName) && ( + + )} + +
+
+
+ ))} +
+
+ )}
); diff --git a/frontend/src/components/redpanda-ui/components/multi-select.tsx b/frontend/src/components/redpanda-ui/components/multi-select.tsx index 1affbb1d7b..56829e68a0 100644 --- a/frontend/src/components/redpanda-ui/components/multi-select.tsx +++ b/frontend/src/components/redpanda-ui/components/multi-select.tsx @@ -341,7 +341,7 @@ const MultiSelectContent = React.forwardRef< + {tabs.map((tab) => ( @@ -161,8 +161,8 @@ function SecurityLayout() { ))} - - + + ); } diff --git a/frontend/tests/shared/global-setup.mjs b/frontend/tests/shared/global-setup.mjs index 9b9c3ff065..76936cf7d7 100644 --- a/frontend/tests/shared/global-setup.mjs +++ b/frontend/tests/shared/global-setup.mjs @@ -439,11 +439,20 @@ export async function buildBackendImage(isEnterprise) { const modulePath = match[1]; if (modulePath === '.' || modulePath === 'use' || modulePath === '(' || modulePath === ')') continue; - // Resolve the actual path relative to backendDir - const absModulePath = resolve(backendDir, modulePath); + // Resolve the actual path relative to backendDir, or fall back to the + // repo root (parent of backendDir). go.work paths like ../console/backend + // are authored relative to the repo root, not the backend/ subdir. + let absModulePath = resolve(backendDir, modulePath); if (!existsSync(absModulePath)) { - console.warn(` Workspace module not found: ${absModulePath}, skipping`); - continue; + const parentDir = resolve(backendDir, '..'); + const fromParent = resolve(parentDir, modulePath); + if (existsSync(fromParent)) { + console.log(` Resolving ${modulePath} from repo root: ${fromParent}`); + absModulePath = fromParent; + } else { + console.warn(` Workspace module not found: ${absModulePath} (also tried ${fromParent}), skipping`); + continue; + } } // Create a sanitized directory name diff --git a/frontend/tests/test-variant-console-enterprise/users.spec.ts b/frontend/tests/test-variant-console-enterprise/users.spec.ts index bb09b51d9d..b6b18eaed0 100644 --- a/frontend/tests/test-variant-console-enterprise/users.spec.ts +++ b/frontend/tests/test-variant-console-enterprise/users.spec.ts @@ -29,9 +29,9 @@ test.describe('Users', () => { await page.goto('/security/users/', { waitUntil: 'domcontentloaded', }); - await page.getByPlaceholder('Filter by name').fill(`user-${r}-regexp-[1,2]`); + await page.getByPlaceholder('Filter by name (regexp)...').fill(`user-${r}-regexp-[1,2]`); // Wait for nuqs to push the filter into the URL (TanStack Router navigate is async) - await page.waitForURL(/[?&]q=/); + await page.waitForURL(/[?&]name=/); await expect( page.getByTestId('data-table-cell').locator(`a[href='/security/users/${userName1}/details']`) diff --git a/frontend/tests/test-variant-console/acls/user-management.spec.ts b/frontend/tests/test-variant-console/acls/user-management.spec.ts index 924b47c5b7..772f097e00 100644 --- a/frontend/tests/test-variant-console/acls/user-management.spec.ts +++ b/frontend/tests/test-variant-console/acls/user-management.spec.ts @@ -19,7 +19,7 @@ test.describe('ACL User Management', () => { test('should create a new user with special characters in password', async ({ page }) => { await test.step('1. Click Create user button to open user creation dialog', async () => { await page.getByTestId('create-user-button').click(); - await expect(page).toHaveURL('/security/users/create'); + await expect(page.getByTestId('create-user-name')).toBeVisible(); }); const timestamp = Date.now(); @@ -45,8 +45,9 @@ test.describe('ACL User Management', () => { await expect(page.getByText(username)).toBeVisible(); }); - await test.step('6. Return to users list', async () => { + await test.step('6. Close dialog', async () => { await page.getByTestId('done-button').click(); + await expect(page.getByTestId('create-user-name')).not.toBeVisible(); await expect(page).toHaveURL('/security/users'); }); @@ -61,7 +62,7 @@ test.describe('ACL User Management', () => { await test.step('1. Create a new user', async () => { await page.getByTestId('create-user-button').click(); - await expect(page).toHaveURL('/security/users/create'); + await expect(page.getByTestId('create-user-name')).toBeVisible(); await page.getByTestId('create-user-name').fill(username); await page.getByTestId('create-user-submit').click(); await expect(page.getByTestId('user-created-successfully')).toBeVisible(); @@ -91,8 +92,9 @@ test.describe('ACL User Management', () => { }); test('should toggle special characters checkbox and regenerate password', async ({ page }) => { - await test.step('1. Navigate to create user page', async () => { + await test.step('1. Open create user dialog', async () => { await page.getByTestId('create-user-button').click(); + await expect(page.getByTestId('create-user-name')).toBeVisible(); }); const passwordInput = page.getByTestId('create-user-password'); @@ -123,8 +125,9 @@ test.describe('ACL User Management', () => { expect(finalPassword).not.toBe(passwordAfterToggle); }); - await test.step('Cancel and return to list', async () => { + await test.step('Cancel and close dialog', async () => { await page.getByTestId('create-user-cancel').click(); + await expect(page.getByTestId('create-user-name')).not.toBeVisible(); await expect(page).toHaveURL('/security/users'); }); }); @@ -147,7 +150,7 @@ test.describe('ACL User Management', () => { await expect(table).toBeVisible(); }); - const filterInput = page.getByTestId('search-field-input').getByRole('textbox'); + const filterInput = page.getByPlaceholder('Filter by name (regexp)...'); await test.step('3. Get filter input', async () => { await expect(filterInput).toBeVisible(); @@ -157,8 +160,8 @@ test.describe('ACL User Management', () => { await filterInput.fill('test'); }); - await test.step('5. Verify URL contains query parameter q=test', async () => { - await expect(page).toHaveURL('/security/users/?q=test'); + await test.step('5. Verify URL contains query parameter name=test', async () => { + await expect(page).toHaveURL('/security/users/?name=test'); }); await test.step('6. Verify filtered results show only users with test in name', async () => { @@ -192,14 +195,14 @@ test.describe('ACL User Management', () => { await expect(page).toHaveURL('/security/users'); }); - const filterInput = page.getByTestId('search-field-input').getByRole('textbox'); + const filterInput = page.getByPlaceholder('Filter by name (regexp)...'); await test.step('2. Filter by e2e', async () => { await filterInput.fill('e2e'); }); - await test.step('3. Verify URL contains query parameter q=e2e', async () => { - await expect(page).toHaveURL('/security/users/?q=e2e'); + await test.step('3. Verify URL contains query parameter name=e2e', async () => { + await expect(page).toHaveURL('/security/users/?name=e2e'); }); await test.step('4. Verify only e2euser is visible', async () => { @@ -210,8 +213,8 @@ test.describe('ACL User Management', () => { await filterInput.fill('test'); }); - await test.step('6. Verify URL contains query parameter q=test', async () => { - await expect(page).toHaveURL('/security/users/?q=test'); + await test.step('6. Verify URL contains query parameter name=test', async () => { + await expect(page).toHaveURL('/security/users/?name=test'); }); await test.step('7. Verify test-user is visible', async () => { @@ -310,8 +313,9 @@ test.describe('ACL User Management', () => { }); test('should validate username format requirements', async ({ page }) => { - await test.step('1. Navigate to create user page', async () => { + await test.step('1. Open create user dialog', async () => { await page.getByTestId('create-user-button').click(); + await expect(page.getByTestId('create-user-name')).toBeVisible(); }); await test.step('2. Verify username input has help text', async () => { @@ -334,8 +338,9 @@ test.describe('ACL User Management', () => { }); test('should display password requirements', async ({ page }) => { - await test.step('1. Navigate to create user page', async () => { + await test.step('1. Open create user dialog', async () => { await page.getByTestId('create-user-button').click(); + await expect(page.getByTestId('create-user-name')).toBeVisible(); }); await test.step('2. Verify password requirements are displayed', async () => { diff --git a/frontend/tests/test-variant-console/utils/security-page.ts b/frontend/tests/test-variant-console/utils/security-page.ts index 5469fd8b62..dfacb52295 100644 --- a/frontend/tests/test-variant-console/utils/security-page.ts +++ b/frontend/tests/test-variant-console/utils/security-page.ts @@ -19,15 +19,12 @@ export class SecurityPage { await this.page.goto(`/security/users/${username}/details`); } - async goToCreateUser() { - await this.page.goto('/security/users/create'); - } - /** * User list operations */ async clickCreateUserButton() { await this.page.getByTestId('create-user-button').click(); + await this.page.getByTestId('create-user-name').waitFor({ state: 'visible' }); } /** @@ -65,7 +62,6 @@ export class SecurityPage { return await test.step('Create user', async () => { await this.goToUsersList(); await this.clickCreateUserButton(); - await this.page.waitForURL('/security/users/create'); await this.fillUsername(username); await this.submitUserCreation(); await this.page.getByTestId('user-created-successfully').waitFor({ state: 'visible' }); From 0853f6acb813be1c8796567e8c112142ff69b770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Tue, 28 Apr 2026 15:50:24 +0200 Subject: [PATCH 2/7] Security page rewrite --- frontend/src/components/layout/header.tsx | 88 +-- .../license/feature-license-notification.tsx | 12 +- .../observability/observability-page.tsx | 4 +- .../components/pages/overview/overview.tsx | 2 +- frontend/src/components/pages/page.ts | 18 +- .../rp-connect/onboarding/add-user-step.tsx | 7 +- .../components/pages/schemas/schema-list.tsx | 4 +- .../security/acls/acl-create-page.test.tsx | 110 ---- .../pages/security/acls/acl-create-page.tsx | 60 -- .../pages/security/acls/acl-detail-page.tsx | 36 +- .../pages/security/acls/acl-update-page.tsx | 141 ----- .../hooks/use-principal-permissions.ts | 142 +++++ .../hooks/use-security-breadcrumbs.test.tsx | 92 --- .../hooks/use-security-breadcrumbs.ts | 38 -- .../security/roles/role-create-dialog.tsx | 93 +++ .../pages/security/roles/role-create-page.tsx | 10 +- .../pages/security/roles/role-detail-page.tsx | 274 +++++---- .../pages/security/roles/role-update-page.tsx | 14 +- .../pages/security/shared/acls-card.tsx | 281 +++++++++ .../security/shared/description-with-help.tsx | 49 ++ .../security/shared/security-tabs-nav.tsx | 113 ++++ .../pages/security/tabs/acls-tab.tsx | 267 --------- .../security/tabs/permissions-list-tab.tsx | 533 +++++++++--------- .../pages/security/tabs/roles-tab.tsx | 156 ++--- .../pages/security/tabs/users-tab.tsx | 27 +- .../pages/security/users/add-acl-dialog.tsx | 348 ++++++++++++ .../security/users/user-acls-card.test.tsx | 9 +- .../pages/security/users/user-acls-card.tsx | 131 +---- .../security/users/user-create-dialog.tsx | 12 +- .../pages/security/users/user-create.tsx | 154 +++-- .../pages/security/users/user-details.tsx | 88 +-- .../pages/security/users/user-roles-card.tsx | 76 ++- .../components/pages/topics/topic-list.tsx | 4 +- .../redpanda-ui/components/list-layout.tsx | 2 +- frontend/src/react-query/api/acl.tsx | 18 +- frontend/src/routeTree.gen.ts | 64 --- frontend/src/routes/__root.tsx | 6 +- frontend/src/routes/security.tsx | 108 +--- .../routes/security/acls/$aclName/update.tsx | 28 - frontend/src/routes/security/acls/create.tsx | 29 - frontend/src/routes/security/acls/index.tsx | 21 - frontend/src/routes/security/index.tsx | 6 +- frontend/src/state/ui-state.ts | 39 ++ 43 files changed, 1872 insertions(+), 1842 deletions(-) delete mode 100644 frontend/src/components/pages/security/acls/acl-create-page.test.tsx delete mode 100644 frontend/src/components/pages/security/acls/acl-create-page.tsx delete mode 100644 frontend/src/components/pages/security/acls/acl-update-page.tsx create mode 100644 frontend/src/components/pages/security/hooks/use-principal-permissions.ts delete mode 100644 frontend/src/components/pages/security/hooks/use-security-breadcrumbs.test.tsx delete mode 100644 frontend/src/components/pages/security/hooks/use-security-breadcrumbs.ts create mode 100644 frontend/src/components/pages/security/roles/role-create-dialog.tsx create mode 100644 frontend/src/components/pages/security/shared/acls-card.tsx create mode 100644 frontend/src/components/pages/security/shared/description-with-help.tsx create mode 100644 frontend/src/components/pages/security/shared/security-tabs-nav.tsx delete mode 100644 frontend/src/components/pages/security/tabs/acls-tab.tsx create mode 100644 frontend/src/components/pages/security/users/add-acl-dialog.tsx delete mode 100644 frontend/src/routes/security/acls/$aclName/update.tsx delete mode 100644 frontend/src/routes/security/acls/create.tsx delete mode 100644 frontend/src/routes/security/acls/index.tsx diff --git a/frontend/src/components/layout/header.tsx b/frontend/src/components/layout/header.tsx index 8d4a7bf354..e35e70a742 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 1bc647941d..d49f35ec8d 100644 --- a/frontend/src/components/license/feature-license-notification.tsx +++ b/frontend/src/components/license/feature-license-notification.tsx @@ -27,6 +27,7 @@ import { } from '../../protogen/redpanda/api/console/v1alpha1/license_pb'; import { api } from '../../state/backend-api'; import { Alert, AlertDescription } from '../redpanda-ui/components/alert'; +import { Badge } from '../redpanda-ui/components/badge'; // biome-ignore lint/nursery/useMaxParams: Refactoring to options object would require updating all call sites const getLicenseAlertContentForFeature = ( @@ -172,7 +173,10 @@ const getLicenseAlertContentForFeature = ( return null; }; -export const FeatureLicenseNotification: FC<{ featureName: 'reassignPartitions' | 'rbac' }> = ({ featureName }) => { +export const FeatureLicenseNotification: FC<{ + featureName: 'reassignPartitions' | 'rbac'; + as?: 'alert' | 'badge'; +}> = ({ featureName, as: renderAs = 'alert' }) => { const [registerModalOpen, setIsRegisterModalOpen] = useState(false); useEffect(() => { @@ -222,9 +226,13 @@ export const FeatureLicenseNotification: FC<{ featureName: 'reassignPartitions' const { message, variant } = alertContent; + if (renderAs === 'badge') { + return {message}; + } + return ( <> - + {message} diff --git a/frontend/src/components/pages/observability/observability-page.tsx b/frontend/src/components/pages/observability/observability-page.tsx index 9fe8744124..1c11f8c429 100644 --- a/frontend/src/components/pages/observability/observability-page.tsx +++ b/frontend/src/components/pages/observability/observability-page.tsx @@ -12,7 +12,7 @@ import { type FC, lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react'; import { useListQueries } from 'react-query/api/observability'; import { appGlobal } from 'state/app-global'; -import { uiState } from 'state/ui-state'; +import { setPageHeader } from 'state/ui-state'; const MetricChart = lazy(() => import('./metric-chart').then((m) => ({ default: m.MetricChart }))); @@ -46,7 +46,7 @@ const ObservabilityPage: FC = () => { }, [refetch]); useEffect(() => { - uiState.pageBreadcrumbs = [{ title: 'Metrics', linkTo: '/observability' }]; + setPageHeader('Metrics', [{ title: 'Metrics', linkTo: '/observability' }]); appGlobal.onRefresh = () => refreshData(); }, [refreshData]); diff --git a/frontend/src/components/pages/overview/overview.tsx b/frontend/src/components/pages/overview/overview.tsx index 00a53c050f..694be281c7 100644 --- a/frontend/src/components/pages/overview/overview.tsx +++ b/frontend/src/components/pages/overview/overview.tsx @@ -384,7 +384,7 @@ function ClusterDetails() {
+ {aclCount} , ], diff --git a/frontend/src/components/pages/page.ts b/frontend/src/components/pages/page.ts index c7992edc66..2e5d460f05 100644 --- a/frontend/src/components/pages/page.ts +++ b/frontend/src/components/pages/page.ts @@ -19,7 +19,7 @@ import { useRpcnSecretManagerStore, useTransformsStore, } from '../../state/backend-api'; -import { type BreadcrumbOptions, uiState } from '../../state/ui-state'; +import { type BreadcrumbEntry, type BreadcrumbOptions, setPageHeader } from '../../state/ui-state'; // // Page Types @@ -30,11 +30,17 @@ export type NoRouteParams = {}; export type PageProps = TRouteParams & { matchedPath: string }; export class PageInitHelper { + private pageTitle = ''; + private pageBreadcrumbs: BreadcrumbEntry[] = []; + set title(title: string) { - uiState.pageTitle = title; + this.pageTitle = title; } addBreadcrumb(title: string, to: string, heading?: string, options?: BreadcrumbOptions) { - uiState.pageBreadcrumbs.push({ title, linkTo: to, heading, options }); + this.pageBreadcrumbs.push({ title, linkTo: to, heading, options }); + } + _flush() { + setPageHeader(this.pageTitle, this.pageBreadcrumbs); } } export abstract class PageComponent extends React.Component> { @@ -43,9 +49,9 @@ export abstract class PageComponent extends React. constructor(props: Readonly>) { super(props); - uiState.pageBreadcrumbs = []; - - this.initPage(new PageInitHelper()); + const helper = new PageInitHelper(); + this.initPage(helper); + helper._flush(); } componentDidMount() { diff --git a/frontend/src/components/pages/rp-connect/onboarding/add-user-step.tsx b/frontend/src/components/pages/rp-connect/onboarding/add-user-step.tsx index e0045b3694..809cf4509f 100644 --- a/frontend/src/components/pages/rp-connect/onboarding/add-user-step.tsx +++ b/frontend/src/components/pages/rp-connect/onboarding/add-user-step.tsx @@ -698,8 +698,11 @@ export const AddUserStep = forwardRef You will need to configure{' '} - ACLs for custom - user permissions if you want the user to be able to read from the topic. + + Permissions + {' '} + for custom user permissions if you want the user to be able to read from the + topic. diff --git a/frontend/src/components/pages/schemas/schema-list.tsx b/frontend/src/components/pages/schemas/schema-list.tsx index d0b6d12a5b..4a2735b986 100644 --- a/frontend/src/components/pages/schemas/schema-list.tsx +++ b/frontend/src/components/pages/schemas/schema-list.tsx @@ -67,7 +67,7 @@ import { api } from '../../../state/backend-api'; import type { SchemaRegistrySubject } from '../../../state/rest-interfaces'; import { useSupportedFeaturesStore } from '../../../state/supported-features'; import { uiSettings } from '../../../state/ui'; -import { uiState } from '../../../state/ui-state'; +import { setPageHeader } from '../../../state/ui-state'; import { encodeURIComponentPercents } from '../../../utils/utils'; import PageContent from '../../misc/page-content'; import Section from '../../misc/section'; @@ -180,7 +180,7 @@ const SchemaList: FC = () => { }, [derivedContexts, selectedContext, schemaRegistryContextsSupported, schemaMode, schemaCompatibility]); useEffect(() => { - uiState.pageBreadcrumbs = [{ title: 'Schema Registry', linkTo: '/schema-registry' }]; + setPageHeader('Schema Registry', [{ title: 'Schema Registry', linkTo: '/schema-registry' }]); appGlobal.onRefresh = () => refreshData(); }, [refreshData]); diff --git a/frontend/src/components/pages/security/acls/acl-create-page.test.tsx b/frontend/src/components/pages/security/acls/acl-create-page.test.tsx deleted file mode 100644 index 23a4b0b2f9..0000000000 --- a/frontend/src/components/pages/security/acls/acl-create-page.test.tsx +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Copyright 2025 Redpanda Data, Inc. - * - * Use of this software is governed by the Business Source License - * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md - * - * As of the Change Date specified in that file, in accordance with - * the Business Source License, use of this software will be governed - * by the Apache License, Version 2.0 - */ - -import { renderWithFileRoutes, screen, waitFor } from 'test-utils'; - -// Mock getRouteApi to return controlled search params -let mockSearch: Record = {}; - -vi.mock('@tanstack/react-router', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getRouteApi: () => ({ - useSearch: () => mockSearch, - }), - useNavigate: () => vi.fn(), - }; -}); - -vi.mock('state/backend-api', () => ({ - useApiStoreHook: (selector: (s: { enterpriseFeaturesUsed: { name: string; enabled: boolean }[] }) => T) => - selector({ enterpriseFeaturesUsed: [] }), -})); - -vi.mock('../../../../state/supported-features', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useSupportedFeaturesStore: (selector: (s: Record) => T) => - selector({ createUser: true, deleteUser: true, rolesApi: true, schemaRegistryACLApi: false }), - }; -}); - -// Polyfills -global.ResizeObserver = class ResizeObserver { - observe() {} - unobserve() {} - disconnect() {} -}; -Element.prototype.scrollIntoView = vi.fn(); - -// Import after mocks -import AclCreatePage from './acl-create-page'; - -describe('AclCreatePage — search param → form population', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockSearch = {}; - }); - - test('principal input is pre-populated from principalType=User&principalName=my-user', async () => { - mockSearch = { principalType: 'User', principalName: 'my-user' }; - - renderWithFileRoutes(); - - const principalInput = await screen.findByTestId('shared-principal-input'); - await waitFor(() => { - expect(principalInput).toHaveValue('my-user'); - }); - - const typeSelect = screen.getByTestId('shared-principal-type-select'); - expect(typeSelect).toHaveTextContent('User'); - }); - - test('principal input is empty when no search params', async () => { - mockSearch = {}; - - renderWithFileRoutes(); - - const principalInput = await screen.findByTestId('shared-principal-input'); - await waitFor(() => { - expect(principalInput).toHaveValue(''); - }); - }); - - test('principal input is editable (not locked) with search params', async () => { - mockSearch = { principalType: 'User', principalName: 'editable-user' }; - - renderWithFileRoutes(); - - const principalInput = await screen.findByTestId('shared-principal-input'); - expect(principalInput).not.toBeDisabled(); - }); - - test('principal input updates when search params arrive after initial render', async () => { - // Simulate: first render has no params (route loading), then params arrive - mockSearch = {}; - - const { rerender } = renderWithFileRoutes(); - - const principalInput = await screen.findByTestId('shared-principal-input'); - expect(principalInput).toHaveValue(''); - - // Params arrive (route finishes loading) - mockSearch = { principalType: 'User', principalName: 'late-user' }; - rerender(); - - await waitFor(() => { - expect(principalInput).toHaveValue('late-user'); - }); - }); -}); diff --git a/frontend/src/components/pages/security/acls/acl-create-page.tsx b/frontend/src/components/pages/security/acls/acl-create-page.tsx deleted file mode 100644 index a09db59092..0000000000 --- a/frontend/src/components/pages/security/acls/acl-create-page.tsx +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Copyright 2022 Redpanda Data, Inc. - * - * Use of this software is governed by the Business Source License - * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md - * - * As of the Change Date specified in that file, in accordance with - * the Business Source License, use of this software will be governed - * by the Apache License, Version 2.0 - */ - -import { getRouteApi, useNavigate } from '@tanstack/react-router'; - -const routeApi = getRouteApi('/security/acls/create'); - -import CreateACL from './create-acl'; -import { useCreateAcls } from '../../../../react-query/api/acl'; -import { useSecurityBreadcrumbs } from '../hooks/use-security-breadcrumbs'; -import { convertRulesToCreateACLRequests, handleResponses, type Rule } from '../shared/acl-model'; -import { parsePrincipalFromParam, resolveAclSearchParams } from '../shared/principal-utils'; - -const AclCreatePage = () => { - const navigate = useNavigate({ from: '/security/acls/create' }); - const search = routeApi.useSearch(); - - const { sharedConfig, principalType } = resolveAclSearchParams(search); - - useSecurityBreadcrumbs([{ title: 'ACLs', linkTo: '/security/acls' }]); - - const { createAcls } = useCreateAcls(); - - const createAclMutation = async (principal: string, host: string, rules: Rule[]) => { - const result = convertRulesToCreateACLRequests(rules, principal, host); - const applyResult = await createAcls(result); - handleResponses(applyResult.errors, applyResult.created); - - const { principalType: parsedType, principalName } = parsePrincipalFromParam(principal); - const aclName = parsedType === 'User' ? principalName : principal; - navigate({ - to: '/security/acls/$aclName/details', - params: { aclName }, - search: { host: undefined }, - }); - }; - - return ( -
-

Create ACL

- navigate({ to: '/security/acls' })} - onSubmit={createAclMutation} - principalType={principalType} - sharedConfig={sharedConfig} - /> -
- ); -}; - -export default AclCreatePage; diff --git a/frontend/src/components/pages/security/acls/acl-detail-page.tsx b/frontend/src/components/pages/security/acls/acl-detail-page.tsx index 0c5c7c6978..9df2ca44ba 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,21 @@ * 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 { Button } from '../../../redpanda-ui/components/button'; +import { setPageHeader } from '../../../../state/ui-state'; 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 +32,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...
; @@ -54,22 +55,7 @@ const AclDetailPage = () => { return (

ACL: {principalName}

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

Update ACL: {principalName}

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

Security ACL rules

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

Role: {roleName}

-
- Configuration details - {(!!host || data?.length === 1) && ( -
- -
- )} -
- -
-
{renderACLInformation}
- -
+
+ + + {/* Principals */} + + + } + > +

Principals

+
+ + {membersLoading ? ( +
Loading members...
+ ) : allMembers.length === 0 ? ( +

No principals assigned to this role.

+ ) : ( +
+ {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-update-page.tsx b/frontend/src/components/pages/security/roles/role-update-page.tsx index 93a4b923f1..cd139a2797 100644 --- a/frontend/src/components/pages/security/roles/role-update-page.tsx +++ b/frontend/src/components/pages/security/roles/role-update-page.tsx @@ -13,13 +13,14 @@ import { getRouteApi, useNavigate } from '@tanstack/react-router'; const routeApi = getRouteApi('/security/roles/$roleName/update'); +import { useLayoutEffect } from 'react'; import { toast } from 'sonner'; import { useGetAclsByPrincipal, useUpdateAclMutation } from '../../../../react-query/api/acl'; +import { setPageHeader } from '../../../../state/ui-state'; import CreateACL from '../acls/create-acl'; import { HostSelector } from '../acls/host-selector'; import { LockedPrincipalField } from '../acls/locked-principal-field'; -import { useSecurityBreadcrumbs } from '../hooks/use-security-breadcrumbs'; import { getOperationsForResourceType, handleResponses, @@ -38,10 +39,13 @@ const RoleUpdatePage = () => { const search = routeApi.useSearch(); const host = search.host ?? undefined; - useSecurityBreadcrumbs([ - { title: 'Roles', linkTo: '/security/roles' }, - { title: roleName, linkTo: `/security/roles/${roleName}/details` }, - ]); + useLayoutEffect(() => { + setPageHeader(roleName, [ + { title: 'Security', linkTo: '/security/users' }, + { title: 'Roles', linkTo: '/security/roles' }, + { title: roleName, linkTo: `/security/roles/${roleName}/details` }, + ]); + }, [roleName]); const { applyUpdates } = useUpdateAclMutation(); diff --git a/frontend/src/components/pages/security/shared/acls-card.tsx b/frontend/src/components/pages/security/shared/acls-card.tsx new file mode 100644 index 0000000000..5b5509044f --- /dev/null +++ b/frontend/src/components/pages/security/shared/acls-card.tsx @@ -0,0 +1,281 @@ +/** + * 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 { + ACL_Operation, + ACL_PermissionType, + ACL_ResourcePatternType, + ACL_ResourceType, + CreateACLRequestSchema, + DeleteACLsRequestSchema, +} from 'protogen/redpanda/api/dataplane/v1/acl_pb'; +import { useState } from 'react'; + +import { + type AclDetail, + getGRPCOperationType, + getGRPCPermissionType, + getGRPCResourcePatternType, + getGRPCResourceType, + getResourceNameValue, + OperationTypeNotSet, +} from './acl-model'; +import { useCreateACLMutation, useDeleteAclMutation } from '../../../../react-query/api/acl'; +import { Button } from '../../../redpanda-ui/components/button'; +import { Checkbox } from '../../../redpanda-ui/components/checkbox'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '../../../redpanda-ui/components/dialog'; +import { ListLayout, ListLayoutContent, ListLayoutFilters } from '../../../redpanda-ui/components/list-layout'; +import { AddAclDialog } from '../users/add-acl-dialog'; + +const RESOURCE_TYPE_LABELS: Record = { + cluster: 'Cluster', + topic: 'Topic', + consumerGroup: 'Consumer Group', + transactionalId: 'Transactional ID', + subject: 'Subject', + schemaRegistry: 'Schema Registry', +}; + +const GRANT_ALL_RESOURCES: { type: ACL_ResourceType; label: string; name: string }[] = [ + { type: ACL_ResourceType.TOPIC, label: 'Topic', name: '*' }, + { type: ACL_ResourceType.GROUP, label: 'Consumer Group', name: '*' }, + { type: ACL_ResourceType.CLUSTER, label: 'Cluster', name: 'kafka-cluster' }, + { type: ACL_ResourceType.TRANSACTIONAL_ID, label: 'Transactional ID', name: '*' }, + { type: ACL_ResourceType.SUBJECT, label: 'Subject', name: '*' }, + { type: ACL_ResourceType.REGISTRY, label: 'Schema Registry', name: '*' }, +]; + +type AclRow = { + id: string; + resourceType: string; + resourceName: string; + operation: string; + permissionType: 'Allow' | 'Deny'; + host: string; + rawResourceType: ACL_ResourceType; + rawPatternType: ACL_ResourcePatternType; + rawOperation: ACL_Operation; + rawPermissionType: ACL_PermissionType; + rawPrincipal: string; +}; + +type AclsCardProps = { + acls?: AclDetail[]; + principal?: string; +}; + +export const AclsCard = ({ acls, principal }: AclsCardProps) => { + const [dialogOpen, setDialogOpen] = useState(false); + const [grantAllOpen, setGrantAllOpen] = useState(false); + const [selected, setSelected] = useState>(new Set()); + const { mutateAsync: deleteAcl, isPending: isDeleting } = useDeleteAclMutation(); + const { mutateAsync: createACL, isPending: isGranting } = useCreateACLMutation(); + const list = acls ?? []; + + let rowCounter = 0; + const rows: AclRow[] = list.flatMap((detail) => + detail.rules.flatMap((rule) => + Object.entries(rule.operations) + .filter(([, perm]) => perm !== OperationTypeNotSet) + .map(([op, perm]) => ({ + id: String(rowCounter++), + resourceType: RESOURCE_TYPE_LABELS[rule.resourceType] ?? rule.resourceType, + resourceName: getResourceNameValue(rule), + operation: op.charAt(0) + op.slice(1).toLowerCase(), + permissionType: (perm === 'allow' ? 'Allow' : 'Deny') as 'Allow' | 'Deny', + host: detail.sharedConfig.host, + rawResourceType: getGRPCResourceType(rule.resourceType), + rawPatternType: + rule.selectorType === 'any' + ? ACL_ResourcePatternType.LITERAL + : getGRPCResourcePatternType(rule.selectorType), + rawOperation: getGRPCOperationType(op), + rawPermissionType: getGRPCPermissionType(perm), + rawPrincipal: detail.sharedConfig.principal, + })) + ) + ); + + const allSelected = rows.length > 0 && selected.size === rows.length; + const someSelected = selected.size > 0; + + const toggleAll = () => { + if (someSelected) { + setSelected(new Set()); + } else { + setSelected(new Set(rows.map((r) => r.id))); + } + }; + + const toggleRow = (id: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + const deleteSelected = async () => { + await Promise.all( + rows + .filter((r) => selected.has(r.id)) + .map((r) => + deleteAcl( + create(DeleteACLsRequestSchema, { + filter: { + principal: r.rawPrincipal, + resourceType: r.rawResourceType, + resourceName: r.resourceName, + host: r.host, + operation: r.rawOperation, + permissionType: r.rawPermissionType, + resourcePatternType: r.rawPatternType, + }, + }) + ) + ) + ); + setSelected(new Set()); + }; + + const confirmGrantAllPermissions = async () => { + if (!principal) return; + await Promise.all( + GRANT_ALL_RESOURCES.map((r) => + createACL( + create(CreateACLRequestSchema, { + resourceType: r.type, + resourceName: r.name, + resourcePatternType: ACL_ResourcePatternType.LITERAL, + principal, + host: '*', + operation: ACL_Operation.ALL, + permissionType: ACL_PermissionType.ALLOW, + }) + ) + ) + ); + setGrantAllOpen(false); + }; + + return ( + <> + + + {someSelected && ( + + )} + {principal && ( + + )} + +
+ } + > +

ACLs

+ + + {rows.length === 0 ? ( +

No ACLs assigned.

+ ) : ( +
+
+ + Type + Resource + Operation + Permission + Host +
+ {rows.map((row) => ( +
+ toggleRow(row.id)} /> + {row.resourceType} + {row.resourceName} + {row.operation} + + {row.permissionType} + + {row.host} +
+ ))} +
+ )} +
+ + + {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/description-with-help.tsx b/frontend/src/components/pages/security/shared/description-with-help.tsx new file mode 100644 index 0000000000..45301f73b9 --- /dev/null +++ b/frontend/src/components/pages/security/shared/description-with-help.tsx @@ -0,0 +1,49 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { InfoIcon } from 'lucide-react'; +import { useState } from 'react'; + +import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '../../../redpanda-ui/components/drawer'; + +type Props = { + short: string; + title: string; + children: React.ReactNode; +}; + +export function DescriptionWithHelp({ short, title, children }: Props) { + const [open, setOpen] = useState(false); + + return ( + <> + + {short} + + + + + + {title} + +
{children}
+
+
+ + ); +} diff --git a/frontend/src/components/pages/security/shared/security-tabs-nav.tsx b/frontend/src/components/pages/security/shared/security-tabs-nav.tsx new file mode 100644 index 0000000000..715916a33f --- /dev/null +++ b/frontend/src/components/pages/security/shared/security-tabs-nav.tsx @@ -0,0 +1,113 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { useLocation, useNavigate } from '@tanstack/react-router'; +import { ListLayoutNavigation } from 'components/redpanda-ui/components/list-layout'; +import { isServerless } from 'config'; + +import { useApiStoreHook } from '../../../../state/backend-api'; +import { useSupportedFeaturesStore } from '../../../../state/supported-features'; +import { Tabs, TabsList, TabsTrigger } from '../../../redpanda-ui/components/tabs'; + +type TabConfig = { + key: string; + label: string; + path: string; + disabled: boolean; +}; + +function buildTabs( + isAdminApiConfigured: boolean, + featureCreateUser: boolean, + featureRolesApi: boolean, + userData: { canManageUsers?: boolean; canListAcls?: boolean; canViewPermissionsList?: boolean } | null | undefined +): TabConfig[] { + const result: TabConfig[] = [ + { + key: 'users', + label: 'Users', + path: '/security/users', + disabled: + !(isAdminApiConfigured && featureCreateUser) || + (userData?.canManageUsers !== undefined && userData?.canManageUsers === false), + }, + ]; + + if (!isServerless()) { + result.push({ + key: 'roles', + label: 'Roles', + path: '/security/roles', + disabled: !featureRolesApi || userData?.canManageUsers === false, + }); + } + + result.push({ + key: 'permissions-list', + label: 'Permissions', + path: '/security/permissions-list', + disabled: userData?.canViewPermissionsList === false, + }); + + return result; +} + +function deriveActiveTab(pathname: string, tabs: TabConfig[]): string { + for (const tab of tabs) { + if (pathname === tab.path || pathname.startsWith(`${tab.path}/`)) { + return tab.key; + } + } + return tabs[0]?.key ?? 'users'; +} + +export function SecurityTabsNav() { + const location = useLocation(); + const navigate = useNavigate(); + const userData = useApiStoreHook((s) => s.userData); + const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); + const featureCreateUser = useSupportedFeaturesStore((s) => s.createUser); + const redpandaOverview = useApiStoreHook((s) => s.clusterOverview?.redpanda); + const isAdminApiConfigured = Boolean(redpandaOverview); + + const tabs = buildTabs(isAdminApiConfigured, featureCreateUser, featureRolesApi, userData); + const activeTab = deriveActiveTab(location.pathname, tabs); + + const handleTabClick = (tabKey: string) => { + const tab = tabs.find((t) => t.key === tabKey); + if (tab && !tab.disabled) { + navigate({ to: tab.path }); + } + }; + + return ( + <> + + + + {tabs.map((tab) => ( + handleTabClick(tab.key)} + value={tab.key} + variant="underline" + > + {tab.label} + + ))} + + + + + ); +} diff --git a/frontend/src/components/pages/security/tabs/acls-tab.tsx b/frontend/src/components/pages/security/tabs/acls-tab.tsx deleted file mode 100644 index c92b12d02e..0000000000 --- a/frontend/src/components/pages/security/tabs/acls-tab.tsx +++ /dev/null @@ -1,267 +0,0 @@ -/** - * Copyright 2022 Redpanda Data, Inc. - * - * Use of this software is governed by the Business Source License - * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md - * - * As of the Change Date specified in that file, in accordance with - * the Business Source License, use of this software will be governed - * by the Apache License, Version 2.0 - */ - -import { create } from '@bufbuild/protobuf'; -import { DataTable, SearchField } from '@redpanda-data/ui'; -import { Link, useNavigate } from '@tanstack/react-router'; -import { TrashIcon } from 'components/icons'; -import { InfoIcon } from 'lucide-react'; -import { - ACL_Operation, - ACL_PermissionType, - ACL_ResourcePatternType, - ACL_ResourceType, - type DeleteACLsRequest, - DeleteACLsRequestSchema, -} from 'protogen/redpanda/api/dataplane/v1/acl_pb'; -import type { FC } from 'react'; -import { useState } from 'react'; -import { toast } from 'sonner'; - -import ErrorResult from '../../../../components/misc/error-result'; -import { useDeleteAclMutation, useListACLAsPrincipalGroups } from '../../../../react-query/api/acl'; -import { useGetRedpandaInfoQuery } from '../../../../react-query/api/cluster-status'; -import { useDeleteUserMutation, useInvalidateUsersCache, useListUsersQuery } from '../../../../react-query/api/user'; -import { api } from '../../../../state/backend-api'; -import { AclRequestDefault } from '../../../../state/rest-interfaces'; -import { useSupportedFeaturesStore } from '../../../../state/supported-features'; -import { Code as CodeEl, DefaultSkeleton } from '../../../../utils/tsx-utils'; -import Section from '../../../misc/section'; -import { Alert, AlertDescription } from '../../../redpanda-ui/components/alert'; -import { Badge } from '../../../redpanda-ui/components/badge'; -import { Button } from '../../../redpanda-ui/components/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '../../../redpanda-ui/components/dropdown-menu'; -import { useSecurityBreadcrumbs } from '../hooks/use-security-breadcrumbs'; -import { AlertDeleteFailed } from '../shared/alert-delete-failed'; -import { filterByName } from '../shared/filter-by-name'; - -export const AclsTab: FC = () => { - useSecurityBreadcrumbs([]); - const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); - const featureDeleteUser = useSupportedFeaturesStore((s) => s.deleteUser); - const { data: redpandaInfo, isSuccess: isRedpandaInfoSuccess } = useGetRedpandaInfoQuery(); - const isAdminApiConfigured = isRedpandaInfoSuccess && Boolean(redpandaInfo); - const { data: usersData } = useListUsersQuery(undefined, { enabled: isAdminApiConfigured }); - const { data: principalGroups, isLoading, isError, error } = useListACLAsPrincipalGroups(); - const { mutateAsync: deleteACLMutation } = useDeleteAclMutation(); - const { mutateAsync: deleteUserMut } = useDeleteUserMutation(); - const invalidateUsersCache = useInvalidateUsersCache(); - - const [aclFailed, setAclFailed] = useState<{ err: unknown } | null>(null); - const [searchQuery, setSearchQuery] = useState(''); - - const navigate = useNavigate(); - - const deleteACLsForPrincipal = async (principal: string, host: string) => { - const deleteRequest: DeleteACLsRequest = create(DeleteACLsRequestSchema, { - filter: { - principal, - resourceType: ACL_ResourceType.ANY, - resourceName: undefined, - host, - operation: ACL_Operation.ANY, - permissionType: ACL_PermissionType.ANY, - resourcePatternType: ACL_ResourcePatternType.ANY, - }, - }); - await deleteACLMutation(deleteRequest); - toast.success( - - Deleted ACLs for {principal} - - ); - }; - - const aclPrincipalGroups = - principalGroups?.filter((g) => g.principalType === 'User' || g.principalType === 'Group') || []; - const groups = filterByName(aclPrincipalGroups, searchQuery, (g) => g.principalName); - - if (isError && error) { - return ; - } - - if (isLoading || !principalGroups) { - return DefaultSkeleton; - } - - return ( -
-
- This tab displays all access control lists (ACLs), grouped by principal and host. A principal represents any - entity that can be authenticated, such as a user, service, or system (for example, a SASL-SCRAM user, OIDC - identity, or mTLS client). The ACLs tab shows only the permissions directly granted to each principal. For a - complete view of all permissions, including permissions granted through roles, see the Permissions List tab. -
- {Boolean(featureRolesApi) && ( - } variant="warning"> - - Roles are a more flexible and efficient way to manage user permissions, especially with complex - organizational hierarchies or large numbers of users. - - - )} - -
- setAclFailed(null)} /> - - - -
- - columns={[ - { - size: Number.POSITIVE_INFINITY, - header: 'Principal', - accessorKey: 'principal', - cell: ({ row: { original: record } }) => ( - ({ ...prev, host: record.host })} - to="/security/acls/$aclName/details" - > - - - {record.principalName} - - {record.principalType === 'Group' && Group} - - - ), - }, - { - header: 'Host', - accessorKey: 'host', - cell: ({ - row: { - original: { host }, - }, - }) => (!host || host === '*' ? Any : host), - }, - { - size: 60, - id: 'menu', - header: '', - cell: ({ row: { original: record } }) => { - const userExists = usersData?.users?.some((u) => u.name === record.principalName) ?? false; - - const onDelete = async (user: boolean, acls: boolean) => { - if (acls) { - try { - await deleteACLsForPrincipal(record.principal, record.host); - } catch (err: unknown) { - // biome-ignore lint/suspicious/noConsole: error logging - console.error('failed to delete acls', { error: err }); - setAclFailed({ err }); - } - } - - if (user) { - try { - await deleteUserMut({ name: record.principalName }); - toast.success( - - Deleted user {record.principalName} - - ); - } catch (err: unknown) { - // biome-ignore lint/suspicious/noConsole: error logging - console.error('failed to delete user', { error: err }); - setAclFailed({ err }); - } - } - - await Promise.allSettled([api.refreshAcls(AclRequestDefault, true), invalidateUsersCache()]); - }; - - return ( - - - - - - { - onDelete(true, true).catch(() => { - // Error handling managed by API layer - }); - e.stopPropagation(); - }} - > - Delete (User and ACLs) - - { - onDelete(true, false).catch(() => { - // Error handling managed by API layer - }); - e.stopPropagation(); - }} - > - Delete (User only) - - { - onDelete(false, true).catch(() => { - // Error handling managed by API layer - }); - e.stopPropagation(); - }} - > - Delete (ACLs only) - - - - ); - }, - }, - ]} - data={groups} - pagination - sorting - /> -
-
-
- ); -}; diff --git a/frontend/src/components/pages/security/tabs/permissions-list-tab.tsx b/frontend/src/components/pages/security/tabs/permissions-list-tab.tsx index e4478c21ee..d4339401fe 100644 --- a/frontend/src/components/pages/security/tabs/permissions-list-tab.tsx +++ b/frontend/src/components/pages/security/tabs/permissions-list-tab.tsx @@ -11,30 +11,10 @@ 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 { TrashIcon } from 'components/icons'; -import { - ListLayout, - ListLayoutContent, - ListLayoutFilters, - ListLayoutHeader, - ListLayoutPagination, - ListLayoutSearchInput, -} from 'components/redpanda-ui/components/list-layout'; -import { parseAsString, useQueryStates } from 'nuqs'; +import { ListLayout, ListLayoutContent, ListLayoutFilters } from 'components/redpanda-ui/components/list-layout'; +import { ChevronDown, ChevronRight, ExternalLink, ShieldIcon } from 'lucide-react'; +import { parseAsString, useQueryState } from 'nuqs'; import { ACL_Operation, ACL_PermissionType, @@ -43,7 +23,7 @@ import { DeleteACLsRequestSchema, } from 'protogen/redpanda/api/dataplane/v1/acl_pb'; import type { FC } from 'react'; -import { useCallback, useMemo, useState } from 'react'; +import { useLayoutEffect, useState } from 'react'; import { toast } from 'sonner'; import ErrorResult from '../../../../components/misc/error-result'; @@ -52,138 +32,252 @@ import { useDeleteUserMutation, useInvalidateUsersCache } from '../../../../reac 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 { DataTableColumnHeader, DataTablePagination } from '../../../redpanda-ui/components/data-table'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '../../../redpanda-ui/components/dropdown-menu'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../../redpanda-ui/components/table'; -import { type PrincipalEntry, usePrincipalList } from '../hooks/use-principal-list'; -import { useSecurityBreadcrumbs } from '../hooks/use-security-breadcrumbs'; +import { + type PrincipalPermissionGroup, + type RoleAclGroup, + usePrincipalPermissions, +} from '../hooks/use-principal-permissions'; import { AlertDeleteFailed } from '../shared/alert-delete-failed'; import { DeleteUserConfirmModal } from '../shared/delete-user-confirm-modal'; -import { UserRoleTags } from '../shared/user-role-tags'; +import { DescriptionWithHelp } from '../shared/description-with-help'; +import { SecurityTabsNav } from '../shared/security-tabs-nav'; +import { AddAclDialog } from '../users/add-acl-dialog'; -const 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 AclTableHeader: FC = () => ( +
+ Type + Resource + Operation + Permission + Host + +
+); + +export const AclRow: 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 && ( + + + + )} + +
+); + +const RoleGroup: FC<{ group: RoleAclGroup }> = ({ group }) => ( +
+
+ + Via Role: {group.roleName} +
+ {group.acls.map((acl, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: no stable key + + ))} +
+); -const PermissionsListActions = ({ - entry, - canDeleteUser, - onDelete, -}: { - entry: PrincipalEntry; +type PrincipalRowProps = { + group: PrincipalPermissionGroup; + isExpanded: boolean; + onToggle: () => void; + onDelete: (deleteUser: boolean, deleteAcls: boolean) => void; canDeleteUser: boolean; - onDelete: (entry: PrincipalEntry, deleteUser: boolean, deleteAcls: boolean) => Promise; -}) => { - const [pendingAction, setPendingAction] = useState<'user-and-acls' | 'user-only' | null>(null); +}; + +const PrincipalRow: FC = ({ group, isExpanded, onToggle, onDelete, canDeleteUser }) => { + const [pendingDelete, setPendingDelete] = useState<'user-and-acls' | 'user-only' | null>(null); + + const summaryText = (() => { + if (group.directAclCount > 0 && group.inheritedAclCount > 0) { + return `${group.directAclCount} direct ACL${group.directAclCount !== 1 ? 's' : ''}, ${group.inheritedAclCount} ACL${group.inheritedAclCount !== 1 ? 's' : ''} inherited from roles`; + } + if (group.inheritedAclCount > 0) { + return `${group.inheritedAclCount} ACL${group.inheritedAclCount !== 1 ? 's' : ''} inherited from roles`; + } + if (group.directAclCount > 0) { + return `${group.directAclCount} direct ACL${group.directAclCount !== 1 ? 's' : ''}`; + } + return 'No ACLs'; + })(); + + const hasAcls = group.directAclCount + group.inheritedAclCount > 0; return ( <> { - if (pendingAction === 'user-and-acls') await onDelete(entry, true, true); - if (pendingAction === 'user-only') await onDelete(entry, true, false); + if (pendingDelete === 'user-and-acls') onDelete(true, true); + if (pendingDelete === 'user-only') onDelete(true, false); }} onOpenChange={(open) => { - if (!open) setPendingAction(null); + if (!open) setPendingDelete(null); }} - open={pendingAction !== null} - userName={entry.name} + open={pendingDelete !== null} + userName={group.principalName} /> - - - - - - {entry.principalType !== 'Group' && ( - <> - { - e.stopPropagation(); - setPendingAction('user-and-acls'); - }} - > - Delete (User and ACLs) - - { - e.stopPropagation(); - setPendingAction('user-only'); - }} - > - Delete (User only) - - + +
+ {/* Principal header row */} +
e.key === 'Enter' && onToggle()} + role="button" + tabIndex={0} + > + {isExpanded ? ( + + ) : ( + )} - { - e.stopPropagation(); - onDelete(entry, false, true).catch(() => {}); - }} + +
+ + {group.principalType === 'Group' ? 'Group:' : ''} + {group.principalName} + + {group.principalType === 'Group' && Group} + {summaryText} + {group.denyCount > 0 && {group.denyCount} deny} +
+ +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + role="presentation" > - Delete (ACLs only) - - - + {group.principalType === 'User' && ( + e.stopPropagation()} + params={{ userName: group.principalName }} + to="/security/users/$userName/details" + > + + + )} + + + + + + + {group.principalType === 'User' && ( + <> + { + e.stopPropagation(); + setPendingDelete('user-and-acls'); + }} + > + Delete (User and ACLs) + + { + e.stopPropagation(); + setPendingDelete('user-only'); + }} + > + Delete (User only) + + + )} + { + e.stopPropagation(); + onDelete(false, true); + }} + > + Delete (ACLs only) + + + +
+
+ + {/* Expanded content */} + {isExpanded && hasAcls && ( +
+ + {group.directAcls.map((acl, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: no stable key + + ))} + {group.roleAclGroups.map((rg) => ( + + ))} +
+ )} + + {isExpanded && !hasAcls && ( +
No ACLs assigned.
+ )} +
); }; export const PermissionsListTab: FC = () => { - useSecurityBreadcrumbs([{ title: 'Permissions', linkTo: '/security/permissions-list' }]); + 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 [sorting, setSorting] = useState([]); - const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(10); - - const [urlFilterParams, setUrlFilterParams] = useQueryStates({ - name: parseAsString, - }); - const columnFilters = useMemo(() => { - const result: ColumnFiltersState = []; - if (urlFilterParams.name) { - result.push({ id: 'name', value: urlFilterParams.name }); - } - return result; - }, [urlFilterParams]); + const { principalGroups, isAclsLoading, isAclsError, aclsError, isUsersError, usersError } = + usePrincipalPermissions(); - const handleColumnFiltersChange = useCallback( - (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, - }); - }, - [columnFilters, setUrlFilterParams] - ); - - const { principals, isUsersError, usersError, isAclsError, aclsError } = usePrincipalList(); + 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, { @@ -205,23 +299,20 @@ export const PermissionsListTab: FC = () => { ); }; - // Best-effort delete: ACL and user deletions are independent operations. - // If ACL deletion fails, we still attempt user deletion (and vice versa). - // Any failure is surfaced via the AlertDeleteFailed banner. - const onDelete = async (entry: PrincipalEntry, deleteUser: boolean, deleteAcls: boolean) => { + const onDelete = async (group: PrincipalPermissionGroup, deleteUser: boolean, deleteAcls: boolean) => { if (deleteAcls) { try { - await deleteACLsForPrincipal(entry.name, entry.principalType); + await deleteACLsForPrincipal(group.principalName, group.principalType); } catch (err: unknown) { setAclFailed({ err }); } } if (deleteUser) { try { - await deleteUserMutation({ name: entry.name }); + await deleteUserMutation({ name: group.principalName }); toast.success( - Deleted user {entry.name} + Deleted user {group.principalName} ); } catch (err: unknown) { @@ -231,90 +322,18 @@ export const PermissionsListTab: FC = () => { await Promise.allSettled([api.refreshAcls(AclRequestDefault, true), invalidateUsersCache()]); }; - const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]); - - const handlePaginationChange = useCallback( - (updater: Updater) => { - const next = typeof updater === 'function' ? updater(pagination) : updater; - setPageIndex(next.pageIndex); - setPageSize(next.pageSize); - }, - [pagination] - ); - - const columns = useMemo[]>( - () => [ - { - accessorKey: 'name', - header: ({ column }) => , - cell: ({ row: { original: entry } }) => { - if (entry.principalType === 'Group') { - return ( - - - {entry.name} - Group - - - ); - } - return ( - - {entry.name} - - ); - }, - filterFn: nameFilterFn, - }, - { - id: 'assignedRoles', - header: 'Permissions', - enableSorting: false, - cell: ({ row: { original: entry } }) => ( - - ), - }, - { - id: 'menu', - header: '', - enableSorting: false, - meta: { align: 'right' as const }, - cell: ({ row: { original: entry } }) => ( - - ), - }, - ], - // eslint-disable-next-line react-hooks/exhaustive-deps - [featureDeleteUser] - ); + 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 table = useReactTable({ - data: principals, - columns, - state: { sorting, pagination, columnFilters }, - onSortingChange: setSorting, - onPaginationChange: handlePaginationChange, - onColumnFiltersChange: handleColumnFiltersChange, - autoResetPageIndex: false, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), - getPaginationRowModel: getPaginationRowModel(), - }); + const filteredGroups = principalGroups.filter((g) => matchesSearch(g, searchQuery)); if (isUsersError && usersError) { return ( @@ -330,57 +349,59 @@ export const PermissionsListTab: FC = () => { } 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. +

+ +

- - table.getColumn('name')?.setFilterValue(e.target.value || undefined)} - placeholder="Filter by name (regexp)..." - value={(table.getColumn('name')?.getFilterValue() as string) ?? ''} - /> - + setCreateAclOpen(true)}>Create ACL}> + setSearchQuery(e.target.value)} + placeholder="Search principals, resources, roles..." + value={searchQuery} + /> + - {aclFailed && setAclFailed(null)} />} + {aclFailed !== null && setAclFailed(null)} />} - - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} - - ))} - - ))} - - - {table.getRowModel().rows.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - - No principals yet. - - - )} - -
-
+ + {isAclsLoading ? ( +
Loading...
+ ) : filteredGroups.length === 0 ? ( +
+ {searchQuery ? 'No principals match your search.' : 'No principals yet.'} +
+ ) : ( +
+ {filteredGroups.map((group) => ( + { + onDelete(group, deleteUser, deleteAcls).catch(() => {}); + }} + onToggle={() => toggleExpanded(group.principal)} + /> + ))} +
+ )} +
+
- - - -
+ + ); }; diff --git a/frontend/src/components/pages/security/tabs/roles-tab.tsx b/frontend/src/components/pages/security/tabs/roles-tab.tsx index 155a5021ab..38c9d867eb 100644 --- a/frontend/src/components/pages/security/tabs/roles-tab.tsx +++ b/frontend/src/components/pages/security/tabs/roles-tab.tsx @@ -26,31 +26,33 @@ import { useReactTable, } from '@tanstack/react-table'; import { EditIcon, TrashIcon } from 'components/icons'; +import { RoleCreateDialog } from 'components/pages/security/roles/role-create-dialog'; import { ListLayout, ListLayoutContent, ListLayoutFilters, - ListLayoutHeader, ListLayoutPagination, ListLayoutSearchInput, } from 'components/redpanda-ui/components/list-layout'; import { parseAsString, useQueryStates } from 'nuqs'; import { DeleteRoleRequestSchema } from 'protogen/redpanda/api/dataplane/v1/security_pb'; import type { FC } from 'react'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useLayoutEffect, useMemo, useState } from 'react'; import ErrorResult from '../../../../components/misc/error-result'; import { useDeleteRoleMutation, useListRolesQuery } from '../../../../react-query/api/security'; import { rolesApi, useApiStoreHook } from '../../../../state/backend-api'; import { useSupportedFeaturesStore } from '../../../../state/supported-features'; +import { setPageHeader } from '../../../../state/ui-state'; import { FeatureLicenseNotification } from '../../../license/feature-license-notification'; import { NullFallbackBoundary } from '../../../misc/null-fallback-boundary'; import { 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 { useSecurityBreadcrumbs } from '../hooks/use-security-breadcrumbs'; import { DeleteRoleConfirmModal } from '../shared/delete-role-confirm-modal'; +import { DescriptionWithHelp } from '../shared/description-with-help'; +import { SecurityTabsNav } from '../shared/security-tabs-nav'; type RoleEntry = { name: string; @@ -67,10 +69,16 @@ const nameFilterFn = (row: Row, columnId: string, filterValue: string }; export const RolesTab: FC = () => { - useSecurityBreadcrumbs([{ title: 'Roles', linkTo: '/security/roles' }]); + useLayoutEffect(() => { + setPageHeader('Security', [ + { title: 'Security', linkTo: '/security/users' }, + { title: 'Roles', linkTo: '/security/roles' }, + ]); + }, []); const navigate = useNavigate(); const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); const userData = useApiStoreHook((s) => s.userData); + const [createDialogOpen, setCreateDialogOpen] = useState(false); const [sorting, setSorting] = useState([]); const [pageIndex, setPageIndex] = useState(0); const [pageSize, setPageSize] = useState(10); @@ -208,74 +216,86 @@ export const RolesTab: FC = () => { .join(' '); 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) ?? ''} - /> - + + + + + {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())} - - ))} - - ))} - - - {table.getRowModel().rows.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - + +
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + ))} - )) - ) : ( - - - No roles yet. - - - )} - -
-
+ ))} + + + {table.getRowModel().rows.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No roles yet. + + + )} + + + + + + + +
- - - -
+ + ); }; diff --git a/frontend/src/components/pages/security/tabs/users-tab.tsx b/frontend/src/components/pages/security/tabs/users-tab.tsx index 9ec9846c35..b08f607aab 100644 --- a/frontend/src/components/pages/security/tabs/users-tab.tsx +++ b/frontend/src/components/pages/security/tabs/users-tab.tsx @@ -28,17 +28,17 @@ import { useReactTable, } from '@tanstack/react-table'; import { MoreHorizontalIcon } from 'components/icons'; +import { DescriptionWithHelp } from 'components/pages/security/shared/description-with-help'; import { ListLayout, ListLayoutContent, ListLayoutFilters, - ListLayoutHeader, ListLayoutPagination, ListLayoutSearchInput, } from 'components/redpanda-ui/components/list-layout'; import { parseAsArrayOf, parseAsString, useQueryStates } from 'nuqs'; import type { FC } from 'react'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useLayoutEffect, useMemo, 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'; @@ -47,6 +47,7 @@ import { useGetRedpandaInfoQuery } from '../../../../react-query/api/cluster-sta 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'; @@ -63,9 +64,9 @@ import { } from '../../../redpanda-ui/components/dropdown-menu'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../../redpanda-ui/components/table'; import { TagsValue } from '../../../redpanda-ui/components/tags'; -import { Tooltip, TooltipContent, TooltipTrigger } from '../../../redpanda-ui/components/tooltip'; -import { useSecurityBreadcrumbs } from '../hooks/use-security-breadcrumbs'; +import { Tooltip, TooltipTrigger } from '../../../redpanda-ui/components/tooltip'; import { DeleteUserConfirmModal } from '../shared/delete-user-confirm-modal'; +import { SecurityTabsNav } from '../shared/security-tabs-nav'; import { CreateUserDialog } from '../users/user-create-dialog'; import { ChangePasswordModal, ChangeRolesModal } from '../users/user-edit-modals'; @@ -121,7 +122,12 @@ const getCreateUserButtonProps = ( }; export const UsersTab: FC = () => { - useSecurityBreadcrumbs([{ title: 'Users', linkTo: '/security/users' }]); + useLayoutEffect(() => { + setPageHeader('Security', [ + { title: 'Security', linkTo: '/security' }, + { title: 'Users', linkTo: '/security/users' }, + ]); + }, []); const { data: redpandaInfo, isSuccess: isRedpandaInfoSuccess } = useGetRedpandaInfoQuery(); const isAdminApiConfigured = isRedpandaInfoSuccess && Boolean(redpandaInfo); @@ -267,7 +273,7 @@ export const UsersTab: FC = () => { ); } - const { disabled: createDisabled, tooltip: createTooltip } = getCreateUserButtonProps( + const { disabled: createDisabled } = getCreateUserButtonProps( isAdminApiConfigured, featureCreateUser, userData?.canManageUsers @@ -275,9 +281,15 @@ export const UsersTab: FC = () => { 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. + +

{ Create user - {createTooltip && {createTooltip}} } > diff --git a/frontend/src/components/pages/security/users/add-acl-dialog.tsx b/frontend/src/components/pages/security/users/add-acl-dialog.tsx new file mode 100644 index 0000000000..3ef6616193 --- /dev/null +++ b/frontend/src/components/pages/security/users/add-acl-dialog.tsx @@ -0,0 +1,348 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { create } from '@bufbuild/protobuf'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { + ACL_Operation, + ACL_PermissionType, + ACL_ResourcePatternType, + ACL_ResourceType, + CreateACLRequestSchema, +} from 'protogen/redpanda/api/dataplane/v1/acl_pb'; +import { useMemo, useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { useCreateACLMutation } from '../../../../react-query/api/acl'; +import { useListUsersQuery } from '../../../../react-query/api/user'; +import { Alert, AlertDescription } from '../../../redpanda-ui/components/alert'; +import { Button } from '../../../redpanda-ui/components/button'; +import { Combobox } from '../../../redpanda-ui/components/combobox'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '../../../redpanda-ui/components/dialog'; +import { Input } from '../../../redpanda-ui/components/input'; +import { Label } from '../../../redpanda-ui/components/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../../redpanda-ui/components/select'; + +const schema = z.object({ + resourceType: z.nativeEnum(ACL_ResourceType), + patternType: z.nativeEnum(ACL_ResourcePatternType), + resourceName: z.string(), + operation: z.nativeEnum(ACL_Operation), + permissionType: z.nativeEnum(ACL_PermissionType), + host: z.string(), +}); + +type FormValues = z.infer; + +const RESOURCE_TYPE_OPTIONS = [ + { value: ACL_ResourceType.TOPIC, label: 'Topic' }, + { value: ACL_ResourceType.GROUP, label: 'Consumer Group' }, + { value: ACL_ResourceType.CLUSTER, label: 'Cluster' }, + { value: ACL_ResourceType.TRANSACTIONAL_ID, label: 'Transactional ID' }, + { value: ACL_ResourceType.SUBJECT, label: 'Subject' }, + { value: ACL_ResourceType.REGISTRY, label: 'Schema Registry' }, +]; + +const OPERATION_OPTIONS = [ + { value: ACL_Operation.ALL, label: 'All' }, + { value: ACL_Operation.READ, label: 'Read' }, + { value: ACL_Operation.WRITE, label: 'Write' }, + { value: ACL_Operation.CREATE, label: 'Create' }, + { value: ACL_Operation.DELETE, label: 'Delete' }, + { value: ACL_Operation.ALTER, label: 'Alter' }, + { value: ACL_Operation.DESCRIBE, label: 'Describe' }, + { value: ACL_Operation.DESCRIBE_CONFIGS, label: 'Describe Configs' }, + { value: ACL_Operation.ALTER_CONFIGS, label: 'Alter Configs' }, + { value: ACL_Operation.IDEMPOTENT_WRITE, label: 'Idempotent Write' }, + { value: ACL_Operation.CLUSTER_ACTION, label: 'Cluster Action' }, +]; + +const PATTERN_TYPE_HELP: Partial> = { + [ACL_ResourcePatternType.LITERAL]: 'Matches the exact resource name.', + [ACL_ResourcePatternType.PREFIXED]: 'Matches any resource name starting with this prefix.', + [ACL_ResourcePatternType.ANY]: 'Matches any resource name.', +}; + +type AddAclDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + /** When provided the principal selector is hidden and this value is used directly. */ + principal?: string; +}; + +export const AddAclDialog = ({ open, onOpenChange, principal }: AddAclDialogProps) => { + const { mutateAsync: createACL, isPending } = useCreateACLMutation(); + const [submitError, setSubmitError] = useState(null); + const [principalType, setPrincipalType] = useState<'User' | 'Group'>('User'); + const [principalValue, setPrincipalValue] = useState(''); + + const { data: usersData } = useListUsersQuery(undefined, { enabled: !principal }); + const userOptions = useMemo( + () => (usersData?.users ?? []).map((u) => ({ value: u.name, label: u.name })), + [usersData] + ); + + const effectivePrincipal = principal ?? `${principalType}:${principalValue}`; + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + resourceType: ACL_ResourceType.TOPIC, + patternType: ACL_ResourcePatternType.LITERAL, + resourceName: '', + operation: ACL_Operation.ALL, + permissionType: ACL_PermissionType.ALLOW, + host: '*', + }, + }); + + const resourceType = form.watch('resourceType'); + const patternType = form.watch('patternType'); + + const showPatternAndName = resourceType !== ACL_ResourceType.CLUSTER && resourceType !== ACL_ResourceType.REGISTRY; + + const showResourceName = + showPatternAndName && + (patternType === ACL_ResourcePatternType.LITERAL || patternType === ACL_ResourcePatternType.PREFIXED); + + const resetPrincipalSelector = () => { + setPrincipalType('User'); + setPrincipalValue(''); + }; + + const onSubmit = async (values: FormValues) => { + setSubmitError(null); + try { + await createACL( + create(CreateACLRequestSchema, { + resourceType: values.resourceType, + resourceName: values.resourceName || '*', + resourcePatternType: values.patternType, + principal: effectivePrincipal, + host: values.host || '*', + operation: values.operation, + permissionType: values.permissionType, + }) + ); + onOpenChange(false); + form.reset(); + if (!principal) resetPrincipalSelector(); + } catch (err) { + setSubmitError(err instanceof Error ? err.message : String(err)); + } + }; + + const handleClose = () => { + setSubmitError(null); + onOpenChange(false); + form.reset(); + if (!principal) resetPrincipalSelector(); + }; + + return ( + + + + Add ACL + {principal && Define a new access control rule for {principal}.} + +
+
+ {!principal && ( +
+ +
+ + {principalType === 'User' ? ( + + ) : ( + setPrincipalValue(e.target.value)} + placeholder="Enter group name..." + value={principalValue} + /> + )} +
+
+ )} + +
+ + ( + + )} + /> +
+ + {showPatternAndName && ( +
+ + ( +
+
+ {[ + { value: ACL_ResourcePatternType.LITERAL, label: 'Literal' }, + { value: ACL_ResourcePatternType.PREFIXED, label: 'Prefixed' }, + { value: ACL_ResourcePatternType.ANY, label: 'Any' }, + ].map((opt) => ( + + ))} +
+ {PATTERN_TYPE_HELP[field.value] && ( +

{PATTERN_TYPE_HELP[field.value]}

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

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

+ +
+ + {submitError && ( + + {submitError} + + )} +
+ + + + + +
+
+
+ ); +}; diff --git a/frontend/src/components/pages/security/users/user-acls-card.test.tsx b/frontend/src/components/pages/security/users/user-acls-card.test.tsx index 574cc6db32..c43dd9051b 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,24 +58,19 @@ 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.getByText('No ACLs assigned.')).toBeInTheDocument(); expect(screen.getByRole('button', { name: '+ Add ACL' })).toBeInTheDocument(); }); test('should render empty state when acls is undefined', () => { renderWithFileRoutes(); - expect(screen.getByText('ACLs (0)')).toBeInTheDocument(); - expect(screen.getByText('No ACLs assigned to this user.')).toBeInTheDocument(); + expect(screen.getByText('No ACLs assigned.')).toBeInTheDocument(); }); test('should render flat ACL table with correct row count and data', () => { renderWithFileRoutes(); - // 3 flat rows: READ + WRITE on test-topic, DESCRIBE on cluster - expect(screen.getByText('ACLs 3 rules')).toBeInTheDocument(); - // Resource types expect(screen.getAllByText('Topic')).toHaveLength(2); expect(screen.getByText('Cluster')).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 df2a3494ea..4d969d7932 100644 --- a/frontend/src/components/pages/security/users/user-acls-card.tsx +++ b/frontend/src/components/pages/security/users/user-acls-card.tsx @@ -9,135 +9,14 @@ * by the Apache License, Version 2.0 */ -import { useNavigate } from '@tanstack/react-router'; -import { MoreHorizontalIcon } from 'components/icons'; - -import { Button } from '../../../redpanda-ui/components/button'; -import { Card, CardAction, CardContent, CardHeader, CardTitle } from '../../../redpanda-ui/components/card'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '../../../redpanda-ui/components/dropdown-menu'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../../redpanda-ui/components/table'; -import { - type AclDetail, - getResourceNameValue, - type OperationType, - OperationTypeNotSet, - type ResourceType, -} from '../shared/acl-model'; - -type FlatAclRow = { - resourceType: ResourceType; - resourceName: string; - operation: string; - permission: OperationType; - host: string; - principal: string; -}; - -const RESOURCE_TYPE_LABELS: Record = { - cluster: 'Cluster', - topic: 'Topic', - consumerGroup: 'Consumer Group', - transactionalId: 'Transactional ID', - subject: 'Subject', - schemaRegistry: 'Schema Registry', -}; - -const flattenAcls = (acls: AclDetail[]): FlatAclRow[] => - acls.flatMap((detail) => - detail.rules.flatMap((rule) => - Object.entries(rule.operations) - .filter(([, perm]) => perm !== OperationTypeNotSet) - .map(([op, perm]) => ({ - resourceType: rule.resourceType, - resourceName: getResourceNameValue(rule), - operation: op.charAt(0) + op.slice(1).toLowerCase(), - permission: perm, - host: detail.sharedConfig.host, - principal: detail.sharedConfig.principal, - })) - ) - ); +import type { AclDetail } from '../shared/acl-model'; +import { AclsCard } from '../shared/acls-card'; type UserAclsCardProps = { acls?: AclDetail[]; userName?: string; }; -export const UserAclsCard = ({ acls, userName }: UserAclsCardProps) => { - const navigate = useNavigate(); - const rows = flattenAcls(acls ?? []); - const count = rows.length; - - const navigateToEdit = () => { - const name = userName ?? (acls?.[0] ? acls[0].sharedConfig.principal.replace(/^User:/, '') : ''); - navigate({ to: `/security/acls/${name}/details` }); - }; - - const navigateToCreate = () => { - navigate({ to: '/security/acls/create', search: { principalType: undefined, principalName: undefined } }); - }; - - return ( - - - {count === 0 ? 'ACLs (0)' : `ACLs ${count} ${count === 1 ? 'rule' : 'rules'}`} - - - - - - {count === 0 ? ( -

No ACLs assigned to this user.

- ) : ( - - - - Resource Type - Resource Name - Operation - Permission - Host - - - - - {rows.map((row, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: rows have no stable unique key - - {RESOURCE_TYPE_LABELS[row.resourceType] ?? row.resourceType} - {row.resourceName} - {row.operation} - - - {row.permission === 'allow' ? 'Allow' : 'Deny'} - - - {row.host} - - - - - - - Edit - - - - - ))} - -
- )} -
-
- ); -}; +export const UserAclsCard = ({ acls, userName }: UserAclsCardProps) => ( + +); diff --git a/frontend/src/components/pages/security/users/user-create-dialog.tsx b/frontend/src/components/pages/security/users/user-create-dialog.tsx index 95dc373128..5fc7b495b9 100644 --- a/frontend/src/components/pages/security/users/user-create-dialog.tsx +++ b/frontend/src/components/pages/security/users/user-create-dialog.tsx @@ -16,7 +16,6 @@ import { useCallback, useState } from 'react'; import { generatePassword } from 'utils/password'; import { CreateUserConfirmationModal, CreateUserModal } from './user-create'; -import { ChangeRolesModal } from './user-edit-modals'; import { getSASLMechanism, useCreateUserMutation, useListUsersQuery } from '../../../../react-query/api/user'; import { type SaslMechanism, validatePassword, validateUsername } from '../../../../utils/user'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../../redpanda-ui/components/dialog'; @@ -35,7 +34,6 @@ export const CreateUserDialog = ({ open, onOpenChange }: CreateUserDialogProps) }); const [step, setStep] = useState<'form' | 'confirmation'>('form'); const [isSubmitting, setIsSubmitting] = useState(false); - const [isAssignRolesOpen, setIsAssignRolesOpen] = useState(false); const navigate = useNavigate(); const { mutateAsync: createUserMutate } = useCreateUserMutation(); @@ -72,9 +70,9 @@ export const CreateUserDialog = ({ open, onOpenChange }: CreateUserDialogProps) return true; }, [username, password, mechanism, createUserMutate]); - const onCreateAcls = () => { + const onGoToUserDetails = () => { handleClose(); - navigate({ to: '/security/acls/create', search: { principalType: 'User', principalName: username } }); + navigate({ to: `/security/users/${username}/details` }); }; const state = { @@ -107,17 +105,13 @@ export const CreateUserDialog = ({ open, onOpenChange }: CreateUserDialogProps) setIsAssignRolesOpen(true)} - onCreateAcls={onCreateAcls} + onGoToUserDetails={onGoToUserDetails} password={password} username={username} /> )} - {step === 'confirmation' && ( - - )} ); }; diff --git a/frontend/src/components/pages/security/users/user-create.tsx b/frontend/src/components/pages/security/users/user-create.tsx index e2aac3309b..5bd301b1eb 100644 --- a/frontend/src/components/pages/security/users/user-create.tsx +++ b/frontend/src/components/pages/security/users/user-create.tsx @@ -14,12 +14,12 @@ 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 { useSupportedFeaturesStore } from 'state/supported-features'; +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 { setPageHeader } from '../../../../state/ui-state'; import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, @@ -37,7 +37,6 @@ 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'; const UserCreatePage = () => { const [formState, setFormState] = useState({ @@ -67,7 +66,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 +107,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 +136,7 @@ const UserCreatePage = () => { @@ -299,8 +299,7 @@ type CreateUserConfirmationModalProps = { password: string; mechanism: SaslMechanism; closeModal: () => void; - onCreateAcls: () => void; - onAssignRoles?: () => void; + onGoToUserDetails: () => void; }; export const CreateUserConfirmationModal = ({ @@ -308,83 +307,70 @@ export const CreateUserConfirmationModal = ({ password, mechanism, closeModal, - onCreateAcls, - onAssignRoles, -}: CreateUserConfirmationModalProps) => { - const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); - - return ( - <> -

- User created successfully -

- - } variant="info"> - - You will not be able to view this password again. Make sure that it is copied and saved. - - - -
-
- Username -
-
- {username} - - - - - Copy username - -
+ onGoToUserDetails, +}: CreateUserConfirmationModalProps) => ( + <> +

+ User created successfully +

+ + } variant="info"> + + You will not be able to view this password again. Make sure that it is copied and saved. + + + +
+
+ Username +
+
+ {username} + + + + + Copy username + +
-
- Password -
-
- - - - - - Copy password - -
+
+ Password +
+
+ + + + + + Copy password + +
-
- Mechanism -
-
- {mechanism} -
+
+ Mechanism
+
+ {mechanism} +
+
-
-

What's next?

-

- This user has no permissions yet. Assign roles or create ACLs to grant access to cluster resources. -

-
- - {featureRolesApi && onAssignRoles && ( - - )} - -
+
+

What's next?

+

+ This user has no permissions yet. Assign roles or create ACLs to grant access to cluster resources. +

+
+ +
- - ); -}; +
+ +); export const StateRoleSelector = ({ roles, setRoles }: { roles: string[]; setRoles: (roles: string[]) => void }) => { const { diff --git a/frontend/src/components/pages/security/users/user-details.tsx b/frontend/src/components/pages/security/users/user-details.tsx index 4cc637d226..6b82035e4b 100644 --- a/frontend/src/components/pages/security/users/user-details.tsx +++ b/frontend/src/components/pages/security/users/user-details.tsx @@ -12,10 +12,10 @@ 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, useState } from 'react'; +import { useEffect, useLayoutEffect, useState } from 'react'; import { UserAclsCard } from './user-acls-card'; -import { ChangePasswordModal, ChangeRolesModal } from './user-edit-modals'; +import { ChangePasswordModal } from './user-edit-modals'; import { UserRolesCard } from './user-roles-card'; import { useGetAclsByPrincipal } from '../../../../react-query/api/acl'; import { useListRolesQuery } from '../../../../react-query/api/security'; @@ -24,8 +24,8 @@ import { appGlobal } from '../../../../state/app-global'; import { api, rolesApi } from '../../../../state/backend-api'; import { AclRequestDefault } from '../../../../state/rest-interfaces'; import { useSupportedFeaturesStore } from '../../../../state/supported-features'; +import { setPageHeader } from '../../../../state/ui-state'; import { DefaultSkeleton } from '../../../../utils/tsx-utils'; -import { useSecurityBreadcrumbs } from '../hooks/use-security-breadcrumbs'; import { DeleteUserConfirmModal } from '../shared/delete-user-confirm-modal'; type UserDetailsPageProps = { @@ -40,21 +40,26 @@ const formatMechanism = (mechanism?: SASLMechanism): string | null => { const UserDetailsPage = ({ userName }: UserDetailsPageProps) => { const [isChangePasswordModalOpen, setIsChangePasswordModalOpen] = useState(false); - const [isChangeRolesModalOpen, setIsChangeRolesModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); const { data: usersData, isLoading: isUsersLoading } = useListUsersQuery(); const users = usersData?.users?.map((u) => u.name) ?? []; const currentUser = usersData?.users?.find((u) => u.name === userName); - const mechanism = formatMechanism(currentUser?.mechanism); + formatMechanism(currentUser?.mechanism); const { mutateAsync: deleteUserMutation } = useDeleteUserMutation(); - useSecurityBreadcrumbs([ - { title: 'Users', linkTo: '/security/users' }, - { title: userName, linkTo: `/security/users/${userName}/details` }, - ]); + useLayoutEffect(() => { + setPageHeader( + userName, + [ + { title: 'Security', linkTo: '/security' }, + { title: 'Users', linkTo: '/security/users' }, + { title: userName, linkTo: `/security/users/${userName}/details` }, + ], + { title: 'Users', linkTo: '/security/users' } + ); + }, [userName]); useEffect(() => { const refreshData = async () => { @@ -97,48 +102,19 @@ const UserDetailsPage = ({ userName }: UserDetailsPageProps) => { }; return ( -
- {/* Header */} -
-
-

{userName}

-
- - Principal: User:{userName} - - {mechanism && ( - <> - · - - Mechanism: {mechanism} - - - )} -
-
- -
- + {Boolean(isServiceAccount) && ( + - {Boolean(isServiceAccount) && ( - - )} -
+ )}
- { - setIsChangeRolesModalOpen(true); - } - : undefined - } - userName={userName} - /> + { setIsOpen={setIsChangePasswordModalOpen} userName={userName} /> - - {Boolean(featureRolesApi) && ( - - )}
); }; export default UserDetailsPage; -const UserPermissionDetailsContent = ({ - userName, - onChangeRoles, -}: { - userName: string; - onChangeRoles?: () => void; -}) => { +const UserPermissionDetailsContent = ({ userName }: { userName: string }) => { const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); const { data: rolesData } = useListRolesQuery({ filter: { principal: userName } }); const { data: acls } = useGetAclsByPrincipal(`User:${userName}`); @@ -182,7 +148,7 @@ const UserPermissionDetailsContent = ({ return (
- +
); 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 aa2e566b01..059deb1902 100644 --- a/frontend/src/components/pages/security/users/user-roles-card.tsx +++ b/frontend/src/components/pages/security/users/user-roles-card.tsx @@ -12,12 +12,14 @@ import { create } from '@bufbuild/protobuf'; import { useNavigate } from '@tanstack/react-router'; import { ExternalLinkIcon, Trash2Icon } from 'lucide-react'; +import { useMemo } from 'react'; import { UpdateRoleMembershipRequestSchema } from '../../../../protogen/redpanda/api/dataplane/v1/security_pb'; -import { useUpdateRoleMembershipMutation } from '../../../../react-query/api/security'; +import { useListRolesQuery, useUpdateRoleMembershipMutation } from '../../../../react-query/api/security'; import { rolesApi } from '../../../../state/backend-api'; import { Button } from '../../../redpanda-ui/components/button'; -import { Card, CardAction, CardContent, CardHeader, CardTitle } from '../../../redpanda-ui/components/card'; +import { Combobox } from '../../../redpanda-ui/components/combobox'; +import { ListLayout, ListLayoutContent, ListLayoutFilters } from '../../../redpanda-ui/components/list-layout'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../../redpanda-ui/components/table'; type Role = { @@ -27,46 +29,62 @@ type Role = { type UserRolesCardProps = { roles: Role[]; - onChangeRoles?: () => void; userName?: string; }; -export const UserRolesCard = ({ roles, onChangeRoles, userName }: UserRolesCardProps) => { +export const UserRolesCard = ({ roles, userName }: UserRolesCardProps) => { const navigate = useNavigate(); const { mutateAsync: updateRoleMembership } = useUpdateRoleMembershipMutation(); + const { data: rolesData } = useListRolesQuery(); + + const assignedRoleNames = useMemo(() => new Set(roles.map((r) => r.principalName)), [roles]); + + const availableRoleOptions = useMemo( + () => + (rolesData?.roles ?? []) + .filter((r) => !assignedRoleNames.has(r.name)) + .map((r) => ({ value: r.name, label: r.name })), + [rolesData, assignedRoleNames] + ); const removeFromRole = async (roleName: string) => { if (!userName) return; - const membership = create(UpdateRoleMembershipRequestSchema, { - roleName, - remove: [{ principal: userName }], - }); - await updateRoleMembership(membership); + await updateRoleMembership( + create(UpdateRoleMembershipRequestSchema, { roleName, remove: [{ principal: userName }] }) + ); + await Promise.all([rolesApi.refreshRoles(), rolesApi.refreshRoleMembers()]); + }; + + 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 headerTitle = count > 0 ? `Roles ${count} assigned` : 'Roles'; return ( - - - {headerTitle} - - {Boolean(onChangeRoles) && ( - - )} - - - + + + ) : undefined + } + > +

Roles

+
+ {count === 0 ? ( -

No permissions assigned to this user.

+

No roles assigned to this user.

) : ( @@ -106,7 +124,7 @@ export const UserRolesCard = ({ roles, onChangeRoles, userName }: UserRolesCardP
)} -
-
+ + ); }; diff --git a/frontend/src/components/pages/topics/topic-list.tsx b/frontend/src/components/pages/topics/topic-list.tsx index 7a4461b700..e113615e94 100644 --- a/frontend/src/components/pages/topics/topic-list.tsx +++ b/frontend/src/components/pages/topics/topic-list.tsx @@ -45,7 +45,7 @@ import { appGlobal } from '../../../state/app-global'; import { api } from '../../../state/backend-api'; import { type Topic, TopicActions } from '../../../state/rest-interfaces'; import { uiSettings } from '../../../state/ui'; -import { uiState } from '../../../state/ui-state'; +import { setPageHeader } from '../../../state/ui-state'; import { onPaginationChange } from '../../../utils/pagination'; import { editQuery } from '../../../utils/query-helper'; import { Code, DefaultSkeleton, QuickTable } from '../../../utils/tsx-utils'; @@ -59,7 +59,7 @@ const QUICK_SEARCH_REGEX_CACHE = new Map(); const TopicList: FC = () => { useEffect(() => { - uiState.pageBreadcrumbs = [{ title: 'Topics', linkTo: '' }]; + setPageHeader('Topics', [{ title: 'Topics', linkTo: '/topics' }]); }, []); const [localSearchValue, setLocalSearchValue] = useQueryState('q', parseAsString.withDefault('')); diff --git a/frontend/src/components/redpanda-ui/components/list-layout.tsx b/frontend/src/components/redpanda-ui/components/list-layout.tsx index fb76b8cd0d..e17aa3b8d7 100644 --- a/frontend/src/components/redpanda-ui/components/list-layout.tsx +++ b/frontend/src/components/redpanda-ui/components/list-layout.tsx @@ -28,7 +28,7 @@ ListLayout.displayName = 'ListLayout'; interface ListLayoutHeaderProps extends React.HTMLAttributes { title: string; - description?: string; + description?: React.ReactNode; actions?: React.ReactNode; } diff --git a/frontend/src/react-query/api/acl.tsx b/frontend/src/react-query/api/acl.tsx index baa88eb598..e2e8870f03 100644 --- a/frontend/src/react-query/api/acl.tsx +++ b/frontend/src/react-query/api/acl.tsx @@ -393,12 +393,20 @@ export const useCreateACLMutation = () => { return useMutation(createACL, { onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: createConnectQueryKey({ - schema: ACLService.method.listACLs, - cardinality: 'infinite', + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: createConnectQueryKey({ + schema: ACLService.method.listACLs, + cardinality: 'infinite', + }), }), - }); + queryClient.invalidateQueries({ + queryKey: createConnectQueryKey({ + schema: ACLService.method.listACLs, + cardinality: 'finite', + }), + }), + ]); }, onError: (error) => formatToastErrorMessageGRPC({ diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index cbbdb8c792..7998d55393 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -52,7 +52,6 @@ import { Route as ShadowlinksNameIndexRouteImport } from './routes/shadowlinks/$ import { Route as SecurityUsersIndexRouteImport } from './routes/security/users/index'; import { Route as SecurityRolesIndexRouteImport } from './routes/security/roles/index'; import { Route as SecurityPermissionsListIndexRouteImport } from './routes/security/permissions-list/index'; -import { Route as SecurityAclsIndexRouteImport } from './routes/security/acls/index'; import { Route as RpConnectPipelineIdIndexRouteImport } from './routes/rp-connect/$pipelineId/index'; import { Route as KnowledgebasesKnowledgebaseIdIndexRouteImport } from './routes/knowledgebases/$knowledgebaseId/index'; import { Route as ConnectClustersClusterNameIndexRouteImport } from './routes/connect-clusters/$clusterName/index'; @@ -61,7 +60,6 @@ import { Route as TopicsTopicNameProduceRecordRouteImport } from './routes/topic import { Route as ShadowlinksNameEditRouteImport } from './routes/shadowlinks/$name/edit'; import { Route as SecurityUsersCreateRouteImport } from './routes/security/users/create'; import { Route as SecurityRolesCreateRouteImport } from './routes/security/roles/create'; -import { Route as SecurityAclsCreateRouteImport } from './routes/security/acls/create'; import { Route as SecretsIdEditRouteImport } from './routes/secrets/$id/edit'; import { Route as RpConnectSecretsCreateRouteImport } from './routes/rp-connect/secrets/create'; import { Route as RpConnectPipelineIdEditRouteImport } from './routes/rp-connect/$pipelineId/edit'; @@ -74,7 +72,6 @@ import { Route as SecurityUsersUserNameDetailsRouteImport } from './routes/secur import { Route as SecurityRolesRoleNameUpdateRouteImport } from './routes/security/roles/$roleName/update'; import { Route as SecurityRolesRoleNameEditRouteImport } from './routes/security/roles/$roleName/edit'; import { Route as SecurityRolesRoleNameDetailsRouteImport } from './routes/security/roles/$roleName/details'; -import { Route as SecurityAclsAclNameUpdateRouteImport } from './routes/security/acls/$aclName/update'; import { Route as SecurityAclsAclNameDetailsRouteImport } from './routes/security/acls/$aclName/details'; import { Route as SchemaRegistrySubjectsSubjectNameEditModeRouteImport } from './routes/schema-registry/subjects/$subjectName/edit-mode'; import { Route as SchemaRegistrySubjectsSubjectNameEditCompatibilityRouteImport } from './routes/schema-registry/subjects/$subjectName/edit-compatibility'; @@ -303,11 +300,6 @@ const SecurityPermissionsListIndexRoute = path: '/permissions-list/', getParentRoute: () => SecurityRoute, } as any); -const SecurityAclsIndexRoute = SecurityAclsIndexRouteImport.update({ - id: '/acls/', - path: '/acls/', - getParentRoute: () => SecurityRoute, -} as any); const RpConnectPipelineIdIndexRoute = RpConnectPipelineIdIndexRouteImport.update({ id: '/rp-connect/$pipelineId/', @@ -352,11 +344,6 @@ const SecurityRolesCreateRoute = SecurityRolesCreateRouteImport.update({ path: '/roles/create', getParentRoute: () => SecurityRoute, } as any); -const SecurityAclsCreateRoute = SecurityAclsCreateRouteImport.update({ - id: '/acls/create', - path: '/acls/create', - getParentRoute: () => SecurityRoute, -} as any); const SecretsIdEditRoute = SecretsIdEditRouteImport.update({ id: '/secrets/$id/edit', path: '/secrets/$id/edit', @@ -425,12 +412,6 @@ const SecurityRolesRoleNameDetailsRoute = path: '/roles/$roleName/details', getParentRoute: () => SecurityRoute, } as any); -const SecurityAclsAclNameUpdateRoute = - SecurityAclsAclNameUpdateRouteImport.update({ - id: '/acls/$aclName/update', - path: '/acls/$aclName/update', - getParentRoute: () => SecurityRoute, - } as any); const SecurityAclsAclNameDetailsRoute = SecurityAclsAclNameDetailsRouteImport.update({ id: '/acls/$aclName/details', @@ -538,7 +519,6 @@ export interface FileRoutesByFullPath { '/rp-connect/$pipelineId/edit': typeof RpConnectPipelineIdEditRoute; '/rp-connect/secrets/create': typeof RpConnectSecretsCreateRoute; '/secrets/$id/edit': typeof SecretsIdEditRoute; - '/security/acls/create': typeof SecurityAclsCreateRoute; '/security/roles/create': typeof SecurityRolesCreateRoute; '/security/users/create': typeof SecurityUsersCreateRoute; '/shadowlinks/$name/edit': typeof ShadowlinksNameEditRoute; @@ -547,7 +527,6 @@ export interface FileRoutesByFullPath { '/connect-clusters/$clusterName/': typeof ConnectClustersClusterNameIndexRoute; '/knowledgebases/$knowledgebaseId/': typeof KnowledgebasesKnowledgebaseIdIndexRoute; '/rp-connect/$pipelineId/': typeof RpConnectPipelineIdIndexRoute; - '/security/acls/': typeof SecurityAclsIndexRoute; '/security/permissions-list/': typeof SecurityPermissionsListIndexRoute; '/security/roles/': typeof SecurityRolesIndexRoute; '/security/users/': typeof SecurityUsersIndexRoute; @@ -563,7 +542,6 @@ export interface FileRoutesByFullPath { '/schema-registry/subjects/$subjectName/edit-compatibility': typeof SchemaRegistrySubjectsSubjectNameEditCompatibilityRoute; '/schema-registry/subjects/$subjectName/edit-mode': typeof SchemaRegistrySubjectsSubjectNameEditModeRoute; '/security/acls/$aclName/details': typeof SecurityAclsAclNameDetailsRoute; - '/security/acls/$aclName/update': typeof SecurityAclsAclNameUpdateRoute; '/security/roles/$roleName/details': typeof SecurityRolesRoleNameDetailsRoute; '/security/roles/$roleName/edit': typeof SecurityRolesRoleNameEditRoute; '/security/roles/$roleName/update': typeof SecurityRolesRoleNameUpdateRoute; @@ -615,7 +593,6 @@ export interface FileRoutesByTo { '/rp-connect/$pipelineId/edit': typeof RpConnectPipelineIdEditRoute; '/rp-connect/secrets/create': typeof RpConnectSecretsCreateRoute; '/secrets/$id/edit': typeof SecretsIdEditRoute; - '/security/acls/create': typeof SecurityAclsCreateRoute; '/security/roles/create': typeof SecurityRolesCreateRoute; '/security/users/create': typeof SecurityUsersCreateRoute; '/shadowlinks/$name/edit': typeof ShadowlinksNameEditRoute; @@ -624,7 +601,6 @@ export interface FileRoutesByTo { '/connect-clusters/$clusterName': typeof ConnectClustersClusterNameIndexRoute; '/knowledgebases/$knowledgebaseId': typeof KnowledgebasesKnowledgebaseIdIndexRoute; '/rp-connect/$pipelineId': typeof RpConnectPipelineIdIndexRoute; - '/security/acls': typeof SecurityAclsIndexRoute; '/security/permissions-list': typeof SecurityPermissionsListIndexRoute; '/security/roles': typeof SecurityRolesIndexRoute; '/security/users': typeof SecurityUsersIndexRoute; @@ -640,7 +616,6 @@ export interface FileRoutesByTo { '/schema-registry/subjects/$subjectName/edit-compatibility': typeof SchemaRegistrySubjectsSubjectNameEditCompatibilityRoute; '/schema-registry/subjects/$subjectName/edit-mode': typeof SchemaRegistrySubjectsSubjectNameEditModeRoute; '/security/acls/$aclName/details': typeof SecurityAclsAclNameDetailsRoute; - '/security/acls/$aclName/update': typeof SecurityAclsAclNameUpdateRoute; '/security/roles/$roleName/details': typeof SecurityRolesRoleNameDetailsRoute; '/security/roles/$roleName/edit': typeof SecurityRolesRoleNameEditRoute; '/security/roles/$roleName/update': typeof SecurityRolesRoleNameUpdateRoute; @@ -694,7 +669,6 @@ export interface FileRoutesById { '/rp-connect/$pipelineId/edit': typeof RpConnectPipelineIdEditRoute; '/rp-connect/secrets/create': typeof RpConnectSecretsCreateRoute; '/secrets/$id/edit': typeof SecretsIdEditRoute; - '/security/acls/create': typeof SecurityAclsCreateRoute; '/security/roles/create': typeof SecurityRolesCreateRoute; '/security/users/create': typeof SecurityUsersCreateRoute; '/shadowlinks/$name/edit': typeof ShadowlinksNameEditRoute; @@ -703,7 +677,6 @@ export interface FileRoutesById { '/connect-clusters/$clusterName/': typeof ConnectClustersClusterNameIndexRoute; '/knowledgebases/$knowledgebaseId/': typeof KnowledgebasesKnowledgebaseIdIndexRoute; '/rp-connect/$pipelineId/': typeof RpConnectPipelineIdIndexRoute; - '/security/acls/': typeof SecurityAclsIndexRoute; '/security/permissions-list/': typeof SecurityPermissionsListIndexRoute; '/security/roles/': typeof SecurityRolesIndexRoute; '/security/users/': typeof SecurityUsersIndexRoute; @@ -719,7 +692,6 @@ export interface FileRoutesById { '/schema-registry/subjects/$subjectName/edit-compatibility': typeof SchemaRegistrySubjectsSubjectNameEditCompatibilityRoute; '/schema-registry/subjects/$subjectName/edit-mode': typeof SchemaRegistrySubjectsSubjectNameEditModeRoute; '/security/acls/$aclName/details': typeof SecurityAclsAclNameDetailsRoute; - '/security/acls/$aclName/update': typeof SecurityAclsAclNameUpdateRoute; '/security/roles/$roleName/details': typeof SecurityRolesRoleNameDetailsRoute; '/security/roles/$roleName/edit': typeof SecurityRolesRoleNameEditRoute; '/security/roles/$roleName/update': typeof SecurityRolesRoleNameUpdateRoute; @@ -774,7 +746,6 @@ export interface FileRouteTypes { | '/rp-connect/$pipelineId/edit' | '/rp-connect/secrets/create' | '/secrets/$id/edit' - | '/security/acls/create' | '/security/roles/create' | '/security/users/create' | '/shadowlinks/$name/edit' @@ -783,7 +754,6 @@ export interface FileRouteTypes { | '/connect-clusters/$clusterName/' | '/knowledgebases/$knowledgebaseId/' | '/rp-connect/$pipelineId/' - | '/security/acls/' | '/security/permissions-list/' | '/security/roles/' | '/security/users/' @@ -799,7 +769,6 @@ export interface FileRouteTypes { | '/schema-registry/subjects/$subjectName/edit-compatibility' | '/schema-registry/subjects/$subjectName/edit-mode' | '/security/acls/$aclName/details' - | '/security/acls/$aclName/update' | '/security/roles/$roleName/details' | '/security/roles/$roleName/edit' | '/security/roles/$roleName/update' @@ -851,7 +820,6 @@ export interface FileRouteTypes { | '/rp-connect/$pipelineId/edit' | '/rp-connect/secrets/create' | '/secrets/$id/edit' - | '/security/acls/create' | '/security/roles/create' | '/security/users/create' | '/shadowlinks/$name/edit' @@ -860,7 +828,6 @@ export interface FileRouteTypes { | '/connect-clusters/$clusterName' | '/knowledgebases/$knowledgebaseId' | '/rp-connect/$pipelineId' - | '/security/acls' | '/security/permissions-list' | '/security/roles' | '/security/users' @@ -876,7 +843,6 @@ export interface FileRouteTypes { | '/schema-registry/subjects/$subjectName/edit-compatibility' | '/schema-registry/subjects/$subjectName/edit-mode' | '/security/acls/$aclName/details' - | '/security/acls/$aclName/update' | '/security/roles/$roleName/details' | '/security/roles/$roleName/edit' | '/security/roles/$roleName/update' @@ -929,7 +895,6 @@ export interface FileRouteTypes { | '/rp-connect/$pipelineId/edit' | '/rp-connect/secrets/create' | '/secrets/$id/edit' - | '/security/acls/create' | '/security/roles/create' | '/security/users/create' | '/shadowlinks/$name/edit' @@ -938,7 +903,6 @@ export interface FileRouteTypes { | '/connect-clusters/$clusterName/' | '/knowledgebases/$knowledgebaseId/' | '/rp-connect/$pipelineId/' - | '/security/acls/' | '/security/permissions-list/' | '/security/roles/' | '/security/users/' @@ -954,7 +918,6 @@ export interface FileRouteTypes { | '/schema-registry/subjects/$subjectName/edit-compatibility' | '/schema-registry/subjects/$subjectName/edit-mode' | '/security/acls/$aclName/details' - | '/security/acls/$aclName/update' | '/security/roles/$roleName/details' | '/security/roles/$roleName/edit' | '/security/roles/$roleName/update' @@ -1330,13 +1293,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SecurityPermissionsListIndexRouteImport; parentRoute: typeof SecurityRoute; }; - '/security/acls/': { - id: '/security/acls/'; - path: '/acls'; - fullPath: '/security/acls/'; - preLoaderRoute: typeof SecurityAclsIndexRouteImport; - parentRoute: typeof SecurityRoute; - }; '/rp-connect/$pipelineId/': { id: '/rp-connect/$pipelineId/'; path: '/rp-connect/$pipelineId'; @@ -1393,13 +1349,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SecurityRolesCreateRouteImport; parentRoute: typeof SecurityRoute; }; - '/security/acls/create': { - id: '/security/acls/create'; - path: '/acls/create'; - fullPath: '/security/acls/create'; - preLoaderRoute: typeof SecurityAclsCreateRouteImport; - parentRoute: typeof SecurityRoute; - }; '/secrets/$id/edit': { id: '/secrets/$id/edit'; path: '/secrets/$id/edit'; @@ -1484,13 +1433,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SecurityRolesRoleNameDetailsRouteImport; parentRoute: typeof SecurityRoute; }; - '/security/acls/$aclName/update': { - id: '/security/acls/$aclName/update'; - path: '/acls/$aclName/update'; - fullPath: '/security/acls/$aclName/update'; - preLoaderRoute: typeof SecurityAclsAclNameUpdateRouteImport; - parentRoute: typeof SecurityRoute; - }; '/security/acls/$aclName/details': { id: '/security/acls/$aclName/details'; path: '/acls/$aclName/details'; @@ -1566,15 +1508,12 @@ declare module '@tanstack/react-router' { interface SecurityRouteChildren { SecurityIndexRoute: typeof SecurityIndexRoute; - SecurityAclsCreateRoute: typeof SecurityAclsCreateRoute; SecurityRolesCreateRoute: typeof SecurityRolesCreateRoute; SecurityUsersCreateRoute: typeof SecurityUsersCreateRoute; - SecurityAclsIndexRoute: typeof SecurityAclsIndexRoute; SecurityPermissionsListIndexRoute: typeof SecurityPermissionsListIndexRoute; SecurityRolesIndexRoute: typeof SecurityRolesIndexRoute; SecurityUsersIndexRoute: typeof SecurityUsersIndexRoute; SecurityAclsAclNameDetailsRoute: typeof SecurityAclsAclNameDetailsRoute; - SecurityAclsAclNameUpdateRoute: typeof SecurityAclsAclNameUpdateRoute; SecurityRolesRoleNameDetailsRoute: typeof SecurityRolesRoleNameDetailsRoute; SecurityRolesRoleNameEditRoute: typeof SecurityRolesRoleNameEditRoute; SecurityRolesRoleNameUpdateRoute: typeof SecurityRolesRoleNameUpdateRoute; @@ -1583,15 +1522,12 @@ interface SecurityRouteChildren { const SecurityRouteChildren: SecurityRouteChildren = { SecurityIndexRoute: SecurityIndexRoute, - SecurityAclsCreateRoute: SecurityAclsCreateRoute, SecurityRolesCreateRoute: SecurityRolesCreateRoute, SecurityUsersCreateRoute: SecurityUsersCreateRoute, - SecurityAclsIndexRoute: SecurityAclsIndexRoute, SecurityPermissionsListIndexRoute: SecurityPermissionsListIndexRoute, SecurityRolesIndexRoute: SecurityRolesIndexRoute, SecurityUsersIndexRoute: SecurityUsersIndexRoute, SecurityAclsAclNameDetailsRoute: SecurityAclsAclNameDetailsRoute, - SecurityAclsAclNameUpdateRoute: SecurityAclsAclNameUpdateRoute, SecurityRolesRoleNameDetailsRoute: SecurityRolesRoleNameDetailsRoute, SecurityRolesRoleNameEditRoute: SecurityRolesRoleNameEditRoute, SecurityRolesRoleNameUpdateRoute: SecurityRolesRoleNameUpdateRoute, diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index b3e153923b..dc76c6333a 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -69,7 +69,7 @@ function SelfHostedLayout() { -
+
@@ -94,7 +94,9 @@ function AppContent() { - +
+ +
diff --git a/frontend/src/routes/security.tsx b/frontend/src/routes/security.tsx index 2f89b3157d..084861614b 100644 --- a/frontend/src/routes/security.tsx +++ b/frontend/src/routes/security.tsx @@ -9,96 +9,29 @@ * by the Apache License, Version 2.0 */ -import { createFileRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router'; +import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'; import { ShieldCheckIcon } from 'components/icons'; -import { ListLayoutNavigation } from 'components/redpanda-ui/components/list-layout'; -import { isServerless } from 'config'; import { useEffect } from 'react'; import { Alert, AlertDescription } from '../components/redpanda-ui/components/alert'; -import { Tabs, TabsList, TabsTrigger } from '../components/redpanda-ui/components/tabs'; import { appGlobal } from '../state/app-global'; import { api, rolesApi, useApiStoreHook } from '../state/backend-api'; -import { useSupportedFeaturesStore } from '../state/supported-features'; export const Route = createFileRoute('/security')({ staticData: { title: 'Security', icon: ShieldCheckIcon, }, + beforeLoad: ({ location }) => { + if (location.pathname === '/security' || location.pathname === '/security/') { + throw redirect({ to: '/security/users' }); + } + }, component: SecurityLayout, }); -type TabConfig = { - key: string; - label: string; - path: string; - disabled: boolean; -}; - -function buildTabs( - isAdminApiConfigured: boolean, - featureCreateUser: boolean, - featureRolesApi: boolean, - userData: { canManageUsers?: boolean; canListAcls?: boolean; canViewPermissionsList?: boolean } | null | undefined -): TabConfig[] { - const result: TabConfig[] = [ - { - key: 'users', - label: 'Users', - path: '/security/users', - disabled: - !(isAdminApiConfigured && featureCreateUser) || - (userData?.canManageUsers !== undefined && userData?.canManageUsers === false), - }, - ]; - - if (!isServerless()) { - result.push({ - key: 'roles', - label: 'Roles', - path: '/security/roles', - disabled: !featureRolesApi || userData?.canManageUsers === false, - }); - } - - result.push( - { - key: 'acls', - label: 'ACLs', - path: '/security/acls', - disabled: userData?.canListAcls === false, - }, - { - key: 'permissions-list', - label: 'Permissions', - path: '/security/permissions-list', - disabled: userData?.canViewPermissionsList === false, - } - ); - - return result; -} - -function deriveActiveTab(pathname: string, tabs: TabConfig[]): string { - for (const tab of tabs) { - if (pathname === tab.path || pathname.startsWith(`${tab.path}/`)) { - return tab.key; - } - } - return 'acls'; -} - function SecurityLayout() { - const location = useLocation(); - const navigate = useNavigate(); const acls = useApiStoreHook((s) => s.ACLs); - const userData = useApiStoreHook((s) => s.userData); - const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); - const featureCreateUser = useSupportedFeaturesStore((s) => s.createUser); - - const redpandaOverview = useApiStoreHook((s) => s.clusterOverview?.redpanda); - const isAdminApiConfigured = Boolean(redpandaOverview); useEffect(() => { const refreshData = async () => { @@ -115,9 +48,6 @@ function SecurityLayout() { }); }, []); - const tabs = buildTabs(isAdminApiConfigured, featureCreateUser, featureRolesApi, userData); - const activeTab = deriveActiveTab(location.pathname, tabs); - const warning = acls === null ? ( @@ -132,36 +62,10 @@ function SecurityLayout() { ) : null; - const handleTabClick = (tabKey: string) => { - const tab = tabs.find((t) => t.key === tabKey); - if (tab && !tab.disabled) { - navigate({ to: tab.path }); - } - }; - return ( <> {warning} {noAclAuthorizer} - - - - - {tabs.map((tab) => ( - handleTabClick(tab.key)} - value={tab.key} - variant="underline" - > - {tab.label} - - ))} - - - ); diff --git a/frontend/src/routes/security/acls/$aclName/update.tsx b/frontend/src/routes/security/acls/$aclName/update.tsx deleted file mode 100644 index a78646b3b9..0000000000 --- a/frontend/src/routes/security/acls/$aclName/update.tsx +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Copyright 2026 Redpanda Data, Inc. - * - * Use of this software is governed by the Business Source License - * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md - * - * As of the Change Date specified in that file, in accordance with - * the Business Source License, use of this software will be governed - * by the Apache License, Version 2.0 - */ - -import { createFileRoute } from '@tanstack/react-router'; -import { fallback, zodValidator } from '@tanstack/zod-adapter'; -import { z } from 'zod'; - -import AclUpdatePage from '../../../../components/pages/security/acls/acl-update-page'; - -const searchSchema = z.object({ - host: fallback(z.string().optional(), undefined), -}); - -export const Route = createFileRoute('/security/acls/$aclName/update')({ - staticData: { - title: 'Update ACL', - }, - validateSearch: zodValidator(searchSchema), - component: AclUpdatePage, -}); diff --git a/frontend/src/routes/security/acls/create.tsx b/frontend/src/routes/security/acls/create.tsx deleted file mode 100644 index 8ccd3776be..0000000000 --- a/frontend/src/routes/security/acls/create.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Copyright 2026 Redpanda Data, Inc. - * - * Use of this software is governed by the Business Source License - * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md - * - * As of the Change Date specified in that file, in accordance with - * the Business Source License, use of this software will be governed - * by the Apache License, Version 2.0 - */ - -import { createFileRoute } from '@tanstack/react-router'; -import { fallback, zodValidator } from '@tanstack/zod-adapter'; -import { z } from 'zod'; - -import AclCreatePage from '../../../components/pages/security/acls/acl-create-page'; - -const searchSchema = z.object({ - principalType: fallback(z.string().optional(), undefined), - principalName: fallback(z.string().optional(), undefined), -}); - -export const Route = createFileRoute('/security/acls/create')({ - staticData: { - title: 'Create ACL', - }, - validateSearch: zodValidator(searchSchema), - component: AclCreatePage, -}); diff --git a/frontend/src/routes/security/acls/index.tsx b/frontend/src/routes/security/acls/index.tsx deleted file mode 100644 index c00192588b..0000000000 --- a/frontend/src/routes/security/acls/index.tsx +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Copyright 2026 Redpanda Data, Inc. - * - * Use of this software is governed by the Business Source License - * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md - * - * As of the Change Date specified in that file, in accordance with - * the Business Source License, use of this software will be governed - * by the Apache License, Version 2.0 - */ - -import { createFileRoute } from '@tanstack/react-router'; - -import { AclsTab } from '../../../components/pages/security/tabs/acls-tab'; - -export const Route = createFileRoute('/security/acls/')({ - staticData: { - title: 'Security', - }, - component: AclsTab, -}); diff --git a/frontend/src/routes/security/index.tsx b/frontend/src/routes/security/index.tsx index 5e527c1502..a4046a964c 100644 --- a/frontend/src/routes/security/index.tsx +++ b/frontend/src/routes/security/index.tsx @@ -13,12 +13,8 @@ import { createFileRoute, redirect } from '@tanstack/react-router'; export const Route = createFileRoute('/security/')({ beforeLoad: () => { - // Redirect /security/ to /security/acls at router level. - // This prevents the component-level useEffect redirect which can cause - // navigation loops in embedded mode where shell and console routers conflict. - // ACLs tab is always available regardless of admin API or serverless mode. throw redirect({ - to: '/security/acls', + to: '/security/users', replace: true, }); }, diff --git a/frontend/src/state/ui-state.ts b/frontend/src/state/ui-state.ts index 5dabaebbf9..418c80d2df 100644 --- a/frontend/src/state/ui-state.ts +++ b/frontend/src/state/ui-state.ts @@ -36,10 +36,16 @@ export type ServerVersionInfo = { branchBusiness?: string; }; +export type BackLink = { + title: string; + linkTo: string; +}; + type UIStateStore = { // Core state _pageTitle: string | React.ReactElement; pageBreadcrumbs: BreadcrumbEntry[]; + backLink: BackLink | null; shouldHidePageHeader: boolean; pathName: string; _currentTopicName: string | undefined; @@ -56,6 +62,12 @@ type UIStateStore = { // Actions (setters) setPageTitle: (title: string | React.ReactElement) => void; setPageBreadcrumbs: (breadcrumbs: BreadcrumbEntry[]) => void; + setPageState: ( + title: string | React.ReactElement, + breadcrumbs: BreadcrumbEntry[], + backLink?: BackLink | null + ) => void; + setBackLink: (backLink: BackLink | null) => void; setShouldHidePageHeader: (hide: boolean) => void; setPathName: (path: string) => void; setCurrentTopicName: (topicName: string | undefined) => void; @@ -68,6 +80,7 @@ export const useUIStateStore = create((set, get) => ({ // Initial state _pageTitle: ' ', pageBreadcrumbs: [], + backLink: null, shouldHidePageHeader: false, pathName: '', _currentTopicName: undefined, @@ -132,6 +145,19 @@ export const useUIStateStore = create((set, get) => ({ set({ pageBreadcrumbs: breadcrumbs }); }, + setPageState: (title: string | React.ReactElement, breadcrumbs: BreadcrumbEntry[], backLink?: BackLink | null) => { + if (typeof title === 'string') { + document.title = `${title} - Redpanda Console`; + } else { + document.title = 'Redpanda Console'; + } + set({ _pageTitle: title, pageBreadcrumbs: breadcrumbs, backLink: backLink ?? null }); + }, + + setBackLink: (backLink: BackLink | null) => { + set({ backLink }); + }, + setShouldHidePageHeader: (hide: boolean) => { set({ shouldHidePageHeader: hide }); }, @@ -174,6 +200,7 @@ export const uiState = new Proxy( {} as { pageTitle: string | React.ReactElement; pageBreadcrumbs: BreadcrumbEntry[]; + backLink: BackLink | null; shouldHidePageHeader: boolean; selectedClusterName: string | null; pathName: string; @@ -232,8 +259,20 @@ export const uiState = new Proxy( store.setServerBuildTimestamp(value as number | undefined); return true; } + if (prop === 'backLink') { + store.setBackLink(value as BackLink | null); + return true; + } return true; }, } ); + +export function setPageHeader( + title: string | React.ReactElement, + breadcrumbs: BreadcrumbEntry[], + backLink?: BackLink | null +) { + useUIStateStore.getState().setPageState(title, breadcrumbs, backLink); +} From 5b6df259200e7ba94c700a2a2aae4036dd272c7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Tue, 28 Apr 2026 21:11:25 +0200 Subject: [PATCH 3/7] Tables consistency --- .../pages/security/roles/role-detail-page.tsx | 48 +++--- .../pages/security/roles/role-update-page.tsx | 139 ------------------ .../pages/security/shared/acls-card.tsx | 98 ++++++------ .../shared/delete-role-confirm-modal.tsx | 16 +- .../security/tabs/permissions-list-tab.tsx | 99 ++++++------- .../pages/security/tabs/roles-tab.tsx | 92 +++++++----- .../pages/security/tabs/users-tab.tsx | 17 +-- .../pages/security/users/user-create.tsx | 6 +- .../security/roles/$roleName/update.tsx | 20 +-- 9 files changed, 209 insertions(+), 326 deletions(-) delete mode 100644 frontend/src/components/pages/security/roles/role-update-page.tsx 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 4b7941828a..b4ecd958cb 100644 --- a/frontend/src/components/pages/security/roles/role-detail-page.tsx +++ b/frontend/src/components/pages/security/roles/role-detail-page.tsx @@ -27,6 +27,7 @@ import { setPageHeader } from '../../../../state/ui-state'; import { Button } from '../../../redpanda-ui/components/button'; import { Combobox } from '../../../redpanda-ui/components/combobox'; import { ListLayout, ListLayoutContent, ListLayoutFilters } from '../../../redpanda-ui/components/list-layout'; +import { Table, TableBody, TableCell, TableRow } from '../../../redpanda-ui/components/table'; import { parsePrincipal } from '../shared/acl-model'; import { AclsCard } from '../shared/acls-card'; @@ -132,29 +133,30 @@ const RoleDetailPage = () => { ) : allMembers.length === 0 ? (

No principals assigned to this role.

) : ( -
- {allMembers.map((member) => { - const parsed = parsePrincipal(member.principal); - const displayName = parsed.name || member.principal; - return ( -
- {displayName} - -
- ); - })} -
+ + + {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-update-page.tsx b/frontend/src/components/pages/security/roles/role-update-page.tsx deleted file mode 100644 index cd139a2797..0000000000 --- a/frontend/src/components/pages/security/roles/role-update-page.tsx +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Copyright 2025 Redpanda Data, Inc. - * - * Use of this software is governed by the Business Source License - * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md - * - * As of the Change Date specified in that file, in accordance with - * the Business Source License, use of this software will be governed - * by the Apache License, Version 2.0 - */ - -import { getRouteApi, useNavigate } from '@tanstack/react-router'; - -const routeApi = getRouteApi('/security/roles/$roleName/update'); - -import { useLayoutEffect } from 'react'; -import { toast } from 'sonner'; - -import { useGetAclsByPrincipal, useUpdateAclMutation } from '../../../../react-query/api/acl'; -import { setPageHeader } from '../../../../state/ui-state'; -import CreateACL from '../acls/create-acl'; -import { HostSelector } from '../acls/host-selector'; -import { LockedPrincipalField } from '../acls/locked-principal-field'; -import { - getOperationsForResourceType, - handleResponses, - ModeAllowAll, - ModeDenyAll, - OperationTypeAllow, - OperationTypeDeny, - PrincipalTypeRedpandaRole, - type Rule, - type SharedConfig, -} from '../shared/acl-model'; - -const RoleUpdatePage = () => { - const navigate = useNavigate({ from: '/security/roles/$roleName/update' }); - const { roleName } = routeApi.useParams(); - const search = routeApi.useSearch(); - const host = search.host ?? undefined; - - useLayoutEffect(() => { - setPageHeader(roleName, [ - { title: 'Security', linkTo: '/security/users' }, - { title: 'Roles', linkTo: '/security/roles' }, - { title: roleName, linkTo: `/security/roles/${roleName}/details` }, - ]); - }, [roleName]); - - const { applyUpdates } = useUpdateAclMutation(); - - // Fetch existing ACL data for the role - const { data, isLoading } = useGetAclsByPrincipal(`RedpandaRole:${roleName}`, host); - - const updateRoleAclMutation = - (actualRules: Rule[], sharedConfig: SharedConfig) => async (_: string, _2: string, rules: Rule[]) => { - try { - const applyResult = await applyUpdates(actualRules, sharedConfig, rules); - handleResponses(applyResult.errors, applyResult.created); - - navigate({ - to: `/security/roles/${roleName}/details`, - search: { host }, - }); - } catch (error) { - toast.error(`Failed to update role ACLs: ${error instanceof Error ? error.message : String(error)}`); - } - }; - - if (isLoading) { - return ( -
-
-
Loading role configuration...
-
-
- ); - } - - // If multiple hosts exist and no host is selected, show host selector - if (data && data.length > 1 && !host) { - return ( -
- -
- ); - } - - const acl = data && data.length > 0 ? (host ? data.find((d) => d.sharedConfig.host === host) : data[0]) : null; - - const emptySharedConfig = { principal: `${PrincipalTypeRedpandaRole}${roleName}`, host: host ?? '*' }; - - // Ensure all operations are present for each rule - const rulesWithAllOperations = (acl?.rules ?? []).map((rule) => { - const allOperations = getOperationsForResourceType(rule.resourceType); - let mergedOperations = { ...allOperations }; - - // If mode is AllowAll or DenyAll, set all operations accordingly - if (rule.mode === ModeAllowAll) { - mergedOperations = Object.fromEntries(Object.keys(allOperations).map((op) => [op, OperationTypeAllow])); - } else if (rule.mode === ModeDenyAll) { - mergedOperations = Object.fromEntries(Object.keys(allOperations).map((op) => [op, OperationTypeDeny])); - } else { - // For custom mode, override with the actual values from the fetched rule - for (const [op, value] of Object.entries(rule.operations)) { - if (op in mergedOperations) { - mergedOperations[op] = value; - } - } - } - - return { - ...rule, - operations: mergedOperations, - }; - }); - - return ( -
-

Update role: {roleName}

- - navigate({ - to: `/security/roles/${roleName}/details`, - search: { host }, - }) - } - onSubmit={updateRoleAclMutation(acl?.rules ?? [], acl?.sharedConfig ?? emptySharedConfig)} - principalType={PrincipalTypeRedpandaRole} - renderPrincipal={(props) => } - rules={rulesWithAllOperations.length > 0 ? rulesWithAllOperations : undefined} - sharedConfig={acl?.sharedConfig ?? emptySharedConfig} - /> -
- ); -}; - -export default RoleUpdatePage; diff --git a/frontend/src/components/pages/security/shared/acls-card.tsx b/frontend/src/components/pages/security/shared/acls-card.tsx index 5b5509044f..b6481dd48a 100644 --- a/frontend/src/components/pages/security/shared/acls-card.tsx +++ b/frontend/src/components/pages/security/shared/acls-card.tsx @@ -41,6 +41,7 @@ import { DialogTitle, } from '../../../redpanda-ui/components/dialog'; import { ListLayout, ListLayoutContent, ListLayoutFilters } from '../../../redpanda-ui/components/list-layout'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../../redpanda-ui/components/table'; import { AddAclDialog } from '../users/add-acl-dialog'; const RESOURCE_TYPE_LABELS: Record = { @@ -206,34 +207,39 @@ export const AclsCard = ({ acls, principal }: AclsCardProps) => { {rows.length === 0 ? (

No ACLs assigned.

) : ( -
-
- - Type - Resource - Operation - Permission - Host -
- {rows.map((row) => ( -
- toggleRow(row.id)} /> - {row.resourceType} - {row.resourceName} - {row.operation} - - {row.permissionType} - - {row.host} -
- ))} -
+ + + + + + + Type + Resource + Operation + Permission + Host + + + + {rows.map((row) => ( + + + toggleRow(row.id)} /> + + {row.resourceType} + {row.resourceName} + {row.operation} + + {row.permissionType} + + {row.host} + + ))} + +
)} @@ -249,22 +255,26 @@ export const AclsCard = ({ acls, principal }: AclsCardProps) => { -
-
- Resource Type - Resource Name - Operation - Permission -
- {GRANT_ALL_RESOURCES.map((r) => ( -
- {r.label} - {r.name} - All - Allow -
- ))} -
+ + + + Resource Type + Resource Name + Operation + Permission + + + + {GRANT_ALL_RESOURCES.map((r) => ( + + {r.label} + {r.name} + All + Allow + + ))} + +
@@ -229,14 +202,40 @@ const PrincipalRow: FC = ({ group, isExpanded, onToggle, onDe {/* Expanded content */} {isExpanded && hasAcls && (
- - {group.directAcls.map((acl, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: no stable key - - ))} - {group.roleAclGroups.map((rg) => ( - - ))} + + + + 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 + + ))} +
+ ))} +
)} diff --git a/frontend/src/components/pages/security/tabs/roles-tab.tsx b/frontend/src/components/pages/security/tabs/roles-tab.tsx index 38c9d867eb..61de162c4b 100644 --- a/frontend/src/components/pages/security/tabs/roles-tab.tsx +++ b/frontend/src/components/pages/security/tabs/roles-tab.tsx @@ -10,7 +10,7 @@ */ import { create } from '@bufbuild/protobuf'; -import { Link, useNavigate } from '@tanstack/react-router'; +import { Link } from '@tanstack/react-router'; import { type ColumnDef, type ColumnFiltersState, @@ -25,8 +25,15 @@ import { type Updater, useReactTable, } from '@tanstack/react-table'; -import { EditIcon, TrashIcon } from 'components/icons'; +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 { ListLayout, ListLayoutContent, @@ -50,7 +57,6 @@ 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 { DeleteRoleConfirmModal } from '../shared/delete-role-confirm-modal'; import { DescriptionWithHelp } from '../shared/description-with-help'; import { SecurityTabsNav } from '../shared/security-tabs-nav'; @@ -75,7 +81,6 @@ export const RolesTab: FC = () => { { title: 'Roles', linkTo: '/security/roles' }, ]); }, []); - const navigate = useNavigate(); const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); const userData = useApiStoreHook((s) => s.userData); const [createDialogOpen, setCreateDialogOpen] = useState(false); @@ -154,38 +159,17 @@ export const RolesTab: FC = () => { enableSorting: false, meta: { align: 'right' as const }, cell: ({ row: { original: entry } }) => ( -
- - - - - } - numberOfPrincipals={entry.members.length} - onConfirm={async () => { - await deleteRoleMutation(create(DeleteRoleRequestSchema, { roleName: entry.name, deleteAcls: true })); - }} - roleName={entry.name} - /> -
+ { + await deleteRoleMutation(create(DeleteRoleRequestSchema, { roleName: entry.name, deleteAcls: true })); + }} + roleName={entry.name} + /> ), }, ], - [navigate, deleteRoleMutation] + [deleteRoleMutation] ); const table = useReactTable({ @@ -299,3 +283,45 @@ export const RolesTab: FC = () => { ); }; + +const RoleActions = ({ + roleName, + memberCount, + onDelete, +}: { + roleName: string; + memberCount: number; + onDelete: () => Promise; +}) => { + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + + return ( + <> + + + + + + + { + e.stopPropagation(); + setIsDeleteOpen(true); + }} + > + Delete + + + + + ); +}; diff --git a/frontend/src/components/pages/security/tabs/users-tab.tsx b/frontend/src/components/pages/security/tabs/users-tab.tsx index b08f607aab..19779d4f2e 100644 --- a/frontend/src/components/pages/security/tabs/users-tab.tsx +++ b/frontend/src/components/pages/security/tabs/users-tab.tsx @@ -68,7 +68,7 @@ import { Tooltip, TooltipTrigger } from '../../../redpanda-ui/components/tooltip import { DeleteUserConfirmModal } from '../shared/delete-user-confirm-modal'; import { SecurityTabsNav } from '../shared/security-tabs-nav'; import { CreateUserDialog } from '../users/user-create-dialog'; -import { ChangePasswordModal, ChangeRolesModal } from '../users/user-edit-modals'; +import { ChangePasswordModal } from '../users/user-edit-modals'; type PrincipalEntry = { name: string; @@ -411,9 +411,7 @@ const UserAclsCell = ({ userName }: { userName: string }) => { }; const UserActions = ({ user }: { user: PrincipalEntry }) => { - const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); const [isChangePasswordModalOpen, setIsChangePasswordModalOpen] = useState(false); - const [isChangeRolesModalOpen, setIsChangeRolesModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const invalidateUsersCache = useInvalidateUsersCache(); const { mutateAsync: deleteUserMutation } = useDeleteUserMutation(); @@ -450,9 +448,6 @@ const UserActions = ({ user }: { user: PrincipalEntry }) => { setIsOpen={setIsChangePasswordModalOpen} userName={user.name} /> - {Boolean(featureRolesApi) && ( - - )} { > Change password - {Boolean(featureRolesApi) && ( - { - e.stopPropagation(); - setIsChangeRolesModalOpen(true); - }} - > - Change roles - - )} { e.stopPropagation(); diff --git a/frontend/src/components/pages/security/users/user-create.tsx b/frontend/src/components/pages/security/users/user-create.tsx index 5bd301b1eb..3dc65263f3 100644 --- a/frontend/src/components/pages/security/users/user-create.tsx +++ b/frontend/src/components/pages/security/users/user-create.tsx @@ -356,13 +356,13 @@ export const CreateUserConfirmationModal = ({
-

What's next?

+

Assign new user permissions

- This user has no permissions yet. Assign roles or create ACLs to grant access to cluster resources. + To grant access to clusters, assign a role to the user or create ACLs.

+ + ) : ( diff --git a/frontend/src/components/pages/security/shared/acls-card.tsx b/frontend/src/components/pages/security/shared/acls-card.tsx index b6481dd48a..e83f44fd9c 100644 --- a/frontend/src/components/pages/security/shared/acls-card.tsx +++ b/frontend/src/components/pages/security/shared/acls-card.tsx @@ -10,6 +10,7 @@ */ import { create } from '@bufbuild/protobuf'; +import { KeyRoundIcon } from 'lucide-react'; import { ACL_Operation, ACL_PermissionType, @@ -40,6 +41,14 @@ import { DialogHeader, DialogTitle, } from '../../../redpanda-ui/components/dialog'; +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 { AddAclDialog } from '../users/add-acl-dialog'; @@ -201,11 +210,34 @@ export const AclsCard = ({ acls, principal }: AclsCardProps) => { } > -

ACLs

+ + ACLs + {rows.length === 0 ? ( -

No ACLs assigned.

+ + + + + + No ACLs assigned + + Add ACLs to define what operations this role can perform on cluster resources. + + + + + + ) : (
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 e4c7fc62d1..a0d60ff758 100644 --- a/frontend/src/components/pages/security/tabs/permissions-list-tab.tsx +++ b/frontend/src/components/pages/security/tabs/permissions-list-tab.tsx @@ -12,8 +12,16 @@ 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, ShieldIcon } from 'lucide-react'; +import { ChevronDown, ChevronRight, ExternalLink, KeyRoundIcon, ShieldIcon } from 'lucide-react'; import { parseAsString, useQueryState } from 'nuqs'; import { ACL_Operation, @@ -88,13 +96,13 @@ const PrincipalRow: FC = ({ group, isExpanded, onToggle, onDe const summaryText = (() => { if (group.directAclCount > 0 && group.inheritedAclCount > 0) { - return `${group.directAclCount} direct ACL${group.directAclCount !== 1 ? 's' : ''}, ${group.inheritedAclCount} ACL${group.inheritedAclCount !== 1 ? 's' : ''} inherited from roles`; + return `${pluralizeWithNumber(group.directAclCount, 'direct ACL')}, ${pluralizeWithNumber(group.inheritedAclCount, 'ACL')} inherited from roles`; } if (group.inheritedAclCount > 0) { - return `${group.inheritedAclCount} ACL${group.inheritedAclCount !== 1 ? 's' : ''} inherited from roles`; + return `${pluralizeWithNumber(group.inheritedAclCount, 'ACL')} inherited from roles`; } if (group.directAclCount > 0) { - return `${group.directAclCount} direct ACL${group.directAclCount !== 1 ? 's' : ''}`; + return pluralizeWithNumber(group.directAclCount, 'direct ACL'); } return 'No ACLs'; })(); @@ -240,7 +248,11 @@ const PrincipalRow: FC = ({ group, isExpanded, onToggle, onDe )} {isExpanded && !hasAcls && ( -
No ACLs assigned.
+ + + No ACLs assigned + + )} @@ -378,9 +390,37 @@ export const PermissionsListTab: FC = () => { {isAclsLoading ? (
Loading...
) : filteredGroups.length === 0 ? ( -
- {searchQuery ? 'No principals match your search.' : 'No principals yet.'} -
+ searchQuery ? ( +
No principals match your search.
+ ) : ( +
+ + + + + + No permissions yet + + A unified view of all principal permissions across your cluster. Create an ACL to get started. + + + +
+ + +
+
+
+
+ ) ) : (
{filteredGroups.map((group) => ( diff --git a/frontend/src/components/pages/security/tabs/roles-tab.tsx b/frontend/src/components/pages/security/tabs/roles-tab.tsx index 61de162c4b..a00493e137 100644 --- a/frontend/src/components/pages/security/tabs/roles-tab.tsx +++ b/frontend/src/components/pages/security/tabs/roles-tab.tsx @@ -34,6 +34,14 @@ import { 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, @@ -41,6 +49,7 @@ import { ListLayoutPagination, ListLayoutSearchInput, } from 'components/redpanda-ui/components/list-layout'; +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'; @@ -264,9 +273,36 @@ export const RolesTab: FC = () => { )) ) : ( - - - No roles yet. + + + + + + + + No roles yet + + Roles are groups of ACLs that can be assigned to principals. Create one to start managing + access control. + + + +
+ + +
+
+
)} diff --git a/frontend/src/components/pages/security/tabs/users-tab.tsx b/frontend/src/components/pages/security/tabs/users-tab.tsx index 19779d4f2e..16d02fc0f6 100644 --- a/frontend/src/components/pages/security/tabs/users-tab.tsx +++ b/frontend/src/components/pages/security/tabs/users-tab.tsx @@ -29,6 +29,14 @@ import { } 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, @@ -36,6 +44,7 @@ import { 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 { useCallback, useLayoutEffect, useMemo, useState } from 'react'; @@ -342,9 +351,41 @@ export const UsersTab: FC = () => {
)) ) : ( - - - No users yet. + + + + + + + + No users yet + + SASL-SCRAM user accounts managed by your cluster. Create one to start managing access. + + + +
+ + +
+
+
)} diff --git a/frontend/tests/test-variant-console/utils/security-page.ts b/frontend/tests/test-variant-console/utils/security-page.ts index dfacb52295..e798f0848c 100644 --- a/frontend/tests/test-variant-console/utils/security-page.ts +++ b/frontend/tests/test-variant-console/utils/security-page.ts @@ -65,6 +65,8 @@ export class SecurityPage { await this.fillUsername(username); await this.submitUserCreation(); await this.page.getByTestId('user-created-successfully').waitFor({ state: 'visible' }); + await this.page.getByTestId('go-to-user-details-button').click(); + await this.page.waitForURL(`**/security/users/${username}/details`); }); } From c09f64896d190a4596321d00fbd9ca828600dfba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Wed, 29 Apr 2026 14:32:51 +0200 Subject: [PATCH 5/7] Adds loading for tables in Security --- frontend/src/components/layout/header.tsx | 2 +- .../pages/security/roles/role-detail-page.tsx | 87 ++--- .../pages/security/shared/acls-card.tsx | 150 +++++---- .../security/tabs/permissions-list-tab.tsx | 134 ++++---- .../pages/security/tabs/roles-tab.tsx | 247 +++++++------- .../pages/security/tabs/users-tab.tsx | 302 +++++++++--------- .../pages/security/users/user-acls-card.tsx | 5 +- .../security/users/user-create-dialog.tsx | 2 +- .../pages/security/users/user-details.tsx | 8 +- .../pages/security/users/user-roles-card.tsx | 149 +++++---- 10 files changed, 601 insertions(+), 485 deletions(-) diff --git a/frontend/src/components/layout/header.tsx b/frontend/src/components/layout/header.tsx index e35e70a742..dd9d538ccf 100644 --- a/frontend/src/components/layout/header.tsx +++ b/frontend/src/components/layout/header.tsx @@ -107,7 +107,7 @@ function AppPageHeader() {
{backLink && ( - + {backLink.title} 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 396d3433e8..c1517a8f6b 100644 --- a/frontend/src/components/pages/security/roles/role-detail-page.tsx +++ b/frontend/src/components/pages/security/roles/role-detail-page.tsx @@ -35,7 +35,7 @@ import { EmptyTitle, } from '../../../redpanda-ui/components/empty'; import { ListLayout, ListLayoutContent, ListLayoutFilters } from '../../../redpanda-ui/components/list-layout'; -import { Table, TableBody, TableCell, TableRow } from '../../../redpanda-ui/components/table'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../../redpanda-ui/components/table'; import { parsePrincipal } from '../shared/acl-model'; import { AclsCard } from '../shared/acls-card'; @@ -57,7 +57,7 @@ const RoleDetailPage = () => { ); }, [roleName]); - const { data: aclData } = useGetAclsByPrincipal(`RedpandaRole:${roleName}`); + const { data: aclData, isLoading: isAclsLoading } = useGetAclsByPrincipal(`RedpandaRole:${roleName}`); const { data: membersData, isLoading: membersLoading } = useListRoleMembersQuery( create(ListRoleMembersRequestSchema, { roleName }) @@ -115,7 +115,7 @@ const RoleDetailPage = () => { return (
- + {/* Principals */} @@ -136,35 +136,50 @@ const RoleDetailPage = () => {

Principals

- {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) => { +
+ + + 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 ( @@ -183,10 +198,10 @@ const RoleDetailPage = () => { ); - })} - -
- )} + }) + )} + +
diff --git a/frontend/src/components/pages/security/shared/acls-card.tsx b/frontend/src/components/pages/security/shared/acls-card.tsx index e83f44fd9c..58599e9286 100644 --- a/frontend/src/components/pages/security/shared/acls-card.tsx +++ b/frontend/src/components/pages/security/shared/acls-card.tsx @@ -10,6 +10,7 @@ */ import { create } from '@bufbuild/protobuf'; +import { Heading } from 'components/redpanda-ui/components/typography'; import { KeyRoundIcon } from 'lucide-react'; import { ACL_Operation, @@ -50,6 +51,7 @@ import { EmptyTitle, } from '../../../redpanda-ui/components/empty'; 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 { AddAclDialog } from '../users/add-acl-dialog'; @@ -88,9 +90,10 @@ type AclRow = { type AclsCardProps = { acls?: AclDetail[]; principal?: string; + isLoading?: boolean; }; -export const AclsCard = ({ acls, principal }: AclsCardProps) => { +export const AclsCard = ({ acls, principal, isLoading }: AclsCardProps) => { const [dialogOpen, setDialogOpen] = useState(false); const [grantAllOpen, setGrantAllOpen] = useState(false); const [selected, setSelected] = useState>(new Set()); @@ -188,6 +191,75 @@ export const AclsCard = ({ acls, principal }: AclsCardProps) => { setGrantAllOpen(false); }; + const renderBody = () => { + if (isLoading) { + return [0, 1, 2].map((i) => ( + + + + + + + + + + + + + + + + + + + )); + } + if (rows.length === 0) { + return ( + + + + + + + + No ACLs assigned + + Add ACLs to define what operations this role can perform on cluster resources. + + + + + + + + + ); + } + return rows.map((row) => ( + + + toggleRow(row.id)} /> + + {row.resourceType} + {row.resourceName} + {row.operation} + + {row.permissionType} + + {row.host} + + )); + }; + return ( <> @@ -215,64 +287,24 @@ export const AclsCard = ({ acls, principal }: AclsCardProps) => { - {rows.length === 0 ? ( - - - - - - No ACLs assigned - - Add ACLs to define what operations this role can perform on cluster resources. - - - - - - - ) : ( - - - - - - - Type - Resource - Operation - Permission - Host - - - - {rows.map((row) => ( - - - toggleRow(row.id)} /> - - {row.resourceType} - {row.resourceName} - {row.operation} - - {row.permissionType} - - {row.host} - - ))} - -
- )} + + + + + + + Type + Resource + Operation + Permission + Host + + + {renderBody()} +
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 a0d60ff758..fd63321637 100644 --- a/frontend/src/components/pages/security/tabs/permissions-list-tab.tsx +++ b/frontend/src/components/pages/security/tabs/permissions-list-tab.tsx @@ -33,6 +33,7 @@ import { 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'; @@ -51,7 +52,9 @@ import { 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'; @@ -75,9 +78,9 @@ const AclTableRow: FC<{ {host} {editHref && ( - + - + )} @@ -359,21 +362,87 @@ export const PermissionsListTab: FC = () => { 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}> { {aclFailed !== null && setAclFailed(null)} />} - - {isAclsLoading ? ( -
Loading...
- ) : filteredGroups.length === 0 ? ( - searchQuery ? ( -
No principals match your search.
- ) : ( -
- - - - - - No permissions yet - - A unified view of all principal permissions across your cluster. Create an ACL to get started. - - - -
- - -
-
-
-
- ) - ) : ( -
- {filteredGroups.map((group) => ( - { - onDelete(group, deleteUser, deleteAcls).catch(() => {}); - }} - onToggle={() => toggleExpanded(group.principal)} - /> - ))} -
- )} -
+ {renderContent()}
diff --git a/frontend/src/components/pages/security/tabs/roles-tab.tsx b/frontend/src/components/pages/security/tabs/roles-tab.tsx index a00493e137..f8bdae4771 100644 --- a/frontend/src/components/pages/security/tabs/roles-tab.tsx +++ b/frontend/src/components/pages/security/tabs/roles-tab.tsx @@ -49,11 +49,13 @@ import { 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 { useCallback, useLayoutEffect, useMemo, useState } from 'react'; +import { useLayoutEffect, useState } from 'react'; import ErrorResult from '../../../../components/misc/error-result'; import { useDeleteRoleMutation, useListRolesQuery } from '../../../../react-query/api/security'; @@ -101,85 +103,68 @@ export const RolesTab: FC = () => { name: parseAsString, }); - const columnFilters = useMemo(() => { - const result: ColumnFiltersState = []; - if (urlFilterParams.name) { - result.push({ id: 'name', value: urlFilterParams.name }); - } - return result; - }, [urlFilterParams]); + const columnFilters: ColumnFiltersState = urlFilterParams.name ? [{ id: 'name', value: urlFilterParams.name }] : []; - const handleColumnFiltersChange = useCallback( - (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, - }); - }, - [columnFilters, setUrlFilterParams] - ); + 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, isError: rolesIsError, error: rolesError } = useListRolesQuery(); + const { data: rolesData, isLoading: rolesLoading, isError: rolesIsError, error: rolesError } = useListRolesQuery(); const { mutateAsync: deleteRoleMutation } = useDeleteRoleMutation(); - const rolesWithMembers: RoleEntry[] = (rolesData?.roles ?? []).map((r) => { - const members = rolesApi.roleMembers.get(r.name) ?? []; - return { name: r.name, members }; - }); + const rolesWithMembers: RoleEntry[] = (rolesData?.roles ?? []).map((r) => ({ + name: r.name, + members: rolesApi.roleMembers.get(r.name) ?? [], + })); - const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]); + const pagination: PaginationState = { pageIndex, pageSize }; - const handlePaginationChange = useCallback( - (updater: Updater) => { - const next = typeof updater === 'function' ? updater(pagination) : updater; - setPageIndex(next.pageIndex); - setPageSize(next.pageSize); - }, - [pagination] - ); + const handlePaginationChange = (updater: Updater) => { + const next = typeof updater === 'function' ? updater(pagination) : updater; + setPageIndex(next.pageIndex); + setPageSize(next.pageSize); + }; - const columns = useMemo[]>( - () => [ - { - 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} - /> - ), - }, - ], - [deleteRoleMutation] - ); + 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, @@ -208,22 +193,83 @@ export const RolesTab: FC = () => { .filter(Boolean) .join(' '); + const renderBody = () => { + if (rolesLoading) { + return [0, 1, 2].map((i) => ( + + + + + + + + + + )); + } + if (table.getRowModel().rows.length) { + return table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )); + } + return ( + + + + + + + + No roles yet + + Roles are groups of ACLs that can be assigned to principals. Create one to start managing access + control. + + + +
+ + +
+
+
+
+
+ ); + }; + return ( <> -

+ -

+ 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). -

+ {' '} -

+ { ))} - - {table.getRowModel().rows.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - - - - - - - No roles yet - - Roles are groups of ACLs that can be assigned to principals. Create one to start managing - access control. - - - -
- - -
-
-
-
-
- )} -
+ {renderBody()} diff --git a/frontend/src/components/pages/security/tabs/users-tab.tsx b/frontend/src/components/pages/security/tabs/users-tab.tsx index 16d02fc0f6..c43208a245 100644 --- a/frontend/src/components/pages/security/tabs/users-tab.tsx +++ b/frontend/src/components/pages/security/tabs/users-tab.tsx @@ -10,7 +10,7 @@ */ import { useQuery } from '@connectrpc/connect-query'; -import { Link, useNavigate } from '@tanstack/react-router'; +import { Link } from '@tanstack/react-router'; import { type ColumnDef, type ColumnFiltersState, @@ -47,7 +47,7 @@ import { import { UsersIcon } from 'lucide-react'; import { parseAsArrayOf, parseAsString, useQueryStates } from 'nuqs'; import type { FC } from 'react'; -import { useCallback, useLayoutEffect, useMemo, useState } 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'; @@ -71,9 +71,11 @@ import { 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'; @@ -153,32 +155,24 @@ export const UsersTab: FC = () => { mechanism: parseAsArrayOf(parseAsString), }); - const columnFilters = useMemo(() => { - const result: ColumnFiltersState = []; - if (urlFilterParams.name) { - result.push({ id: 'name', value: urlFilterParams.name }); - } - if (urlFilterParams.mechanism?.length) { - result.push({ id: 'mechanism', value: urlFilterParams.mechanism }); - } - return result; - }, [urlFilterParams]); - - const handleColumnFiltersChange = useCallback( - (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, - }); - }, - [columnFilters, setUrlFilterParams] - ); + 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, { @@ -192,70 +186,64 @@ export const UsersTab: FC = () => { mechanism: u.mechanism, })); - const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]); + const pagination: PaginationState = { pageIndex, pageSize }; - const handlePaginationChange = useCallback( - (updater: Updater) => { - const next = typeof updater === 'function' ? updater(pagination) : updater; - setPageIndex(next.pageIndex); - setPageSize(next.pageSize); - }, - [pagination] - ); + const handlePaginationChange = (updater: Updater) => { + const next = typeof updater === 'function' ? updater(pagination) : updater; + setPageIndex(next.pageIndex); + setPageSize(next.pageSize); + }; - const columns = useMemo[]>( - () => [ - { - 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 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, @@ -288,17 +276,89 @@ export const UsersTab: FC = () => { userData?.canManageUsers ); + const renderBody = () => { + if (usersLoading) { + return [0, 1, 2].map((i) => ( + + + + + + + + + + + + + + + + )); + } + if (table.getRowModel().rows.length) { + return table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )); + } + return ( + + + + + + + + No users yet + + SASL-SCRAM user accounts managed by your cluster. Create one to start managing access. + + + +
+ + +
+
+
+
+
+ ); + }; + return ( <> -

+ These users are SASL-SCRAM users managed by your cluster. View permissions for other authentication identities (for example, OIDC, mTLS) on the Permissions List page. -

+ { ))} - - {table.getRowModel().rows.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - - - - - - - No users yet - - SASL-SCRAM user accounts managed by your cluster. Create one to start managing access. - - - -
- - -
-
-
-
-
- )} -
+ {renderBody()} @@ -403,7 +413,6 @@ export const UsersTab: FC = () => { const UserRolesCell = ({ userName }: { userName: string }) => { const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); - const navigate = useNavigate(); if (!featureRolesApi) { return ; @@ -425,16 +434,15 @@ const UserRolesCell = ({ userName }: { userName: string }) => { return (
{roles.map((r) => ( - navigate({ to: `/security/roles/${r}/details` })}> - {r} - + + {r} + ))}
); }; const UserAclsCell = ({ userName }: { userName: string }) => { - const navigate = useNavigate(); const { data: aclCount } = useQuery(listACLs, { filter: { principal: `User:${userName}` } } as ListACLsRequest, { enabled: !!userName, select: (r) => r.resources.length, @@ -445,9 +453,9 @@ const UserAclsCell = ({ userName }: { userName: string }) => { } return ( - navigate({ to: `/security/acls/${userName}/details` })}> - {`${aclCount} ACL${aclCount !== 1 ? 's' : ''}`} - + + {`${aclCount} ACL${aclCount !== 1 ? 's' : ''}`} + ); }; 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 4d969d7932..f3a8600acb 100644 --- a/frontend/src/components/pages/security/users/user-acls-card.tsx +++ b/frontend/src/components/pages/security/users/user-acls-card.tsx @@ -15,8 +15,9 @@ import { AclsCard } from '../shared/acls-card'; type UserAclsCardProps = { acls?: AclDetail[]; userName?: string; + isLoading?: boolean; }; -export const UserAclsCard = ({ acls, userName }: UserAclsCardProps) => ( - +export const UserAclsCard = ({ acls, userName, isLoading }: UserAclsCardProps) => ( + ); diff --git a/frontend/src/components/pages/security/users/user-create-dialog.tsx b/frontend/src/components/pages/security/users/user-create-dialog.tsx index 5fc7b495b9..8ff1806437 100644 --- a/frontend/src/components/pages/security/users/user-create-dialog.tsx +++ b/frontend/src/components/pages/security/users/user-create-dialog.tsx @@ -93,7 +93,7 @@ export const CreateUserDialog = ({ open, onOpenChange }: CreateUserDialogProps) return ( <> - + {step === 'form' && ( Create user diff --git a/frontend/src/components/pages/security/users/user-details.tsx b/frontend/src/components/pages/security/users/user-details.tsx index 6b82035e4b..6314a5562e 100644 --- a/frontend/src/components/pages/security/users/user-details.tsx +++ b/frontend/src/components/pages/security/users/user-details.tsx @@ -136,8 +136,8 @@ export default UserDetailsPage; const UserPermissionDetailsContent = ({ userName }: { userName: string }) => { const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); - const { data: rolesData } = useListRolesQuery({ filter: { principal: userName } }); - const { data: acls } = useGetAclsByPrincipal(`User:${userName}`); + const { data: rolesData, isLoading: isRolesLoading } = useListRolesQuery({ filter: { principal: userName } }); + const { data: acls, isLoading: isAclsLoading } = useGetAclsByPrincipal(`User:${userName}`); const roles = featureRolesApi ? (rolesData?.roles ?? []).map((r) => ({ @@ -148,8 +148,8 @@ const UserPermissionDetailsContent = ({ userName }: { userName: string }) => { return (
- - + +
); }; 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 059deb1902..dab3a1440f 100644 --- a/frontend/src/components/pages/security/users/user-roles-card.tsx +++ b/frontend/src/components/pages/security/users/user-roles-card.tsx @@ -10,9 +10,17 @@ */ import { create } from '@bufbuild/protobuf'; -import { useNavigate } from '@tanstack/react-router'; +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 { useMemo } from 'react'; import { UpdateRoleMembershipRequestSchema } from '../../../../protogen/redpanda/api/dataplane/v1/security_pb'; import { useListRolesQuery, useUpdateRoleMembershipMutation } from '../../../../react-query/api/security'; @@ -20,7 +28,9 @@ import { rolesApi } from '../../../../state/backend-api'; import { Button } from '../../../redpanda-ui/components/button'; import { Combobox } from '../../../redpanda-ui/components/combobox'; import { ListLayout, ListLayoutContent, ListLayoutFilters } from '../../../redpanda-ui/components/list-layout'; +import { Skeleton } from '../../../redpanda-ui/components/skeleton'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../../redpanda-ui/components/table'; +import { Heading } from '../../../redpanda-ui/components/typography'; type Role = { principalType: string; @@ -30,22 +40,18 @@ type Role = { type UserRolesCardProps = { roles: Role[]; userName?: string; + isLoading?: boolean; }; -export const UserRolesCard = ({ roles, userName }: UserRolesCardProps) => { - const navigate = useNavigate(); +export const UserRolesCard = ({ roles, userName, isLoading }: UserRolesCardProps) => { const { mutateAsync: updateRoleMembership } = useUpdateRoleMembershipMutation(); const { data: rolesData } = useListRolesQuery(); - const assignedRoleNames = useMemo(() => new Set(roles.map((r) => r.principalName)), [roles]); + const assignedRoleNames = new Set(roles.map((r) => r.principalName)); - const availableRoleOptions = useMemo( - () => - (rolesData?.roles ?? []) - .filter((r) => !assignedRoleNames.has(r.name)) - .map((r) => ({ value: r.name, label: r.name })), - [rolesData, assignedRoleNames] - ); + 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; @@ -63,6 +69,71 @@ export const UserRolesCard = ({ roles, userName }: UserRolesCardProps) => { 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

+ + Roles +
- {count === 0 ? ( -

No roles assigned to this user.

- ) : ( - - - - Name - Actions - - - - {roles.map((r) => ( - - {r.principalName} - -
- {Boolean(userName) && ( - - )} - -
-
-
- ))} -
-
- )} + + + + Name + Actions + + + {renderBody()} +
); From 59fccb36ce5de72859ef8fcabce9a9e14cf37ce5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Thu, 30 Apr 2026 09:02:50 +0200 Subject: [PATCH 6/7] fix integration tests --- .../observability/observability-page.test.tsx | 1 + .../security/acls/acl-detail-page.test.tsx | 11 +- .../pages/security/acls/acl-detail-page.tsx | 8 + .../security/roles/role-create-dialog.tsx | 1 + .../tabs/permissions-list-tab.test.tsx | 191 ++++++------------ .../security/tabs/permissions-list-tab.tsx | 5 +- .../pages/security/tabs/roles-tab.test.tsx | 40 ++-- .../security/users/user-acls-card.test.tsx | 4 +- .../pages/security/users/user-create.test.tsx | 5 +- .../pages/security/users/user-create.tsx | 12 ++ .../security/users/user-roles-card.test.tsx | 16 +- 11 files changed, 122 insertions(+), 172 deletions(-) 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/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 9df2ca44ba..429db70096 100644 --- a/frontend/src/components/pages/security/acls/acl-detail-page.tsx +++ b/frontend/src/components/pages/security/acls/acl-detail-page.tsx @@ -18,6 +18,7 @@ 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 { ACLDetails } from '../shared/acl-details'; import { parsePrincipalFromParam } from '../shared/principal-utils'; @@ -52,10 +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/roles/role-create-dialog.tsx b/frontend/src/components/pages/security/roles/role-create-dialog.tsx index 0007fa009e..95efd193d7 100644 --- a/frontend/src/components/pages/security/roles/role-create-dialog.tsx +++ b/frontend/src/components/pages/security/roles/role-create-dialog.tsx @@ -10,6 +10,7 @@ */ 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'; diff --git a/frontend/src/components/pages/security/tabs/permissions-list-tab.test.tsx b/frontend/src/components/pages/security/tabs/permissions-list-tab.test.tsx index 294f1e5e17..b9e1c39685 100644 --- a/frontend/src/components/pages/security/tabs/permissions-list-tab.test.tsx +++ b/frontend/src/components/pages/security/tabs/permissions-list-tab.test.tsx @@ -11,6 +11,7 @@ import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { NuqsTestingAdapter } from 'nuqs/adapters/testing'; import type { ReactNode } from 'react'; import { beforeEach, describe, expect, test, vi } from 'vitest'; @@ -23,104 +24,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; * (they're ACL-only principals, not SASL-SCRAM accounts) */ -const { listACLsData } = vi.hoisted(() => ({ - listACLsData: { - // Return a SCRAM user, a non-SCRAM user (ACL-only), and a Group principal - data: [ - { host: '*', principal: 'User:scram-admin', principalType: 'User', principalName: 'scram-admin', hasAcl: true }, - { - host: '*', - principal: 'User:acl-only-user', - principalType: 'User', - principalName: 'acl-only-user', - hasAcl: true, - }, - { host: '*', principal: 'Group:engineering', principalType: 'Group', principalName: 'engineering', hasAcl: true }, - ], - error: null, - isError: false, - isLoading: false, - }, -})); - -vi.mock('@redpanda-data/ui', () => { - const Div = ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => ( -
{children}
- ); - - return { - Alert: Div, - AlertDescription: Div, - AlertIcon: () => , - AlertTitle: Div, - Badge: Div, - Box: Div, - Button: ({ - children, - isDisabled, - onClick, - ...props - }: { - children?: ReactNode; - isDisabled?: boolean; - onClick?: () => void; - [key: string]: unknown; - }) => ( - - ), - createStandaloneToast: () => ({ - ToastContainer: () => null, - toast: vi.fn(), - }), - DataTable: ({ - columns, - data, - emptyText, - }: { - columns: Array<{ - cell?: (ctx: { row: { original: Record } }) => ReactNode; - header?: ReactNode; - id?: string; - }>; - data: Record[]; - emptyText?: ReactNode; - }) => - data.length > 0 ? ( - - - {data.map((row, rowIndex) => ( - - {columns.map((column, colIndex) => ( - - ))} - - ))} - -
{column.cell?.({ row: { original: row } }) ?? null}
- ) : ( -
{emptyText}
- ), - Flex: Div, - SearchField: ({ - placeholderText, - searchText, - setSearchText, - }: { - placeholderText?: string; - searchText?: string; - setSearchText?: (value: string) => void; - }) => ( - setSearchText?.(e.target.value)} placeholder={placeholderText} value={searchText ?? ''} /> - ), - Skeleton: Div, - Text: Div, - Tooltip: ({ children }: { children?: ReactNode }) => <>{children}, - redpandaTheme: {}, - redpandaToastOptions: { defaultOptions: {} }, - }; -}); +const NuqsWrapper = ({ children }: { children: ReactNode }) => {children}; vi.mock('@tanstack/react-router', async (importOriginal) => { const actual = await importOriginal(); @@ -135,6 +39,10 @@ vi.mock('@tanstack/react-router', async (importOriginal) => { }; }); +vi.mock('../shared/security-tabs-nav', () => ({ + SecurityTabsNav: () => null, +})); + vi.mock('../shared/delete-user-confirm-modal', () => ({ DeleteUserConfirmModal: ({ open, @@ -155,10 +63,6 @@ vi.mock('../shared/delete-user-confirm-modal', () => ({ ) : null, })); -vi.mock('../shared/user-role-tags', () => ({ - UserRoleTags: () => null, -})); - vi.mock('../../../../components/misc/error-result', () => ({ default: () => null, })); @@ -209,27 +113,60 @@ vi.mock('../../../../state/rest-interfaces', () => ({ AclRequestDefault: {}, })); -vi.mock('../../../misc/section', () => ({ - default: ({ children }: { children?: ReactNode }) =>
{children}
, -})); - -vi.mock('react-query/api/cluster-status', () => ({ - useGetRedpandaInfoQuery: () => ({ data: {}, isSuccess: true }), +vi.mock('../../../../react-query/api/acl', () => ({ + useCreateACLMutation: () => ({ mutateAsync: vi.fn() }), + useDeleteAclMutation: () => ({ mutateAsync: vi.fn() }), })); -vi.mock('react-query/api/user', () => ({ +vi.mock('../../../../react-query/api/user', () => ({ useInvalidateUsersCache: () => vi.fn(), useDeleteUserMutation: () => ({ mutateAsync: vi.fn().mockResolvedValue(undefined) }), - // "scram-admin" is a SCRAM user; "acl-only-user" is NOT (only has ACLs) - useListUsersQuery: () => ({ - data: { users: [{ name: 'scram-admin' }] }, - isLoading: false, - }), + useListUsersQuery: () => ({ data: { users: [] }, isLoading: false }), })); -vi.mock('react-query/api/acl', () => ({ - useDeleteAclMutation: () => ({ mutateAsync: vi.fn() }), - useListACLAsPrincipalGroups: () => listACLsData, +vi.mock('../hooks/use-principal-permissions', () => ({ + usePrincipalPermissions: () => ({ + principalGroups: [ + { + principal: 'User:scram-admin', + principalType: 'User', + principalName: 'scram-admin', + isScramUser: true, + directAcls: [], + roleAclGroups: [], + directAclCount: 0, + inheritedAclCount: 0, + denyCount: 0, + }, + { + principal: 'User:acl-only-user', + principalType: 'User', + principalName: 'acl-only-user', + isScramUser: false, + directAcls: [], + roleAclGroups: [], + directAclCount: 0, + inheritedAclCount: 0, + denyCount: 0, + }, + { + principal: 'Group:engineering', + principalType: 'Group', + principalName: 'engineering', + isScramUser: false, + directAcls: [], + roleAclGroups: [], + directAclCount: 0, + inheritedAclCount: 0, + denyCount: 0, + }, + ], + isAclsLoading: false, + isAclsError: false, + aclsError: null, + isUsersError: false, + usersError: null, + }), })); import { PermissionsListTab } from './permissions-list-tab'; @@ -242,11 +179,11 @@ describe('Permissions List - delete dropdown for different principal types', () test('Group principal does not show "Delete User" options in dropdown', async () => { const user = userEvent.setup(); - render(); + render(, { wrapper: NuqsWrapper }); const groupRow = await screen.findByTestId('row-engineering'); - const deleteButton = within(groupRow).getByRole('button'); - await user.click(deleteButton); + const actionsDiv = within(groupRow).getByTestId('actions-engineering'); + await user.click(within(actionsDiv).getByRole('button')); // Group should only have "Delete (ACLs only)", not user-delete options expect(screen.queryByText('Delete (User and ACLs)')).not.toBeInTheDocument(); @@ -257,12 +194,11 @@ describe('Permissions List - delete dropdown for different principal types', () test('SCRAM user principal has "Delete User" options enabled', async () => { const user = userEvent.setup(); - // "scram-admin" exists in usersData.users — it's a real SCRAM user - render(); + render(, { wrapper: NuqsWrapper }); const scramRow = await screen.findByTestId('row-scram-admin'); - const deleteButton = within(scramRow).getByRole('button'); - await user.click(deleteButton); + const actionsDiv = within(scramRow).getByTestId('actions-scram-admin'); + await user.click(within(actionsDiv).getByRole('button')); // SCRAM user should have all delete options available and enabled const deleteUserAndAcls = screen.getByText('Delete (User and ACLs)'); @@ -277,13 +213,12 @@ describe('Permissions List - delete dropdown for different principal types', () test('Group principal has "Delete (ACLs only)" available', async () => { const user = userEvent.setup(); - render(); + render(, { wrapper: NuqsWrapper }); const groupRow = await screen.findByTestId('row-engineering'); - const deleteButton = within(groupRow).getByRole('button'); - await user.click(deleteButton); + const actionsDiv = within(groupRow).getByTestId('actions-engineering'); + await user.click(within(actionsDiv).getByRole('button')); - // Even though user-delete options are hidden, "Delete (ACLs only)" is always available expect(screen.getByText('Delete (ACLs only)')).toBeInTheDocument(); }); }); diff --git a/frontend/src/components/pages/security/tabs/permissions-list-tab.tsx b/frontend/src/components/pages/security/tabs/permissions-list-tab.tsx index fd63321637..813a60d353 100644 --- a/frontend/src/components/pages/security/tabs/permissions-list-tab.tsx +++ b/frontend/src/components/pages/security/tabs/permissions-list-tab.tsx @@ -126,7 +126,7 @@ const PrincipalRow: FC = ({ group, isExpanded, onToggle, onDe userName={group.principalName} /> -
+
{/* Principal header row */}
= ({ group, isExpanded, onToggle, onDe
e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} role="presentation" @@ -368,7 +369,7 @@ export const PermissionsListTab: FC = () => {
{[0, 1, 2].map((i) => (
- +
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/users/user-acls-card.test.tsx b/frontend/src/components/pages/security/users/user-acls-card.test.tsx index c43dd9051b..f144c0e4ae 100644 --- a/frontend/src/components/pages/security/users/user-acls-card.test.tsx +++ b/frontend/src/components/pages/security/users/user-acls-card.test.tsx @@ -58,14 +58,14 @@ describe('UserAclsCard', () => { test('should render empty state when no ACLs provided', () => { renderWithFileRoutes(); - expect(screen.getByText('No ACLs assigned.')).toBeInTheDocument(); + expect(screen.getByText('No ACLs assigned')).toBeInTheDocument(); expect(screen.getByRole('button', { name: '+ Add ACL' })).toBeInTheDocument(); }); test('should render empty state when acls is undefined', () => { renderWithFileRoutes(); - expect(screen.getByText('No ACLs assigned.')).toBeInTheDocument(); + expect(screen.getByText('No ACLs assigned')).toBeInTheDocument(); }); test('should render flat ACL table with correct row count and data', () => { 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 3dc65263f3..3a225db8b5 100644 --- a/frontend/src/components/pages/security/users/user-create.tsx +++ b/frontend/src/components/pages/security/users/user-create.tsx @@ -19,6 +19,7 @@ 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, @@ -37,6 +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 { Text } from '../../../redpanda-ui/components/typography'; const UserCreatePage = () => { const [formState, setFormState] = useState({ @@ -162,12 +164,15 @@ type CreateUserModalProps = { isValidUsername: boolean; isValidPassword: boolean; users: string[]; + selectedRoles: string[]; + setSelectedRoles: (v: string[]) => void; }; onCreateUser: () => Promise; onCancel: () => void; }; 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; @@ -277,6 +282,13 @@ export const CreateUserModal = ({ state, onCreateUser, onCancel }: CreateUserMod
+ {rolesApiEnabled && ( +
+ Assign roles + +
+ )} +