diff --git a/frontend/src/components/layout/header.tsx b/frontend/src/components/layout/header.tsx index 524cee46da..d7ee3c664f 100644 --- a/frontend/src/components/layout/header.tsx +++ b/frontend/src/components/layout/header.tsx @@ -166,18 +166,6 @@ function useShouldShowRefresh() { const connectWizardPagesMatch = matchRoute({ to: '/rp-connect/wizard' }); 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; - - // matches roles - const roleCreateMatch = matchRoute({ to: '/security/roles/create' }); - const roleUpdateMatch = matchRoute({ to: '/security/roles/$roleName/update' }); - const roleDetailMatch = matchRoute({ to: '/security/roles/$roleName/details' }); - const isRoleRelated = roleCreateMatch || roleUpdateMatch || roleDetailMatch; - if (connectClusterMatch && connectClusterMatch.connector === 'create-connector') { return false; } @@ -190,12 +178,6 @@ function useShouldShowRefresh() { if (secretsMatch) { return false; } - if (isACLRelated) { - return false; - } - if (isRoleRelated) { - return false; - } if (connectWizardPagesMatch) { return false; } diff --git a/frontend/src/components/pages/acls/acl-list.roles.test.tsx b/frontend/src/components/pages/acls/acl-list.roles.test.tsx deleted file mode 100644 index 6606ffcd6c..0000000000 --- a/frontend/src/components/pages/acls/acl-list.roles.test.tsx +++ /dev/null @@ -1,411 +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 { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import type { ReactNode } from 'react'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; - -const { historyPushMock, refreshRoleMembersMock, refreshRolesMock, deleteRoleMutationMock } = vi.hoisted(() => ({ - historyPushMock: vi.fn(), - refreshRoleMembersMock: vi.fn().mockResolvedValue(undefined), - refreshRolesMock: vi.fn().mockResolvedValue(undefined), - deleteRoleMutationMock: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock('@redpanda-data/ui', () => { - const Div = ({ - children, - flexDirection: _flexDirection, - ...props - }: { - children?: ReactNode; - flexDirection?: unknown; - [key: string]: unknown; - }) =>
{children}
; - - return { - Alert: Div, - AlertDescription: Div, - AlertIcon: () => , - AlertTitle: Div, - Badge: Div, - Box: Div, - Button: ({ - children, - isDisabled, - onClick, - tooltip: _tooltip, - ...props - }: { - children?: ReactNode; - isDisabled?: boolean; - onClick?: () => void; - tooltip?: unknown; - [key: string]: unknown; - }) => ( - - ), - CloseButton: ({ - children, - onClick, - ...props - }: { - children?: ReactNode; - onClick?: () => void; - [key: string]: unknown; - }) => ( - - ), - createStandaloneToast: () => ({ - ToastContainer: () => null, - toast: vi.fn(), - }), - DataTable: ({ - columns, - data, - emptyAction, - emptyText, - }: { - columns: Array<{ - cell?: (ctx: { row: { original: Record } }) => ReactNode; - header?: ReactNode; - id: string; - }>; - data: Record[]; - emptyAction?: ReactNode; - emptyText?: ReactNode; - }) => - data.length > 0 ? ( - - - {data.map((row, rowIndex) => ( - - {columns.map((column) => ( - - ))} - - ))} - -
{column.cell?.({ row: { original: row } }) ?? null}
- ) : ( -
-
{emptyText}
- {emptyAction} -
- ), - Flex: Div, - Icon: () => , - Link: ({ - as: Component, - children, - ...props - }: { - as?: ((props: Record) => ReactNode) | string; - children?: ReactNode; - [key: string]: unknown; - }) => - Component && typeof Component !== 'string' ? ( - {children} - ) : ( - {children} - ), - Menu: Div, - MenuButton: ({ - children, - onClick, - ...props - }: { - children?: ReactNode; - onClick?: () => void; - [key: string]: unknown; - }) => ( - - ), - MenuItem: ({ - children, - onClick, - ...props - }: { - children?: ReactNode; - onClick?: () => void; - [key: string]: unknown; - }) => ( - - ), - MenuList: Div, - redpandaTheme: {}, - redpandaToastOptions: { - defaultOptions: {}, - }, - SearchField: ({ - placeholderText, - searchText, - setSearchText, - ...props - }: { - placeholderText?: string; - searchText?: string; - setSearchText?: (value: string) => void; - [key: string]: unknown; - }) => ( - setSearchText?.(e.target.value)} - placeholder={placeholderText} - value={searchText ?? ''} - {...props} - /> - ), - Skeleton: Div, - Tabs: ({ index = 0, items }: { index?: number; items: Array<{ component: ReactNode }> }) => ( -
{items[index]?.component ?? null}
- ), - Text: Div, - Tooltip: ({ children }: { children?: ReactNode }) => <>{children}, - }; -}); - -vi.mock('@tanstack/react-router', async (importOriginal) => { - const actual = await importOriginal(); - - return { - ...actual, - Link: ({ - children, - params: _params, - search: _search, - to, - ...props - }: { - children: ReactNode; - params?: unknown; - search?: unknown; - to?: string; - [key: string]: unknown; - }) => ( - - {children} - - ), - useNavigate: () => vi.fn(), - }; -}); - -vi.mock('config', async (importOriginal) => { - const actual = await importOriginal(); - - return { - ...actual, - isServerless: () => false, - }; -}); - -vi.mock('./delete-role-confirm-modal', () => ({ - DeleteRoleConfirmModal: ({ - buttonEl, - onConfirm, - roleName, - }: { - buttonEl: ReactNode; - onConfirm: () => Promise | void; - roleName: string; - }) => ( -
- {buttonEl} - -
- ), -})); - -vi.mock('./delete-user-confirm-modal', () => ({ - DeleteUserConfirmModal: ({ buttonEl }: { buttonEl: ReactNode }) => <>{buttonEl}, -})); - -vi.mock('./models', () => ({ - principalGroupsView: { - principalGroups: [], - }, -})); - -vi.mock('./user-edit-modals', () => ({ - ChangePasswordModal: () => null, - ChangeRolesModal: () => null, -})); - -vi.mock('./user-permission-assignments', () => ({ - UserRoleTags: () => null, -})); - -vi.mock('../../../components/misc/error-result', () => ({ - default: () => null, -})); - -vi.mock('../../../state/app-global', () => ({ - appGlobal: { - historyPush: historyPushMock, - onRefresh: null, - }, -})); - -vi.mock('../../../state/backend-api', () => { - const store = { - ACLs: { isAuthorizerEnabled: true }, - userData: { - canCreateRoles: true, - canListAcls: true, - canManageUsers: true, - canViewPermissionsList: true, - }, - enterpriseFeaturesUsed: [] as { name: string; enabled: boolean }[], - serviceAccounts: null as null | { users: string[] }, - isAdminApiConfigured: false, - }; - return { - api: { - ...store, - refreshClusterOverview: vi.fn().mockResolvedValue(undefined), - refreshUserData: vi.fn().mockResolvedValue(undefined), - }, - useApiStoreHook: (selector: (s: typeof store) => T) => selector(store), - rolesApi: { - deleteRole: vi.fn().mockResolvedValue(undefined), - refreshRoleMembers: refreshRoleMembersMock, - refreshRoles: refreshRolesMock, - roleMembers: new Map([['topic reader/qa', [{ name: 'alice', principalType: 'User' }]]]), - roles: ['topic reader/qa'], - rolesError: null, - }, - }; -}); - -vi.mock('../../../state/supported-features', async (importOriginal) => { - const actual = await importOriginal(); - - return { - ...actual, - Features: { - ...actual.Features, - createUser: true, - rolesApi: true, - }, - }; -}); - -vi.mock('../../../state/ui-state', () => ({ - uiState: { - pageBreadcrumbs: [], - pageTitle: '', - }, -})); - -vi.mock('../../license/feature-license-notification', () => ({ - FeatureLicenseNotification: () => null, -})); - -vi.mock('../../misc/null-fallback-boundary', () => ({ - NullFallbackBoundary: ({ children }: { children?: ReactNode }) => <>{children}, -})); - -vi.mock('../../misc/page-content', () => ({ - default: ({ children }: { children?: ReactNode }) =>
{children}
, -})); - -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/user', () => ({ - useInvalidateUsersCache: () => vi.fn(), - useLegacyListUsersQuery: () => ({ - data: { - users: [], - }, - isLoading: false, - }), -})); - -vi.mock('react-query/api/acl', () => ({ - useDeleteAclMutation: () => ({ - mutateAsync: vi.fn(), - }), - useListACLAsPrincipalGroups: () => ({ - data: [], - error: null, - isError: false, - isLoading: false, - }), -})); - -vi.mock('react-query/api/security', () => ({ - useDeleteRoleMutation: () => ({ - mutateAsync: deleteRoleMutationMock, - }), - useListRolesQuery: () => ({ - data: { - roles: [{ name: 'topic reader/qa' }], - }, - error: null, - isError: false, - }), -})); - -import AclList from './acl-list'; - -describe('AclList role navigation', () => { - beforeEach(() => { - 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(); - - await expect(screen.findByTestId('role-list-item-topic reader/qa')).resolves.toBeInTheDocument(); - }); - - test('delete role calls deleteRoleMutation with correct arguments', async () => { - const user = userEvent.setup(); - - render(); - - await user.click(await screen.findByTestId('mock-confirm-delete-topic reader/qa')); - - expect(deleteRoleMutationMock).toHaveBeenCalledWith( - expect.objectContaining({ roleName: 'topic reader/qa', deleteAcls: true }) - ); - }); -}); diff --git a/frontend/src/components/pages/acls/acl-list.tsx b/frontend/src/components/pages/acls/acl-list.tsx deleted file mode 100644 index f432df0da8..0000000000 --- a/frontend/src/components/pages/acls/acl-list.tsx +++ /dev/null @@ -1,1089 +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, Tabs } from '@redpanda-data/ui'; -import type { TabsItemProps } from '@redpanda-data/ui/dist/components/Tabs/Tabs'; -import { Link, useNavigate } from '@tanstack/react-router'; -import { EditIcon, MoreHorizontalIcon, TrashIcon } from 'components/icons'; -import { isServerless } from 'config'; -import { InfoIcon, X } from 'lucide-react'; -import { parseAsString } from 'nuqs'; -import { - ACL_Operation, - ACL_PermissionType, - ACL_ResourcePatternType, - ACL_ResourceType, - type DeleteACLsRequest, - DeleteACLsRequestSchema, -} from 'protogen/redpanda/api/dataplane/v1/acl_pb'; -import { DeleteRoleRequestSchema } from 'protogen/redpanda/api/dataplane/v1/security_pb'; -import { type FC, useEffect, useRef, useState } from 'react'; -import { toast } from 'sonner'; - -import { DeleteRoleConfirmModal } from './delete-role-confirm-modal'; -import { DeleteUserConfirmModal } from './delete-user-confirm-modal'; -import type { AclPrincipalGroup } from './models'; -import { principalGroupsView } from './models'; -import { ChangePasswordModal, ChangeRolesModal } from './user-edit-modals'; -import { UserRoleTags } from './user-permission-assignments'; -import ErrorResult from '../../../components/misc/error-result'; -import { useQueryStateWithCallback } from '../../../hooks/use-query-state-with-callback'; -import { useDeleteAclMutation, useListACLAsPrincipalGroups } from '../../../react-query/api/acl'; -import { useGetRedpandaInfoQuery } from '../../../react-query/api/cluster-status'; -import { useDeleteRoleMutation, useListRolesQuery } from '../../../react-query/api/security'; -import { useInvalidateUsersCache, useLegacyListUsersQuery } from '../../../react-query/api/user'; -import { appGlobal } from '../../../state/app-global'; -import { api, rolesApi, useApiStoreHook } from '../../../state/backend-api'; -import { AclRequestDefault } from '../../../state/rest-interfaces'; -import { useSupportedFeaturesStore } from '../../../state/supported-features'; -import { uiState } from '../../../state/ui-state'; -import { Code as CodeEl, DefaultSkeleton } from '../../../utils/tsx-utils'; -import { FeatureLicenseNotification } from '../../license/feature-license-notification'; -import { NullFallbackBoundary } from '../../misc/null-fallback-boundary'; -import PageContent from '../../misc/page-content'; -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 { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '../../redpanda-ui/components/dropdown-menu'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../redpanda-ui/components/tooltip'; - -// TODO - once AclList is migrated to FC, we could should move this code to use useToast() -/** Filters items by name using a case-insensitive regex match (falls back to substring if the query is an invalid regex). Returns all items if the query is empty. */ -function filterByName(items: T[], query: string, getName: (item: T) => string): T[] { - if (!query) { - return items; - } - try { - const re = new RegExp(query, 'i'); // nosemgrep: detect-non-literal-regexp -- client-side UI filter, user only affects their own session - return items.filter((item) => re.test(getName(item))); - } catch { - const lowerQuery = query.toLowerCase(); - return items.filter((item) => getName(item).toLowerCase().includes(lowerQuery)); - } -} - -export type AclListTab = 'users' | 'roles' | 'acls' | 'permissions-list'; - -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(' '), - }; -}; - -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: ACL list has complex conditional rendering -const AclList: FC<{ tab?: AclListTab }> = ({ tab }) => { - // Check if Redpanda Admin API is configured using React Query - const { data: redpandaInfo, isSuccess: isRedpandaInfoSuccess } = useGetRedpandaInfoQuery(); - // Admin API is configured if the query succeeded and returned data (even if it's an empty object) - // This matches the MobX logic where api.isAdminApiConfigured checks if clusterOverview.redpanda !== null - const isAdminApiConfigured = isRedpandaInfoSuccess && Boolean(redpandaInfo); - - const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); - const featureCreateUser = useSupportedFeaturesStore((s) => s.createUser); - const userData = useApiStoreHook((s) => s.userData); - const acls = useApiStoreHook((s) => s.ACLs); - - const { data: usersData, isLoading: isUsersLoading } = useLegacyListUsersQuery(undefined, { - enabled: isAdminApiConfigured, - }); - - // Set up page title and breadcrumbs - useEffect(() => { - uiState.pageBreadcrumbs = []; - uiState.pageTitle = 'Access Control'; - uiState.pageBreadcrumbs.push({ title: 'Access Control', linkTo: '/security' }); - - // Set up refresh handler - const refreshData = async () => { - await Promise.allSettled([api.refreshClusterOverview(), rolesApi.refreshRoles(), api.refreshUserData()]); - await rolesApi.refreshRoleMembers(); - }; - - appGlobal.onRefresh = async () => { - await refreshData(); - }; - - // Initial data load - refreshData().catch(() => { - // Fail silently for now - }); - }, []); - - // Note: Redirect from /security/ to /security/users is now handled at route level - // in src/routes/security/index.tsx using beforeLoad to prevent navigation loops - // in embedded mode where shell and console routers can conflict. - - if (isUsersLoading && !usersData?.users?.length) { - return DefaultSkeleton; - } - - const warning = - acls === null ? ( - - You do not have the necessary permissions to view ACLs - - ) : null; - - const noAclAuthorizer = - acls?.isAuthorizerEnabled === false ? ( - - There's no authorizer configured in your Kafka cluster - - ) : null; - - const tabs = [ - { - key: 'users' as AclListTab, - name: 'Users', - component: , - isDisabled: - (!isAdminApiConfigured && 'The Redpanda Admin API is not configured.') || - (!featureCreateUser && "Your cluster doesn't support this feature.") || - (userData?.canManageUsers !== undefined && - userData?.canManageUsers === false && - 'You need RedpandaCapability.MANAGE_REDPANDA_USERS permission.'), - }, - isServerless() - ? null - : { - key: 'roles' as AclListTab, - name: 'Roles', - component: , - isDisabled: - (!featureRolesApi && "Your cluster doesn't support this feature.") || - (userData?.canManageUsers === false && 'You need RedpandaCapability.MANAGE_REDPANDA_USERS permission.'), - }, - { - key: 'acls' as AclListTab, - name: 'ACLs', - component: , - isDisabled: userData?.canListAcls ? false : 'You do not have the necessary permissions to view ACLs.', - }, - { - key: 'permissions-list' as AclListTab, - name: 'Permissions List', - component: , - isDisabled: userData?.canViewPermissionsList - ? false - : 'You need (KafkaAclOperation.DESCRIBE and RedpandaCapability.MANAGE_REDPANDA_USERS permissions.', - }, - ].filter((x) => x !== null) as TabsItemProps[]; - - const activeTab = tabs.findIndex((x) => x.key === tab); - - return ( - <> - {warning} - {noAclAuthorizer} - - - = 0 ? activeTab : 0} - items={tabs} - onChange={(_, key) => { - appGlobal.historyPush(`/security/${key}`); - }} - /> - - - ); -}; - -export default AclList; - -type UsersEntry = { name: string; type: 'SERVICE_ACCOUNT' | 'PRINCIPAL' }; - -const PermissionsListActions = ({ - entry, - canDeleteUser, - onDelete, -}: { - entry: UsersEntry; - canDeleteUser: boolean; - onDelete: (entry: UsersEntry, deleteUser: boolean, deleteAcls: boolean) => Promise; -}) => { - const [pendingAction, setPendingAction] = useState<'user-and-acls' | 'user-only' | null>(null); - - return ( - <> - { - if (pendingAction === 'user-and-acls') await onDelete(entry, true, true); - if (pendingAction === 'user-only') await onDelete(entry, true, false); - }} - onOpenChange={(open) => { - if (!open) setPendingAction(null); - }} - open={pendingAction !== null} - userName={entry.name} - /> - - - - - - { - e.stopPropagation(); - setPendingAction('user-and-acls'); - }} - > - Delete (User and ACLs) - - { - e.stopPropagation(); - setPendingAction('user-only'); - }} - > - Delete (User only) - - { - e.stopPropagation(); - onDelete(entry, false, true).catch(() => {}); - }} - > - Delete (ACLs only) - - - - - ); -}; - -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: permissions list has complex conditional rendering -const PermissionsListTab = () => { - const [searchQuery, setSearchQuery] = useState(''); - 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 invalidateUsersCache = useInvalidateUsersCache(); - - // Check if Redpanda Admin API is configured using React Query - const { data: redpandaInfo, isSuccess: isRedpandaInfoSuccess } = useGetRedpandaInfoQuery(); - const isAdminApiConfigured = isRedpandaInfoSuccess && Boolean(redpandaInfo); - - const { - data: usersData, - isError: isUsersError, - error: usersError, - } = useLegacyListUsersQuery(undefined, { - enabled: isAdminApiConfigured, - }); - - const { data: principalGroupsData, isError: isAclsError, error: aclsError } = useListACLAsPrincipalGroups(); - - const deleteACLsForPrincipal = async (principalName: string) => { - const deleteRequest = create(DeleteACLsRequestSchema, { - filter: { - principal: `User:${principalName}`, - resourceType: ACL_ResourceType.ANY, - resourceName: undefined, - host: undefined, - operation: ACL_Operation.ANY, - permissionType: ACL_PermissionType.ANY, - resourcePatternType: ACL_ResourcePatternType.ANY, - }, - }); - await deleteACLMutation(deleteRequest); - toast.success( - - Deleted ACLs for {principalName} - - ); - }; - - const onDelete = async (entry: UsersEntry, deleteUser: boolean, deleteAcls: boolean) => { - if (deleteAcls) { - try { - await deleteACLsForPrincipal(entry.name); - } catch (err: unknown) { - setAclFailed({ err }); - } - } - if (deleteUser) { - try { - await api.deleteServiceAccount(entry.name); - toast.success( - - Deleted user {entry.name} - - ); - } catch (err: unknown) { - setAclFailed({ err }); - } - } - await Promise.allSettled([api.refreshAcls(AclRequestDefault, true), invalidateUsersCache()]); - }; - - // Check for errors from both queries - if (isUsersError && usersError) { - return ( - - Failed to load users - {usersError.message} - - ); - } - - if (isAclsError && aclsError) { - return ; - } - - const users: UsersEntry[] = (usersData?.users ?? []).map((u) => ({ - name: u.name, - type: 'SERVICE_ACCOUNT', - })); - - // In addition, find all principals that are referenced by roles, or acls, that are not service accounts - for (const g of principalGroupsData ?? []) { - if (g.principalType === 'User' && !g.principalName.includes('*') && !users.any((u) => u.name === g.principalName)) { - // is it a user that is being referenced? - // is the user already listed as a service account? - users.push({ name: g.principalName, type: 'PRINCIPAL' }); - } - } - - for (const [_, roleMembers] of rolesApi.roleMembers ?? []) { - for (const roleMember of roleMembers) { - if (!users.any((u) => u.name === roleMember.name)) { - // make sure that user isn't already in the list - users.push({ name: roleMember.name, type: 'PRINCIPAL' }); - } - } - } - - const usersFiltered = filterByName(users, 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. -
- - - -
- setAclFailed(null)} /> -
- - columns={[ - { - id: 'name', - size: Number.POSITIVE_INFINITY, - header: 'Principal', - 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: ({ row: { original: entry } }) => ( - - ), - }, - ]} - data={usersFiltered} - emptyAction={(() => { - const { disabled, tooltip } = getCreateUserButtonProps( - isAdminApiConfigured, - featureCreateUser, - userData?.canManageUsers - ); - return ( - - - - - - {tooltip && {tooltip}} - - - ); - })()} - emptyText="No principals yet" - pagination - sorting - /> -
-
-
- ); -}; - -const UsersTab = ({ isAdminApiConfigured }: { isAdminApiConfigured: boolean }) => { - const featureCreateUser = useSupportedFeaturesStore((s) => s.createUser); - const userData = useApiStoreHook((s) => s.userData); - const [searchQuery, setSearchQuery] = useQueryStateWithCallback( - { - onUpdate: () => { - // Query state is managed by the URL - }, - getDefaultValue: () => '', - }, - 'q', - parseAsString.withDefault('') - ); - const { - data: usersData, - isError, - error, - } = useLegacyListUsersQuery(undefined, { - enabled: isAdminApiConfigured, - }); - - const users: UsersEntry[] = (usersData?.users ?? []).map((u) => ({ - name: u.name, - type: 'SERVICE_ACCOUNT', - })); - - const usersFiltered = filterByName(users, searchQuery, (u) => u.name); - - if (isError && error) { - return ( - - Failed to load users - {error.message} - - ); - } - - const { disabled: createDisabled, tooltip: createTooltip } = getCreateUserButtonProps( - isAdminApiConfigured, - featureCreateUser, - userData?.canManageUsers - ); - - 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 - /> -
-
-
- ); -}; - -const UserActions = ({ user, isAdminApiConfigured }: { user: UsersEntry; isAdminApiConfigured: boolean }) => { - 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 onConfirmDelete = async () => { - await api.deleteServiceAccount(user.name); - - // Remove user from all its roles - const promises: Promise[] = []; - for (const [roleName, members] of rolesApi.roleMembers) { - if (members.any((m) => m.name === user.name)) { - // is this user part of this role? - // then remove it - promises.push(rolesApi.updateRoleMembership(roleName, [], [{ name: user.name, principalType: 'User' }])); - } - } - - await Promise.allSettled(promises); - await Promise.all([rolesApi.refreshRoleMembers(), invalidateUsersCache()]); - }; - - return ( - <> - {Boolean(isAdminApiConfigured) && !isServerless() && ( - - )} - {Boolean(featureRolesApi) && ( - - )} - - - - - - - - {Boolean(isAdminApiConfigured) && !isServerless() && ( - { - e.stopPropagation(); - setIsChangePasswordModalOpen(true); - }} - > - Change password - - )} - {Boolean(featureRolesApi) && ( - { - e.stopPropagation(); - setIsChangeRolesModalOpen(true); - }} - > - Change roles - - )} - { - e.stopPropagation(); - setIsDeleteModalOpen(true); - }} - > - Delete - - - - - ); -}; - -const RolesTab = () => { - const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); - const userData = useApiStoreHook((s) => s.userData); - const [searchQuery, setSearchQuery] = useState(''); - const { data: rolesData, isError: rolesIsError, error: rolesError } = useListRolesQuery(); - const { mutateAsync: deleteRoleMutation } = useDeleteRoleMutation(); - - const roles = filterByName(rolesData?.roles ?? [], searchQuery, (r) => r.name); - - const rolesWithMembers = roles.map((r) => { - const members = rolesApi.roleMembers.get(r.name) ?? []; - return { name: r.name, members }; - }); - - if (rolesIsError) { - return ; - } - - const createRoleDisabled = userData?.canCreateRoles === false || !featureRolesApi; - const createRoleTooltip = [ - userData?.canCreateRoles === false && - 'You need KafkaAclOperation.KAFKA_ACL_OPERATION_ALTER and RedpandaCapability.MANAGE_RBAC permissions.', - !featureRolesApi && 'This feature is not enabled.', - ] - .filter(Boolean) - .join(' '); - - 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 - /> -
-
-
- ); -}; - -const AclsTab = (_: { principalGroups: AclPrincipalGroup[] }) => { - const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); - const featureDeleteUser = useSupportedFeaturesStore((s) => s.deleteUser); - const enterpriseFeaturesUsed = useApiStoreHook((s) => s.enterpriseFeaturesUsed); - const { data: redpandaInfo, isSuccess: isRedpandaInfoSuccess } = useGetRedpandaInfoQuery(); - const isAdminApiConfigured = isRedpandaInfoSuccess && Boolean(redpandaInfo); - const { data: usersData } = useLegacyListUsersQuery(undefined, { enabled: isAdminApiConfigured }); - const { data: principalGroups, isLoading, isError, error } = useListACLAsPrincipalGroups(); - const { mutateAsync: deleteACLMutation } = useDeleteAclMutation(); - 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 gbacEnabled = enterpriseFeaturesUsed.some((f) => f.name === 'gbac' && f.enabled); - - const aclPrincipalGroups = - principalGroups?.filter((g) => g.principalType === 'User' || (gbacEnabled && 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 api.deleteServiceAccount(record.principalName); - toast.success( - - Deleted user {record.principalName} - - ); - } catch (err: unknown) { - // biome-ignore lint/suspicious/noConsole: error logging - console.error('failed to delete acls', { 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 - /> -
-
-
- ); -}; - -const AlertDeleteFailed: FC<{ - aclFailed: { err: unknown } | null; - onClose: () => void; -}> = ({ aclFailed, onClose }) => { - const ref = useRef(null); - - if (!aclFailed) { - return null; - } - - return ( - - Failed to delete - - {(() => { - if (aclFailed.err instanceof Error) { - return aclFailed.err.message; - } - if (typeof aclFailed.err === 'string') { - return aclFailed.err; - } - return 'Unknown error'; - })()} - - - - ); -}; diff --git a/frontend/src/components/pages/acls/delete-role-confirm-modal.tsx b/frontend/src/components/pages/acls/delete-role-confirm-modal.tsx deleted file mode 100644 index c5c70f815a..0000000000 --- a/frontend/src/components/pages/acls/delete-role-confirm-modal.tsx +++ /dev/null @@ -1,76 +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 type { FC } from 'react'; -import { useState } from 'react'; - -import { Button } from '../../redpanda-ui/components/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '../../redpanda-ui/components/dialog'; -import { Input } from '../../redpanda-ui/components/input'; - -export const DeleteRoleConfirmModal: FC<{ - roleName: string; - numberOfPrincipals: number; - onConfirm: () => Promise | void; - buttonEl: React.ReactElement; -}> = ({ roleName, numberOfPrincipals, onConfirm, buttonEl }) => { - const [open, setOpen] = useState(false); - const [confirmText, setConfirmText] = useState(''); - - const handleOpenChange = (o: boolean) => { - setOpen(o); - if (!o) setConfirmText(''); - }; - - const handleConfirm = async () => { - await onConfirm(); - handleOpenChange(false); - }; - - return ( - - {buttonEl} - - - Delete role {roleName} - - - This role is assigned to {numberOfPrincipals} {numberOfPrincipals === 1 ? 'principal' : 'principals'}. - Deleting it will remove it from these principals and take those permissions away. The ACLs will all be - deleted. To restore the permissions, the role will need to be recreated and reassigned to these principals. To - confirm, type the role name in the confirmation box below. - - setConfirmText(e.target.value)} placeholder={roleName} value={confirmText} /> - - - - - - - ); -}; diff --git a/frontend/src/components/pages/acls/delete-user-confirm-modal.tsx b/frontend/src/components/pages/acls/delete-user-confirm-modal.tsx deleted file mode 100644 index a74ed8a5f1..0000000000 --- a/frontend/src/components/pages/acls/delete-user-confirm-modal.tsx +++ /dev/null @@ -1,92 +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 type { FC } from 'react'; -import { useState } from 'react'; - -import { Button } from '../../redpanda-ui/components/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '../../redpanda-ui/components/dialog'; -import { Input } from '../../redpanda-ui/components/input'; - -type DeleteUserConfirmModalProps = { - userName: string; - onConfirm: () => Promise | void; -} & ( - | { buttonEl: React.ReactElement; open?: never; onOpenChange?: never } - | { buttonEl?: never; open: boolean; onOpenChange: (open: boolean) => void } -); - -export const DeleteUserConfirmModal: FC = ({ - userName, - onConfirm, - buttonEl, - open: controlledOpen, - onOpenChange, -}) => { - const [uncontrolledOpen, setUncontrolledOpen] = useState(false); - const [confirmText, setConfirmText] = useState(''); - - const isControlled = controlledOpen !== undefined; - const open = isControlled ? controlledOpen : uncontrolledOpen; - const setOpen = isControlled ? onOpenChange! : setUncontrolledOpen; - - const handleOpenChange = (o: boolean) => { - setOpen(o); - if (!o) setConfirmText(''); - }; - - const handleConfirm = async () => { - await onConfirm(); - handleOpenChange(false); - }; - - return ( - - {buttonEl && {buttonEl}} - - - Delete user {userName} - - - This user has roles and ACLs assigned to it. Those roles and ACLs will not be deleted, but the user will need - to be recreated and reassigned to them to be used again. To confirm, type the user name in the box below. - - setConfirmText(e.target.value)} - placeholder={`Type "${userName}" to confirm`} - testId="txt-confirmation-delete" - value={confirmText} - /> - - - - - - - ); -}; diff --git a/frontend/src/components/pages/acls/models.ts b/frontend/src/components/pages/acls/models.ts deleted file mode 100644 index 712474ab21..0000000000 --- a/frontend/src/components/pages/acls/models.ts +++ /dev/null @@ -1,660 +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 { api } from '../../../state/backend-api'; -import type { - AclStrOperation, - AclStrPermission, - AclStrResourcePatternType, - AclStrResourceType, -} from '../../../state/rest-interfaces'; - -export type PrincipalType = 'User' | 'RedpandaRole' | 'Group'; -export type AclFlat = { - // AclResource - resourceType: AclStrResourceType; - resourceName: string; - resourcePatternType: AclStrResourcePatternType; - - // AclRule - principal: string; - host: string; - operation: AclStrOperation; - permissionType: AclStrPermission; -}; - -export type AclPrincipalGroup = { - principalType: PrincipalType; - // This can only ever be a literal, or match anything (star in that case). No prefix or postfix matching - principalName: string | '*'; - - host: string; - - topicAcls: TopicACLs[]; - consumerGroupAcls: ConsumerGroupACLs[]; - transactionalIdAcls: TransactionalIdACLs[]; - clusterAcls: ClusterACLs; - - sourceEntries: AclFlat[]; -}; - -export type TopicACLs = { - /** Stable unique key for React list rendering */ - _key: string; - patternType: AclStrResourcePatternType; - selector: string; - all: AclStrPermission; - - permissions: { - Alter: AclStrPermission; - AlterConfigs: AclStrPermission; - Create: AclStrPermission; - Delete: AclStrPermission; - Describe: AclStrPermission; - DescribeConfigs: AclStrPermission; - Read: AclStrPermission; - Write: AclStrPermission; - }; -}; - -export type ConsumerGroupACLs = { - /** Stable unique key for React list rendering */ - _key: string; - patternType: AclStrResourcePatternType; - selector: string; - all: AclStrPermission; - - permissions: { - Delete: AclStrPermission; - Describe: AclStrPermission; - Read: AclStrPermission; - }; -}; - -export type TransactionalIdACLs = { - /** Stable unique key for React list rendering */ - _key: string; - patternType: AclStrResourcePatternType; - selector: string; - all: AclStrPermission; - - permissions: { - Describe: AclStrPermission; - Write: AclStrPermission; - }; -}; - -export type ClusterACLs = { - all: AclStrPermission; - - permissions: { - Alter: AclStrPermission; - AlterConfigs: AclStrPermission; - ClusterAction: AclStrPermission; - Create: AclStrPermission; - Describe: AclStrPermission; - DescribeConfigs: AclStrPermission; - }; -}; - -export type ResourceACLs = TopicACLs | ConsumerGroupACLs | TransactionalIdACLs | ClusterACLs; - -export function createEmptyTopicAcl(): TopicACLs { - return { - _key: crypto.randomUUID(), - selector: '*', - patternType: 'Any', - all: 'Any', - permissions: { - Alter: 'Any', - AlterConfigs: 'Any', - Create: 'Any', - DescribeConfigs: 'Any', - Write: 'Any', - Read: 'Any', - Delete: 'Any', - Describe: 'Any', - }, - }; -} - -export function createEmptyConsumerGroupAcl(): ConsumerGroupACLs { - return { - _key: crypto.randomUUID(), - selector: '*', - patternType: 'Any', - all: 'Any', - permissions: { - Read: 'Any', - Delete: 'Any', - Describe: 'Any', - }, - }; -} - -export function createEmptyTransactionalIdAcl(): TransactionalIdACLs { - return { - _key: crypto.randomUUID(), - selector: '*', - patternType: 'Any', - all: 'Any', - permissions: { - Describe: 'Any', - Write: 'Any', - }, - }; -} - -export function createEmptyClusterAcl(): ClusterACLs { - return { - all: 'Any', - permissions: { - Alter: 'Any', - AlterConfigs: 'Any', - ClusterAction: 'Any', - Create: 'Any', - Describe: 'Any', - DescribeConfigs: 'Any', - }, - }; -} - -function modelPatternTypeToUIType(resourcePatternType: AclStrResourcePatternType, resourceName: string) { - if (resourcePatternType === 'Literal' && resourceName === '*') { - return 'Any'; - } - - return resourcePatternType; -} - -function collectTopicAcls(acls: AclFlat[]): TopicACLs[] { - const topics = acls - .filter((x) => x.resourceType === 'Topic') - .groupInto((x) => `${x.resourcePatternType}: ${x.resourceName}`); - - const topicAcls: TopicACLs[] = []; - for (const { items } of topics) { - const first = items[0]; - const selector = first.resourceName; - - const topicOperations = [ - 'Alter', - 'AlterConfigs', - 'Create', - 'Delete', - 'Describe', - 'DescribeConfigs', - 'Read', - 'Write', - ] as const; - - const topicPermissions: { [key in (typeof topicOperations)[number]]: AclStrPermission } = { - Alter: 'Any', - AlterConfigs: 'Any', - Create: 'Any', - Delete: 'Any', - Describe: 'Any', - DescribeConfigs: 'Any', - Read: 'Any', - Write: 'Any', - }; - - for (const op of topicOperations) { - const entryForOp = items.find((x) => x.operation === op); - if (entryForOp) { - topicPermissions[op] = entryForOp.permissionType; - } - } - - let all: AclStrPermission = 'Any'; - const allEntry = items.find((x) => x.operation === 'All'); - if (allEntry && allEntry.permissionType === 'Allow') { - all = 'Allow'; - } - if (allEntry && allEntry.permissionType === 'Deny') { - all = 'Deny'; - } - - const topicAcl: TopicACLs = { - _key: crypto.randomUUID(), - patternType: modelPatternTypeToUIType(first.resourcePatternType, selector), - selector, - permissions: topicPermissions, - all, - }; - - topicAcls.push(topicAcl); - } - - return topicAcls; -} - -function collectConsumerGroupAcls(acls: AclFlat[]): ConsumerGroupACLs[] { - const consumerGroups = acls - .filter((x) => x.resourceType === 'Group') - .groupInto((x) => `${x.resourcePatternType}: ${x.resourceName}`); - - const consumerGroupAcls: ConsumerGroupACLs[] = []; - for (const { items } of consumerGroups) { - const first = items[0]; - const selector = first.resourceName; - - const groupOperations = ['Delete', 'Describe', 'Read'] as const; - - const groupPermissions: { [key in (typeof groupOperations)[number]]: AclStrPermission } = { - Delete: 'Any', - Describe: 'Any', - Read: 'Any', - }; - - for (const op of groupOperations) { - const entryForOp = items.find((x) => x.operation === op); - if (entryForOp) { - groupPermissions[op] = entryForOp.permissionType; - } - } - - let all: AclStrPermission = 'Any'; - const allEntry = items.find((x) => x.operation === 'All'); - if (allEntry && allEntry.permissionType === 'Allow') { - all = 'Allow'; - } - if (allEntry && allEntry.permissionType === 'Deny') { - all = 'Deny'; - } - - const groupAcl: ConsumerGroupACLs = { - _key: crypto.randomUUID(), - patternType: modelPatternTypeToUIType(first.resourcePatternType, selector), - selector, - permissions: groupPermissions, - all, - }; - - consumerGroupAcls.push(groupAcl); - } - - return consumerGroupAcls; -} - -function collectTransactionalIdAcls(acls: AclFlat[]): TransactionalIdACLs[] { - const transactionalIds = acls - .filter((x) => x.resourceType === 'TransactionalID') - .groupInto((x) => `${x.resourcePatternType}: ${x.resourceName}`); - - const transactionalIdAcls: TransactionalIdACLs[] = []; - for (const { items } of transactionalIds) { - const first = items[0]; - const selector = first.resourceName; - - const transactionalIdOperations = ['Describe', 'Write'] as const; - - const transactionalIdPermissions: { [key in (typeof transactionalIdOperations)[number]]: AclStrPermission } = { - Describe: 'Any', - Write: 'Any', - }; - - for (const op of transactionalIdOperations) { - const entryForOp = items.find((x) => x.operation === op); - if (entryForOp) { - transactionalIdPermissions[op] = entryForOp.permissionType; - } - } - - let all: AclStrPermission = 'Any'; - const allEntry = items.find((x) => x.operation === 'All'); - if (allEntry && allEntry.permissionType === 'Allow') { - all = 'Allow'; - } - if (allEntry && allEntry.permissionType === 'Deny') { - all = 'Deny'; - } - - const groupAcl: TransactionalIdACLs = { - _key: crypto.randomUUID(), - patternType: modelPatternTypeToUIType(first.resourcePatternType, selector), - selector, - permissions: transactionalIdPermissions, - all, - }; - - transactionalIdAcls.push(groupAcl); - } - - return transactionalIdAcls; -} - -function collectClusterAcls(acls: AclFlat[]): ClusterACLs { - const flatClusterAcls = acls.filter((x) => x.resourceType === 'Cluster'); - - const clusterOperations = [ - 'Alter', - 'AlterConfigs', - 'ClusterAction', - 'Create', - 'Describe', - 'DescribeConfigs', - ] as const; - - const clusterPermissions: { [key in (typeof clusterOperations)[number]]: AclStrPermission } = { - Alter: 'Any', - AlterConfigs: 'Any', - ClusterAction: 'Any', - Create: 'Any', - Describe: 'Any', - DescribeConfigs: 'Any', - }; - - for (const op of clusterOperations) { - const entryForOp = flatClusterAcls.find((x) => x.operation === op); - if (entryForOp) { - clusterPermissions[op] = entryForOp.permissionType; - } - } - - let all: AclStrPermission = 'Any'; - const allEntry = flatClusterAcls.find((x) => x.operation === 'All'); - if (allEntry && allEntry.permissionType === 'Allow') { - all = 'Allow'; - } - if (allEntry && allEntry.permissionType === 'Deny') { - all = 'Deny'; - } - - const clusterAcls: ClusterACLs = { - permissions: clusterPermissions, - all, - }; - - return clusterAcls; -} - -export const principalGroupsView = { - get flatAcls() { - const acls = api.ACLs; - if (!acls?.aclResources || acls.aclResources.length === 0) { - return []; - } - - const flattened: AclFlat[] = []; - for (const res of acls.aclResources) { - for (const rule of res.acls) { - const flattenedEntry: AclFlat = { - resourceType: res.resourceType, - resourceName: res.resourceName, - resourcePatternType: res.resourcePatternType, - - principal: rule.principal, - host: rule.host, - operation: rule.operation, - permissionType: rule.permissionType, - }; - - flattened.push(flattenedEntry); - } - } - - return flattened; - }, - - get principalGroups(): AclPrincipalGroup[] { - const flat = this.flatAcls; - - const g = flat.groupInto((f) => { - const groupingKey = `${f.principal ?? 'Any'} ${f.host ?? 'Any'}`; - return groupingKey; - }); - - const result: AclPrincipalGroup[] = []; - - for (const { items } of g) { - const { principal, host } = items[0]; - - let principalType: PrincipalType; - let principalName: string; - if (principal?.includes(':')) { - const split = principal.split(':', 2); - principalType = split[0] as PrincipalType; - principalName = split[1]; - } else { - principalType = 'User'; - principalName = principal; - } - - const principalGroup: AclPrincipalGroup = { - principalType, - principalName, - host, - - topicAcls: collectTopicAcls(items), - consumerGroupAcls: collectConsumerGroupAcls(items), - clusterAcls: collectClusterAcls(items), - transactionalIdAcls: collectTransactionalIdAcls(items), - - sourceEntries: items, - }; - result.push(principalGroup); - } - - // Add service accounts that exist but have no associated acl rules - const serviceAccounts = api.serviceAccounts?.users; - if (serviceAccounts) { - for (const acc of serviceAccounts) { - if (!result.any((group) => group.principalName === acc)) { - // Doesn't have a group yet, create one - result.push({ - principalType: 'User', - host: '', - principalName: acc, - topicAcls: [createEmptyTopicAcl()], - consumerGroupAcls: [createEmptyConsumerGroupAcl()], - transactionalIdAcls: [createEmptyTransactionalIdAcl()], - clusterAcls: createEmptyClusterAcl(), - sourceEntries: [], - }); - } - } - } - - return result; - }, -}; - -/* - Sooner or later you want to go back from an 'AclPrincipalGroup' to flat ACLs. - Why? Because you'll need to call the remove/create acl apis and those only work with flat acls. - Use this method to convert your principal group back to a list of flat acls. -*/ -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complexity 52, refactor later -export function unpackPrincipalGroup(group: AclPrincipalGroup): AclFlat[] { - const flat: AclFlat[] = []; - - const principal = `${group.principalType}:${group.principalName}`; - const host = group.host || '*'; - - for (const topic of group.topicAcls) { - if (!topic.selector) { - continue; - } - - // If the user selects 'Any' in the ui, we need to submit pattern type "Literal" and "*" as resourceName - const resourcePatternType = topic.patternType === 'Any' ? 'Literal' : topic.patternType; - const resourceName = topic.selector; - - if (topic.all === 'Allow' || topic.all === 'Deny') { - const e: AclFlat = { - principal, - host, - - resourceType: 'Topic', - resourcePatternType, - resourceName, - - operation: 'All', - permissionType: topic.all, - }; - flat.push(e); - continue; - } - - for (const [key, permission] of Object.entries(topic.permissions)) { - const operation = key as AclStrOperation; - - if (permission !== 'Allow' && permission !== 'Deny') { - continue; - } - - const e: AclFlat = { - principal, - host, - - resourceType: 'Topic', - resourceName, - resourcePatternType, - - operation, - permissionType: permission, - }; - flat.push(e); - } - } - - for (const consumerGroup of group.consumerGroupAcls) { - if (!consumerGroup.selector) { - continue; - } - - // If the user selects 'Any' in the ui, we need to submit pattern type "Literal" and "*" as resourceName - const resourcePatternType = consumerGroup.patternType === 'Any' ? 'Literal' : consumerGroup.patternType; - const resourceName = consumerGroup.selector; - - if (consumerGroup.all === 'Allow' || consumerGroup.all === 'Deny') { - const e: AclFlat = { - principal, - host, - - resourceType: 'Group', - resourcePatternType, - resourceName, - - operation: 'All', - permissionType: consumerGroup.all, - }; - flat.push(e); - continue; - } - - for (const [key, permission] of Object.entries(consumerGroup.permissions)) { - const operation = key as AclStrOperation; - - if (permission !== 'Allow' && permission !== 'Deny') { - continue; - } - - const e: AclFlat = { - principal, - host, - - resourceType: 'Group', - resourceName, - resourcePatternType, - - operation, - permissionType: permission, - }; - flat.push(e); - } - } - - for (const transactionalId of group.transactionalIdAcls) { - if (!transactionalId.selector) { - continue; - } - - // If the user selects 'Any' in the ui, we need to submit pattern type "Literal" and "*" as resourceName - const resourcePatternType = transactionalId.patternType === 'Any' ? 'Literal' : transactionalId.patternType; - const resourceName = transactionalId.selector; - - if (transactionalId.all === 'Allow' || transactionalId.all === 'Deny') { - const e: AclFlat = { - principal, - host, - - resourceType: 'TransactionalID', - resourcePatternType, - resourceName, - - operation: 'All', - permissionType: transactionalId.all, - }; - flat.push(e); - continue; - } - - for (const [key, permission] of Object.entries(transactionalId.permissions)) { - const operation = key as AclStrOperation; - - if (permission !== 'Allow' && permission !== 'Deny') { - continue; - } - - const e: AclFlat = { - principal, - host, - - resourceType: 'TransactionalID', - resourceName, - resourcePatternType, - - operation, - permissionType: permission, - }; - flat.push(e); - } - } - - if (group.clusterAcls.all === 'Allow' || group.clusterAcls.all === 'Deny') { - const e: AclFlat = { - principal, - host, - - resourceType: 'Cluster', - resourceName: 'kafka-cluster', - resourcePatternType: 'Literal', - - operation: 'All', - permissionType: group.clusterAcls.all, - }; - flat.push(e); - } else { - for (const [key, permission] of Object.entries(group.clusterAcls.permissions)) { - const operation = key as AclStrOperation; - if (permission !== 'Allow' && permission !== 'Deny') { - continue; - } - - const e: AclFlat = { - principal, - host, - - resourceType: 'Cluster', - resourceName: 'kafka-cluster', - resourcePatternType: 'Literal', - - operation, - permissionType: permission, - }; - flat.push(e); - } - } - - return flat; -} diff --git a/frontend/src/components/pages/acls/new-acl/acl-create-page.tsx b/frontend/src/components/pages/acls/new-acl/acl-create-page.tsx deleted file mode 100644 index 48fb28a0d1..0000000000 --- a/frontend/src/components/pages/acls/new-acl/acl-create-page.tsx +++ /dev/null @@ -1,87 +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 { - convertRulesToCreateACLRequests, - handleResponses, - type PrincipalType, - PrincipalTypeGroup, - PrincipalTypeRedpandaRole, - PrincipalTypeUser, - type Rule, -} from 'components/pages/acls/new-acl/acl.model'; -import CreateACL from 'components/pages/acls/new-acl/create-acl'; -import { parsePrincipalFromParam } from 'components/pages/acls/new-acl/principal-utils'; -import { useEffect } from 'react'; -import { uiState } from 'state/ui-state'; - -import { useCreateAcls } from '../../../../react-query/api/acl'; -import PageContent from '../../../misc/page-content'; - -const principalTypeMap: Record = { - redpandarole: PrincipalTypeRedpandaRole, - user: PrincipalTypeUser, - group: PrincipalTypeGroup, -}; - -const AclCreatePage = () => { - const navigate = useNavigate({ from: '/security/acls/create' }); - const search = routeApi.useSearch(); - - const principalTypeParam = search.principalType?.toLowerCase(); - const principalName = search.principalName; - const principalType = principalTypeParam ? principalTypeMap[principalTypeParam] : undefined; - - const sharedConfig = - principalName && principalType ? { principal: `${principalType}${principalName}`, host: '*' } : undefined; - - useEffect(() => { - uiState.pageBreadcrumbs = [ - { title: 'Security', linkTo: '/security' }, - { title: 'ACLs', linkTo: '/security/acls' }, - { title: 'Create ACL', linkTo: '' }, - ]; - }, []); - - 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, principalName } = parsePrincipalFromParam(principal); - const aclName = principalType === 'User' ? principalName : principal; - navigate({ - to: '/security/acls/$aclName/details', - params: { aclName }, - search: { host: undefined }, - }); - }; - - return ( - - navigate({ to: '/security/$tab', params: { tab: 'acls' } })} - onSubmit={createAclMutation} - principalType={principalType} - sharedConfig={sharedConfig} - /> - - ); -}; - -export default AclCreatePage; diff --git a/frontend/src/components/pages/acls/new-acl/acl-detail-page.test.tsx b/frontend/src/components/pages/acls/new-acl/acl-detail-page.test.tsx deleted file mode 100644 index 4a53415545..0000000000 --- a/frontend/src/components/pages/acls/new-acl/acl-detail-page.test.tsx +++ /dev/null @@ -1,139 +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 { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; - -import type { AclDetail } from './acl.model'; - -// --------------------------------------------------------------------------- -// Mocks -// --------------------------------------------------------------------------- - -const { mockUseGetAclsByPrincipal, mockNavigate } = vi.hoisted(() => ({ - mockUseGetAclsByPrincipal: vi.fn(), - mockNavigate: vi.fn(), -})); - -vi.mock('react-query/api/acl', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, useGetAclsByPrincipal: mockUseGetAclsByPrincipal }; -}); - -// Control params/search per-test via module-level variables -let currentAclName = ''; -let currentHost: string | undefined; - -vi.mock('@tanstack/react-router', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getRouteApi: () => ({ - useParams: () => ({ aclName: currentAclName }), - useSearch: () => ({ host: currentHost }), - }), - useNavigate: () => mockNavigate, - }; -}); - -vi.mock('state/ui-state', () => ({ - uiState: { pageBreadcrumbs: [] }, -})); - -vi.mock('./acl-details', () => ({ - ACLDetails: () =>
, -})); - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -const singleHostAclDetail = (principal: string): AclDetail => ({ - sharedConfig: { principal, host: '*' }, - rules: [], -}); - -/** Return value that shows the detail view (single host, no HostSelector). */ -const aclDetailResult = (principal: string) => ({ - data: [singleHostAclDetail(principal)], - isLoading: false, -}); - -/** Return value that keeps the component in "loading" state — useful when we - * only care about the call arguments, not what gets rendered. */ -const loadingResult = { data: undefined, isLoading: true }; - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('AclDetailPage — principal URL encoding', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockUseGetAclsByPrincipal.mockReturnValue(loadingResult); - }); - - test('Group principal in URL is passed to the API as-is', async () => { - currentAclName = 'Group:mygroup'; - currentHost = '*'; - - const { default: AclDetailPage } = await import('./acl-detail-page'); - render(); - - await waitFor(() => { - expect(mockUseGetAclsByPrincipal).toHaveBeenCalledWith('Group:mygroup', '*'); - }); - }); - - test('bare User name (no prefix) defaults to User: principal type', async () => { - currentAclName = 'alice'; - currentHost = '*'; - - const { default: AclDetailPage } = await import('./acl-detail-page'); - render(); - - await waitFor(() => { - expect(mockUseGetAclsByPrincipal).toHaveBeenCalledWith('User:alice', '*'); - }); - }); - - test('explicit User: prefix is preserved correctly', async () => { - currentAclName = 'User:alice'; - currentHost = '*'; - - const { default: AclDetailPage } = await import('./acl-detail-page'); - render(); - - await waitFor(() => { - expect(mockUseGetAclsByPrincipal).toHaveBeenCalledWith('User:alice', '*'); - }); - }); - - test('Edit button navigates to update page preserving aclName and host for Group principal', async () => { - currentAclName = 'Group:mygroup'; - currentHost = '*'; - mockUseGetAclsByPrincipal.mockReturnValue(aclDetailResult('Group:mygroup')); - - const { default: AclDetailPage } = await import('./acl-detail-page'); - 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: '*' }, - }); - }); - }); -}); diff --git a/frontend/src/components/pages/acls/new-acl/acl-detail-page.tsx b/frontend/src/components/pages/acls/new-acl/acl-detail-page.tsx deleted file mode 100644 index fe0ef2e774..0000000000 --- a/frontend/src/components/pages/acls/new-acl/acl-detail-page.tsx +++ /dev/null @@ -1,84 +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/$aclName/details'); - -import { Pencil } from 'lucide-react'; -import { useEffect } from 'react'; -import { uiState } from 'state/ui-state'; - -import { ACLDetails } from './acl-details'; -import { HostSelector } from './host-selector'; -import { parsePrincipalFromParam } from './principal-utils'; -import { useGetAclsByPrincipal } from '../../../../react-query/api/acl'; -import { Button } from '../../../redpanda-ui/components/button'; -import { Text } from '../../../redpanda-ui/components/typography'; - -const AclDetailPage = () => { - const { aclName } = routeApi.useParams(); - const navigate = useNavigate({ from: '/security/acls/$aclName/details' }); - const search = routeApi.useSearch(); - const host = search.host || undefined; - - const { principalType, principalName } = parsePrincipalFromParam(aclName); - const { data, isLoading } = useGetAclsByPrincipal(`${principalType}:${principalName}`, host); - - const [acls, ...hosts] = data || []; - - useEffect(() => { - uiState.pageBreadcrumbs = [ - { title: 'Security', linkTo: '/security' }, - { title: 'ACLs', linkTo: '/security/acls' }, - { title: principalName, linkTo: `/security/acls/${aclName}/details` }, - { title: 'ACL Configuration Details', linkTo: '', heading: '' }, - ]; - }, [aclName, principalName]); - - if (isLoading) { - return
Loading...
; - } - - if (!(acls && data)) { - return
No ACL data found
; - } - - if (hosts.length > 0) { - return ; - } - - return ( -
-
- - ACL Configuration Details for {principalName} - - -
- -
- ); -}; - -export default AclDetailPage; diff --git a/frontend/src/components/pages/acls/new-acl/acl-details.tsx b/frontend/src/components/pages/acls/new-acl/acl-details.tsx deleted file mode 100644 index b4320e1f32..0000000000 --- a/frontend/src/components/pages/acls/new-acl/acl-details.tsx +++ /dev/null @@ -1,118 +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 { useNavigate } from '@tanstack/react-router'; -import { Button } from 'components/redpanda-ui/components/button'; -import { Card, CardContent, CardHeader, CardTitle } from 'components/redpanda-ui/components/card'; - -import { getRuleDataTestId, parsePrincipal, type Rule, type SharedConfig } from './acl.model'; -import { OperationsBadge } from './operations-badge'; - -type ACLDetailsProps = { - sharedConfig: { - principal: string; - host: string; - }; - rules: Rule[]; - isSimpleView?: boolean; // this prop show SharedConfig, this is used for emmbedded this component on legacy user page -}; - -export function ACLDetails({ sharedConfig, rules, isSimpleView = false }: ACLDetailsProps) { - const navigate = useNavigate(); - - const getGoTo = (sc: SharedConfig) => { - if (sc.principal.startsWith('User:')) { - return `/security/acls/${parsePrincipal(sc.principal).name}/details`; - } - if (sc.principal.startsWith('RedpandaRole:')) { - return `/security/roles/${parsePrincipal(sc.principal).name}/details`; - } - return ''; - }; - - const data = { - sharedConfig, - rules, - principalType: parsePrincipal(sharedConfig.principal).type, - hostType: sharedConfig.host === '*' ? 'Allow all hosts' : 'Specific host', - }; - - return ( -
- {/* Main Content */} -
-
-
- {/* Left Column - Configuration Details */} -
- {/* Shared Configuration */} - - - - Shared configuration - - - -
-
-
Principal
-
- {data.principalType}:{' '} - {parsePrincipal(data.sharedConfig.principal).name} -
-
-
-
Host
-
- {data.hostType}:{' '} - {data.sharedConfig.host} -
-
-
-
-
- - {/* Rules */} - - - - {isSimpleView ? ( - - ) : ( - `ACL rules (${data.rules.length})` - )} - - - - {data.rules.length === 0 ? ( -
No permissions configured
- ) : ( - data.rules.map((rule: Rule) => ( -
- -
- )) - )} -
-
-
-
-
-
-
- ); -} diff --git a/frontend/src/components/pages/acls/new-acl/acl-update-page.tsx b/frontend/src/components/pages/acls/new-acl/acl-update-page.tsx deleted file mode 100644 index 31307ef586..0000000000 --- a/frontend/src/components/pages/acls/new-acl/acl-update-page.tsx +++ /dev/null @@ -1,147 +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 { - getOperationsForResourceType, - handleResponses, - ModeAllowAll, - ModeDenyAll, - OperationTypeAllow, - OperationTypeDeny, - type PrincipalType, - PrincipalTypeGroup, - PrincipalTypeRedpandaRole, - PrincipalTypeUser, - type Rule, -} from 'components/pages/acls/new-acl/acl.model'; -import CreateACL from 'components/pages/acls/new-acl/create-acl'; -import { HostSelector } from 'components/pages/acls/new-acl/host-selector'; -import { useEffect } from 'react'; - -import { parsePrincipalFromParam } from './principal-utils'; -import { useGetAclsByPrincipal, useUpdateAclMutation } from '../../../../react-query/api/acl'; -import { uiState } from '../../../../state/ui-state'; -import PageContent from '../../../misc/page-content'; - -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); - - useEffect(() => { - uiState.pageBreadcrumbs = [ - { title: 'Security', linkTo: '/security' }, - { title: 'ACLs', linkTo: '/security/acls' }, - { title: principalName, linkTo: `/security/acls/${aclName}/details` }, - { title: 'Update ACL', linkTo: '', heading: '' }, - ]; - }, [aclName, principalName]); - - // 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 ( - - - 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/acls/new-acl/autocomplete-input.tsx b/frontend/src/components/pages/acls/new-acl/autocomplete-input.tsx deleted file mode 100644 index 394143f039..0000000000 --- a/frontend/src/components/pages/acls/new-acl/autocomplete-input.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { Command, CommandGroup, CommandItem, CommandList } from 'components/redpanda-ui/components/command'; -import { Input } from 'components/redpanda-ui/components/input'; -import { type ChangeEvent, useState } from 'react'; - -const EMPTY_SUGGESTIONS: string[] = []; - -type AutocompleteInputProps = { - value: string; - onChange: (value: string) => void; - placeholder?: string; - className?: string; - suggestions?: string[]; - 'data-testid'?: string; -}; - -export function AutocompleteInput({ - value, - onChange, - placeholder, - className, - suggestions = EMPTY_SUGGESTIONS, - 'data-testid': dataTestId, -}: AutocompleteInputProps) { - const [filteredSuggestions, setFilteredSuggestions] = useState([]); - const [showSuggestions, setShowSuggestions] = useState(false); - - const handleInputChange = (e: ChangeEvent) => { - const newValue = e.target.value; - onChange(newValue); - - if (newValue.length > 0 && suggestions.length > 0) { - const filtered = suggestions.filter((suggestion) => suggestion.toLowerCase().includes(newValue.toLowerCase())); - setFilteredSuggestions(filtered); - setShowSuggestions(filtered.length > 0); - } else { - setShowSuggestions(false); - } - }; - - const handleSelectSuggestion = (suggestion: string) => { - onChange(suggestion); - setShowSuggestions(false); - }; - - return ( -
- setTimeout(() => setShowSuggestions(false), 200)} - onChange={handleInputChange} - onFocus={() => { - if (value.length > 0 && suggestions.length > 0) { - const filtered = suggestions.filter((suggestion) => suggestion.toLowerCase().includes(value.toLowerCase())); - setFilteredSuggestions(filtered); - setShowSuggestions(filtered.length > 0); - } - }} - placeholder={placeholder} - testId={dataTestId} - type="text" - value={value} - /> - - {showSuggestions && filteredSuggestions.length > 0 && ( -
- - - - {filteredSuggestions.map((suggestion) => ( - handleSelectSuggestion(suggestion)} - > - {suggestion} - - ))} - - - -
- )} -
- ); -} diff --git a/frontend/src/components/pages/acls/new-acl/create-acl.tsx b/frontend/src/components/pages/acls/new-acl/create-acl.tsx deleted file mode 100644 index a71e714961..0000000000 --- a/frontend/src/components/pages/acls/new-acl/create-acl.tsx +++ /dev/null @@ -1,1131 +0,0 @@ -/** biome-ignore-all lint/correctness/useUniqueElementIds: this is intentional for form usage */ - -'use no memo'; - -import { Button } from 'components/redpanda-ui/components/button'; -import { - Card, - CardContent, - CardDescription, - CardField, - CardForm, - CardHeader, - CardTitle, -} from 'components/redpanda-ui/components/card'; -import { Input } from 'components/redpanda-ui/components/input'; -import { Label } from 'components/redpanda-ui/components/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from 'components/redpanda-ui/components/select'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'components/redpanda-ui/components/tooltip'; -import { Check, Circle, HelpCircle, Plus, Trash2, X } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; -import { useApiStoreHook } from 'state/backend-api'; -import { useSupportedFeaturesStore } from 'state/supported-features'; - -import { - type AclRulesProps, - getIdFromRule, - getOperationsForResourceType, - getRuleDataTestId, - type HostType, - HostTypeAllowAllHosts, - HostTypeSpecificHost, - ModeAllowAll, - ModeCustom, - ModeDenyAll, - type ModeType, - type OperationType, - OperationTypeAllow, - OperationTypeDeny, - OperationTypeNotSet, - type PrincipalType, - parsePrincipal, - type ResourcePatternType, - ResourcePatternTypeAny, - ResourcePatternTypeLiteral, - ResourcePatternTypePrefix, - type ResourceType, - ResourceTypeCluster, - ResourceTypeConsumerGroup, - ResourceTypeSchemaRegistry, - type ResourceTypeSelectionProps, - ResourceTypeSubject, - ResourceTypeTopic, - ResourceTypeTransactionalId, - RoleTypeGroup, - RoleTypeRedpandaRole, - RoleTypeUser, - type Rule, - type SharedConfig, - type SharedConfigProps, - type SummaryProps, -} from './acl.model'; - -const UNDERSCORE_REGEX = /_/g; -const FIRST_CHAR_REGEX = /^\w/; -const COLON_REGEX = /:/; -const PRINCIPAL_PREFIX_REGEX = /^[^:]+:/; - -// Helper function to convert UPPER_CASE strings to sentence case ex: ALTER => Alter ALTER_CONFIG => Alter config -export const formatLabel = (text: string): string => { - return text - .replace(UNDERSCORE_REGEX, ' ') - .toLowerCase() - .replace(FIRST_CHAR_REGEX, (c) => c.toUpperCase()) - .replace('id', 'ID'); // transactional ids => transactional IDs -}; - -// Helper function to format summary labels, keeping certain words capitalized -export const formatSummaryLabel = (text: string): string => { - return text.replace('id', 'ID'); // transactional ids => transactional IDs -}; - -// Helper function to get resource name for selector label -const getResourceName = (resourceType: ResourceType): string => { - const resourceNames: Record = { - [ResourceTypeTopic]: 'Topic', - [ResourceTypeConsumerGroup]: 'Consumer group', - [ResourceTypeTransactionalId]: 'Transactional ID', - [ResourceTypeSubject]: 'Subject', - [ResourceTypeSchemaRegistry]: 'Schema registry', - }; - return resourceNames[resourceType] || resourceType; -}; - -// Helper function to get plural resource name -const getPluralResourceName = (resourceType: ResourceType): string => { - const pluralNames: Record = { - [ResourceTypeCluster]: 'clusters', - [ResourceTypeTopic]: 'topics', - [ResourceTypeConsumerGroup]: 'consumer groups', - [ResourceTypeTransactionalId]: 'transactional IDs', - [ResourceTypeSubject]: 'subjects', - [ResourceTypeSchemaRegistry]: 'schema registries', - }; - return pluralNames[resourceType] || resourceType; -}; - -function stringToHostType(h: string): HostType { - switch (h) { - case '*': - case '': - return HostTypeAllowAllHosts; - default: - return HostTypeSpecificHost; - } -} - -const ResourceTypeSelection = ({ - rule, - handleResourceTypeChange, - isClusterDisabledForRule, - isSchemaRegistryDisabledForRule, - isSchemaRegistryEnabled, - ruleIndex, - getSchemaRegistryTooltipText, - getSubjectTooltipText, -}: ResourceTypeSelectionProps) => { - const buttons = [ - { - name: 'Cluster', - resourceType: ResourceTypeCluster, - disabled: isClusterDisabledForRule(rule.id), - tooltipText: 'Only one cluster rule is allowed. A cluster rule already exists.', - }, - { - name: 'Topic', - resourceType: ResourceTypeTopic, - }, - { - name: 'Consumer Group', - resourceType: ResourceTypeConsumerGroup, - }, - { - name: 'Transactional ID', - resourceType: ResourceTypeTransactionalId, - }, - { - name: 'Subject', - resourceType: ResourceTypeSubject, - disabled: !isSchemaRegistryEnabled, - tooltipText: getSubjectTooltipText(), - }, - { - name: 'Schema Registry', - resourceType: ResourceTypeSchemaRegistry, - disabled: !isSchemaRegistryEnabled || isSchemaRegistryDisabledForRule(rule.id), - tooltipText: getSchemaRegistryTooltipText(), - }, - ]; - - return ( - - {/*TODO: Add tooltip with

Only one cluster rule is allowed. A cluster rule already exists.

*/} - {buttons.map(({ name, resourceType, disabled, tooltipText }) => ( - - - - - - - - - ))} -
- ); -}; - -const Summary = ({ sharedConfig, rules }: SummaryProps) => { - return ( - - -

Summary

-
- - {/* Shared Configuration Summary */} -
-
-
- Principal: - {sharedConfig.principal.replace(COLON_REGEX, ': ')} -
-
- Host: - - {sharedConfig.host === '*' ? 'All hosts' : sharedConfig.host} - -
-
-
- - {/* Rules Summary */} -
- {/** biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complex business logic */} - {rules.map((rule) => { - const ops = Object.entries(rule.operations); - // Filter out operations that are not set - const enabledOperations = ops - .filter(([_, operationValue]) => operationValue !== OperationTypeNotSet) - .map(([op, operationValue]) => ({ - name: formatLabel(op), - value: operationValue, - originalOperationName: op, - })); - - // Check if all operations have the same permission - const allAllow = - enabledOperations.length > 0 && - enabledOperations.length === ops.length && - enabledOperations.every((op) => op.value === OperationTypeAllow); - const allDeny = - enabledOperations.length > 0 && - enabledOperations.length === ops.length && - enabledOperations.every((op) => op.value === OperationTypeDeny); - const showSummary = allAllow || allDeny; - - return ( -
- {/* Combined Resource and Selector */} -

- {(() => { - let text: string; - if (rule.resourceType === ResourceTypeCluster || rule.resourceType === ResourceTypeSchemaRegistry) { - text = getResourceName(rule.resourceType); - } else if (rule.selectorType === ResourcePatternTypeAny) { - text = `All ${getPluralResourceName(rule.resourceType)}`; - } else { - const matchType = rule.selectorType === ResourcePatternTypeLiteral ? 'matching' : 'starting with'; - text = `${getPluralResourceName(rule.resourceType)} ${matchType}: "${rule.selectorValue}"`; - } - return formatSummaryLabel(text); - })()} -

- - {/* Operations */} -
- {enabledOperations.length > 0 ? ( -
- {showSummary ? ( - - {allAllow ? 'Allow all' : 'Deny all'} - - ) : ( - enabledOperations.map((op) => ( - - {op.name}: {op.value} - - )) - )} -
- ) : ( - No operations configured - )} -
-
- ); - })} -
-
-
- ); -}; - -const AclRules = ({ - rules, - addRule, - addAllowAllOperations, - removeRule, - updateRule, - handleResourceTypeChange, - isClusterDisabledForRule, - isSchemaRegistryDisabledForRule, - isSubjectDisabledForRule, - getSchemaRegistryTooltipText, - getSubjectTooltipText, - handlePermissionModeChange, - handleOperationChange, - getPermissionIcon, - getPermissionDescription, - schemaRegistryEnabled, -}: AclRulesProps) => { - return ( - - -
-
- ACL rules - - Configure permissions for different resource types. - -
- -
-
- - {rules.map((rule, index) => ( - - - {/* Resource Type Selection */} -
- - - - - -
- - {/* Resource Name Selector */} - {/* Show selector if resource type is not Cluster or SchemaRegistry */} - {!(rule.resourceType === ResourceTypeCluster || rule.resourceType === ResourceTypeSchemaRegistry) && ( -
- -
- - {(rule.selectorType === ResourcePatternTypeLiteral || - rule.selectorType === ResourcePatternTypePrefix) && ( -
- - updateRule(rule.id, { - selectorValue: e.target.value, - }) - } - placeholder={`Enter ${rule.selectorType} match`} - testId={`selector-value-input-${index}`} - value={rule.selectorValue} - /> -

- {rule.selectorType === ResourcePatternTypeLiteral - ? 'Literal match cannot be empty' - : 'Prefix match cannot be empty'} -

-
- )} -
-
- )} - - {/* Permission Mode */} -
-
- -
- - - -
-
-
- - {/* Operations */} -
- {Object.entries(rule.operations).map(([operation, operationValue]) => ( -
- - - - - - - -

{getPermissionDescription(operation, rule.resourceType)}

-
-
-
-
- ))} -
-
-
- ))} - - {/* Add Rule Button */} -
- -
-
-
- ); -}; - -const SharedConfiguration = ({ - sharedConfig, - setSharedConfig, - edit, - principalType: propPrincipalType, - principalError, -}: SharedConfigProps & { principalType?: PrincipalType; principalError?: string }) => { - const [principalType, setPrincipalType] = useState( - propPrincipalType ? propPrincipalType.replace(':', '') : parsePrincipal(sharedConfig.principal).type || RoleTypeUser - ); - const [hostType, setHostType] = useState(() => stringToHostType(sharedConfig.host)); - const enterpriseFeaturesUsed = useApiStoreHook((s) => s.enterpriseFeaturesUsed); - const gbacEnabled = enterpriseFeaturesUsed.some((f) => f.name === 'gbac' && f.enabled); - - return ( - - - Shared configuration - These settings apply to all rules. - - - - -
- - - - - - - -
-

- The user getting permissions granted or denied. In Kafka, this user is known as the principal. -

-
    -
  • • Use the wildcard * to target all users.
  • -
  • • Do not include the prefix. For example, use my-user instead of User:my-user.
  • -
-
-
-
-
-
-
- -
- - setSharedConfig({ - ...sharedConfig, - principal: `${principalType}:${e.target.value}`, - }) - } - placeholder="analytics-writer" - testId="shared-principal-input" - value={sharedConfig.principal.replace(PRINCIPAL_PREFIX_REGEX, '')} - /> - {Boolean(principalError) && ( -

- {principalError} -

- )} -
-
-
-
-
- -
-
-
- - - - - - - -

- The IP address or hostname from which the user is allowed or denied access. Use * to allow from - any host. -

-
-
-
-
-
- - {hostType === HostTypeSpecificHost && ( - - setSharedConfig({ - ...sharedConfig, - host: e.target.value, - }) - } - placeholder="1.1.1.1" - testId="shared-host-input" - value={sharedConfig.host === '*' ? '' : sharedConfig.host} - /> - )} -
-
-
-
-
- ); -}; - -type CreateACLProps = { - onSubmit?: (principal: string, host: string, rules: Rule[]) => Promise; - onCancel: () => void; - rules?: Rule[]; - sharedConfig?: SharedConfig; - edit: boolean; - principalType?: PrincipalType; -}; - -export default function CreateACL({ - onSubmit, - onCancel, - rules: propRules, - sharedConfig: propSharedConfig, - edit, - principalType, -}: CreateACLProps) { - const schemaRegistryEnabled = useSupportedFeaturesStore((s) => s.schemaRegistryACLApi); - const [principalError, setPrincipalError] = useState(''); - const [sharedConfig, setSharedConfig] = useState({ - principal: propSharedConfig?.principal ?? (principalType ? `${principalType}` : 'User:'), - host: propSharedConfig?.host ?? '*', - }); - - const ruleIdCounter = useRef(2); - const [rules, setRules] = useState( - propRules ?? [ - { - id: 1, - resourceType: ResourceTypeCluster, - mode: ModeCustom, - selectorType: ResourcePatternTypeAny, - selectorValue: '', - operations: { - ALTER: OperationTypeNotSet, - ALTER_CONFIGS: OperationTypeNotSet, - CLUSTER_ACTION: OperationTypeNotSet, - CREATE: OperationTypeNotSet, - DESCRIBE: OperationTypeNotSet, - DESCRIBE_CONFIGS: OperationTypeNotSet, - IDEMPOTENT_WRITE: OperationTypeNotSet, - }, - }, - ] - ); - - const isValidRule = rules.find((r) => Object.entries(r.operations).some(([, o]) => o !== OperationTypeNotSet)); - - useEffect(() => { - if (parsePrincipal(sharedConfig.principal).type) { - queueMicrotask(() => setPrincipalError('')); - } - }, [sharedConfig.principal]); - - const addRule = () => { - // Determine the default resource type for new rules - let defaultResourceType = ResourceTypeCluster; - if (hasClusterRule()) { - defaultResourceType = ResourceTypeTopic; - } - - const newRule = { - id: ruleIdCounter.current++, - resourceType: defaultResourceType as ResourceType, - mode: ModeCustom, - selectorType: ResourcePatternTypeAny, - selectorValue: '', - operations: getOperationsForResourceType(defaultResourceType), - }; - setRules([...rules, newRule]); - }; - - const addAllowAllOperations = () => { - const resourceTypes = [ - ResourceTypeCluster, - ResourceTypeTopic, - ResourceTypeConsumerGroup, - ResourceTypeTransactionalId, - ]; - - if (schemaRegistryEnabled) { - resourceTypes.push(ResourceTypeSubject, ResourceTypeSchemaRegistry); - } - - const newRules: Rule[] = []; - - resourceTypes.forEach((resourceType, index) => { - const operations = getOperationsForResourceType(resourceType); - const allowAllOperations = Object.fromEntries(Object.entries(operations).map(([op]) => [op, OperationTypeAllow])); - - newRules.push({ - id: Date.now() + index, - resourceType, - mode: ModeAllowAll, - selectorType: ResourcePatternTypeAny, - selectorValue: '', - operations: { - ...allowAllOperations, - }, - }); - }); - - setRules(newRules); - }; - - const removeRule = (id: number) => { - setRules(rules.filter((rule) => rule.id !== id)); - }; - - const updateRule = (id: number, updates: Partial) => { - setRules(rules.map((rule) => (rule.id === id ? { ...rule, ...updates } : rule))); - }; - - const handleResourceTypeChange = (ruleId: number, resourceType: ResourceType) => { - updateRule(ruleId, { - resourceType, - selectorType: ResourcePatternTypeAny, - selectorValue: '', - operations: getOperationsForResourceType(resourceType), - mode: ModeCustom, - } as Rule); - }; - - const hasClusterRule = () => rules.some((rule) => rule.resourceType === ResourceTypeCluster); - - const isClusterDisabledForRule = (ruleId: number) => { - const currentRule = rules.find((rule) => rule.id === ruleId); - if (!currentRule) { - return false; - } - - // If current rule is already cluster, don't disable it - if (currentRule.resourceType === ResourceTypeCluster) { - return false; - } - - // If there's already a cluster rule, disable cluster for this rule - return hasClusterRule(); - }; - - const hasSubjectRule = () => rules.some((rule) => rule.resourceType === ResourceTypeSubject); - - const hasSchemaRegistryRule = () => rules.some((rule) => rule.resourceType === ResourceTypeSchemaRegistry); - - const isSchemaRegistryDisabledForRule = (ruleId: number) => { - const currentRule = rules.find((rule) => rule.id === ruleId); - if (!currentRule) { - return false; - } - - // If Schema Registry is not enabled, disable it for all rules except existing schema registry rules - if (!schemaRegistryEnabled && currentRule.resourceType !== ResourceTypeSchemaRegistry) { - return true; - } - - // If current rule is already schemaRegistry, don't disable it - if (currentRule.resourceType === ResourceTypeSchemaRegistry) { - return false; - } - - // If there's already a schemaRegistry rule, disable schemaRegistry for this rule - return hasSchemaRegistryRule(); - }; - - const getSchemaRegistryTooltipText = () => { - if (!schemaRegistryEnabled) { - return 'Schema Registry is not enabled.'; - } - return 'Only one schema registry rule is allowed. A schema registry rule already exists.'; - }; - - const getSubjectTooltipText = () => { - if (!schemaRegistryEnabled) { - return 'Schema Registry is not enabled.'; - } - return 'Only one subject rule is allowed. A subject rule already exists.'; - }; - - const isSubjectDisabledForRule = (ruleId: number) => { - const currentRule = rules.find((rule) => rule.id === ruleId); - if (!currentRule) { - return false; - } - - // If current rule is already subject, don't disable it - if (currentRule.resourceType === ResourceTypeSubject) { - return false; - } - - // If schema registry is not enabled, disable subject - if (!schemaRegistryEnabled) { - return true; - } - - // If there's already a subject rule, disable subject for this rule - return hasSubjectRule(); - }; - - const handlePermissionModeChange = (ruleId: number, mode: string) => { - const rule = rules.find((r) => r.id === ruleId); - if (!rule) { - return; - } - - const updatedOperations = Object.fromEntries( - Object.entries(rule.operations).map(([op, operationValue]) => { - let newValue: OperationType; - if (mode === ModeAllowAll) { - newValue = OperationTypeAllow; - } else if (mode === ModeDenyAll) { - newValue = OperationTypeDeny; - } else { - newValue = operationValue; - } - return [op, newValue]; - }) - ) as Record; - - updateRule(ruleId, { mode: mode as ModeType, operations: updatedOperations }); - }; - - const handleOperationChange = (ruleId: number, operation: string, value: string) => { - const rule = rules.find((r) => r.id === ruleId); - if (!rule) { - return; - } - - const updatedOperations = { - ...rule.operations, - [operation]: value as OperationType, - }; - - // Check if the current mode should be switched to custom - let newMode = rule.mode; - if (rule.mode === ModeAllowAll && value !== OperationTypeAllow) { - newMode = ModeCustom; - } else if (rule.mode === ModeDenyAll && value !== OperationTypeDeny) { - newMode = ModeCustom; - } - - updateRule(ruleId, { - operations: updatedOperations, - mode: newMode, - }); - }; - - const getPermissionIcon = (value: string) => { - switch (value) { - case OperationTypeAllow: - return ; - case OperationTypeDeny: - return ; - default: - return ; - } - }; - - const getPermissionDescription = (operation: string, resourceType: string) => { - const descriptions: Record> = { - cluster: { - ALTER: 'Edit cluster-level configuration properties.\nAPIs: AlterConfigs, IncrementalAlterConfigs', - ALTER_CONFIGS: 'Edit cluster configuration properties.\nAPIs: AlterConfigs, IncrementalAlterConfigs', - CLUSTER_ACTION: - 'Perform administrative operations on the cluster.\nAPIs: ControlledShutdown, LeaderAndIsr, UpdateMetadata, StopReplica, etc.', - CREATE: 'Create new topics and cluster-level resources.\nAPIs: CreateTopics, CreateAcls', - DESCRIBE: 'View cluster information and metadata.\nAPIs: DescribeCluster, DescribeConfigs', - DESCRIBE_CONFIGS: 'View cluster configuration properties.\nAPIs: DescribeConfigs', - IDEMPOTENT_WRITE: - 'Perform idempotent write operations to ensure exactly-once delivery.\nAPIs: Produce (with idempotence)', - }, - topic: { - ALTER: 'Edit topic configuration properties.\nAPIs: AlterConfigs, IncrementalAlterConfigs', - ALTER_CONFIGS: 'Edit topic configuration properties.\nAPIs: AlterConfigs, IncrementalAlterConfigs', - CREATE: 'Create new topics.\nAPIs: CreateTopics', - DELETE: 'Delete topics and their data.\nAPIs: DeleteTopics', - DESCRIBE: 'View topic metadata and information.\nAPIs: Metadata, ListOffsets, DescribeConfigs', - DESCRIBE_CONFIGS: 'View topic configuration properties.\nAPIs: DescribeConfigs', - READ: 'View messages from topics.\nAPIs: Fetch, OffsetFetch, ListOffsets', - WRITE: 'Produce messages to topics.\nAPIs: Produce', - }, - consumerGroup: { - DELETE: 'Delete consumer groups.\nAPIs: DeleteGroups', - DESCRIBE: 'View consumer group information and metadata.\nAPIs: DescribeGroups, ListGroups', - READ: 'Join consumer groups and view messages.\nAPIs: JoinGroup, SyncGroup, Heartbeat, OffsetCommit, OffsetFetch', - }, - transactionalId: { - DESCRIBE: 'View transactional ID information.\nAPIs: DescribeTransactions', - WRITE: 'Use transactional ID for exactly-once processing.\nAPIs: InitProducerId, AddPartitionsToTxn, EndTxn', - }, - subject: { - READ: 'View schema subjects and their versions.\nAPIs: GET /subjects, GET /subjects/{subject}/versions', - WRITE: 'Create and update schema subjects.\nAPIs: POST /subjects/{subject}/versions', - REMOVE: - 'Delete schema subjects and their versions.\nAPIs: DELETE /subjects/{subject}, DELETE /subjects/{subject}/versions/{version}', - DESCRIBE_CONFIGS: 'View subject-level configuration properties.\nAPIs: GET /config/{subject}', - ALTER_CONFIGS: 'Edit subject-level configuration properties.\nAPIs: PUT /config/{subject}', - }, - schemaRegistry: { - DESCRIBE_CONFIGS: 'View Schema Registry configuration properties.\nAPIs: GET /config', - ALTER_CONFIGS: 'Edit Schema Registry configuration properties.\nAPIs: PUT /config', - DESCRIBE: 'View Schema Registry information and metadata.\nAPIs: GET /subjects, GET /schemas', - READ: 'View schemas from the Schema Registry.\nAPIs: GET /schemas/ids/{id}, GET /subjects/{subject}/versions/{version}/schema', - }, - }; - - // Render newlines as
in tooltips - const desc = descriptions[resourceType]?.[operation]; - if (!desc) { - return 'Permission description not available'; - } - return ( - <> - {desc.split('\n').map((line) => ( - - {line} -
-
- ))} - - ); - }; - - // Track open state for each matching section per rule and section type - const [openMatchingSections, setOpenMatchingSections] = useState>({}); - - // Helper to get a unique key for each rule and section type - const getSectionKey = (ruleId: number, section: string) => `${ruleId}-${section}`; - - return ( -
- {/* Page Header - Sticky */} - - {/* Main Content */} -
-
-
- {/* Left Column - Main Form */} -
-

Configure access control rules for your Kafka resources.

- - - - -
- - {/* Right Column - Summary Card */} -
-
- -
- - -
-
-
-
-
-
-
- ); -} diff --git a/frontend/src/components/pages/acls/new-acl/host-selector.test.tsx b/frontend/src/components/pages/acls/new-acl/host-selector.test.tsx deleted file mode 100644 index 9f9210ca7d..0000000000 --- a/frontend/src/components/pages/acls/new-acl/host-selector.test.tsx +++ /dev/null @@ -1,182 +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 { fireEvent, renderWithFileRoutes, screen, waitFor } from 'test-utils'; - -import { HostSelector } from './host-selector'; - -const MULTIPLE_HOSTS_PATTERN = /principal has ACLs configured for multiple hosts/i; - -describe('HostSelector', () => { - const defaultProps = { - principalName: 'test-user', - hosts: [ - { sharedConfig: { principal: 'test-user', host: '192.168.1.1' }, rules: [] }, - { sharedConfig: { principal: 'test-user', host: '192.168.1.2' }, rules: [] }, - { sharedConfig: { principal: 'test-user', host: '192.168.1.3' }, rules: [] }, - ], - baseUrl: '/security/acls/test-user/details', - }; - - describe('Rendering', () => { - test('should render card with title, description, and all host rows', () => { - renderWithFileRoutes(); - - // Verify title - expect(screen.getByText('Multiple hosts found')).toBeVisible(); - - // Verify principal name and description - expect(screen.getByTestId('host-selector-principal-name')).toBeVisible(); - expect(screen.getByTestId('host-selector-principal-name')).toHaveTextContent('test-user'); - expect(screen.getByTestId('host-selector-description')).toBeVisible(); - expect(screen.getByTestId('host-selector-description')).toHaveTextContent(MULTIPLE_HOSTS_PATTERN); - - // Verify all host values are visible - expect(screen.getByText('192.168.1.1')).toBeVisible(); - expect(screen.getByText('192.168.1.2')).toBeVisible(); - expect(screen.getByText('192.168.1.3')).toBeVisible(); - - // Verify each host has a row using testIds - expect(screen.getByTestId('host-selector-row-192.168.1.1')).toBeVisible(); - expect(screen.getByTestId('host-selector-row-192.168.1.2')).toBeVisible(); - expect(screen.getByTestId('host-selector-row-192.168.1.3')).toBeVisible(); - }); - }); - - describe('Table structure', () => { - test('should render table headers and display principal name and host value in each row', () => { - renderWithFileRoutes(); - - // Verify table headers - expect(screen.getByText('Principal')).toBeVisible(); - expect(screen.getByText('Host')).toBeVisible(); - - // Check that each host has a corresponding principal name and host value with testIds - for (const host of defaultProps.hosts) { - const hostValue = host.sharedConfig.host; - expect(screen.getByTestId(`host-selector-principal-${hostValue}`)).toBeVisible(); - expect(screen.getByTestId(`host-selector-principal-${hostValue}`)).toHaveTextContent('test-user'); - expect(screen.getByTestId(`host-selector-host-${hostValue}`)).toBeVisible(); - expect(screen.getByTestId(`host-selector-host-${hostValue}`)).toHaveTextContent(hostValue); - } - }); - }); - - describe('Navigation', () => { - test('should navigate with correct query parameter when clicking a host row', async () => { - const { router } = renderWithFileRoutes(); - - const firstRow = screen.getByTestId('host-selector-row-192.168.1.1'); - fireEvent.click(firstRow); - - await waitFor(() => { - expect(router.state.location.pathname).toBe('/security/acls/test-user/details'); - expect(router.state.location.search).toEqual({ host: '192.168.1.1' }); - }); - }); - - test('should navigate to different URLs when clicking different hosts', async () => { - const { router } = renderWithFileRoutes(); - - // Click first host - const firstRow = screen.getByTestId('host-selector-row-192.168.1.1'); - fireEvent.click(firstRow); - - await waitFor(() => { - expect(router.state.location.pathname).toBe('/security/acls/test-user/details'); - expect(router.state.location.search).toEqual({ host: '192.168.1.1' }); - }); - - // Click second host - const secondRow = screen.getByTestId('host-selector-row-192.168.1.2'); - fireEvent.click(secondRow); - - await waitFor(() => { - expect(router.state.location.pathname).toBe('/security/acls/test-user/details'); - expect(router.state.location.search).toEqual({ host: '192.168.1.2' }); - }); - }); - - test('should properly handle host values with special characters', async () => { - const propsWithSpecialChars = { - ...defaultProps, - hosts: [ - { sharedConfig: { principal: 'test-user', host: 'host@example.com' }, rules: [] }, - { sharedConfig: { principal: 'test-user', host: '*.domain.com' }, rules: [] }, - { sharedConfig: { principal: 'test-user', host: 'host with spaces' }, rules: [] }, - ], - }; - - const { router } = renderWithFileRoutes(); - - const row = screen.getByTestId('host-selector-row-host@example.com'); - fireEvent.click(row); - - await waitFor(() => { - expect(router.state.location.pathname).toBe('/security/acls/test-user/details'); - expect(router.state.location.search).toEqual({ host: 'host@example.com' }); - }); - }); - - test('should use provided baseUrl correctly', async () => { - const customBaseUrl = '/security/roles/my-role/details'; - const customProps = { - ...defaultProps, - baseUrl: customBaseUrl, - }; - - const { router } = renderWithFileRoutes(); - - const row = screen.getByTestId('host-selector-row-192.168.1.1'); - fireEvent.click(row); - - await waitFor(() => { - expect(router.state.location.pathname).toBe('/security/roles/my-role/details'); - expect(router.state.location.search).toEqual({ host: '192.168.1.1' }); - }); - }); - }); - - describe('Edge cases', () => { - test('should render with single host', () => { - const singleHostProps = { - ...defaultProps, - hosts: [{ sharedConfig: { principal: 'test-user', host: '192.168.1.1' }, rules: [] }], - }; - - renderWithFileRoutes(); - - expect(screen.getByTestId('host-selector-row-192.168.1.1')).toBeVisible(); - expect(screen.getByTestId('host-selector-host-192.168.1.1')).toHaveTextContent('192.168.1.1'); - }); - - test('should render with many hosts', () => { - const manyHostsProps = { - ...defaultProps, - hosts: Array.from({ length: 10 }, (_, i) => ({ - sharedConfig: { principal: 'test-user', host: `192.168.1.${i + 1}` }, - rules: [], - })), - }; - - renderWithFileRoutes(); - - // Verify first and last host are present - expect(screen.getByTestId('host-selector-row-192.168.1.1')).toBeVisible(); - expect(screen.getByTestId('host-selector-row-192.168.1.10')).toBeVisible(); - - // Verify all 10 rows exist - for (let i = 1; i <= 10; i++) { - expect(screen.getByTestId(`host-selector-row-192.168.1.${i}`)).toBeVisible(); - } - }); - }); -}); diff --git a/frontend/src/components/pages/acls/new-acl/host-selector.tsx b/frontend/src/components/pages/acls/new-acl/host-selector.tsx deleted file mode 100644 index 20b78f8ad0..0000000000 --- a/frontend/src/components/pages/acls/new-acl/host-selector.tsx +++ /dev/null @@ -1,73 +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 { useNavigate } from '@tanstack/react-router'; - -import type { AclDetail } from './acl.model'; -import { Card, CardContent, CardHeader, CardTitle } from '../../../redpanda-ui/components/card'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../../redpanda-ui/components/table'; -import { Text } from '../../../redpanda-ui/components/typography'; - -type HostSelectorProps = { - principalName: string; - hosts: AclDetail[]; - baseUrl: string; -}; - -export const HostSelector = ({ principalName, hosts, baseUrl }: HostSelectorProps) => { - const navigate = useNavigate(); - - return ( -
- - - Multiple hosts found - - - - This{' '} - - {principalName} - {' '} - principal has ACLs configured for multiple hosts. Select a host to view its ACL configuration. - - - - - Principal - Host - - - - {hosts.map(({ sharedConfig: { host: hostValue } }) => ( - { - navigate({ to: baseUrl, search: { host: hostValue } }); - }} - testId={`host-selector-row-${hostValue}`} - > - - {principalName} - - - {hostValue} - - - ))} - -
-
-
-
- ); -}; diff --git a/frontend/src/components/pages/acls/new-acl/operations-badge.tsx b/frontend/src/components/pages/acls/new-acl/operations-badge.tsx deleted file mode 100644 index 9cb8b4fd33..0000000000 --- a/frontend/src/components/pages/acls/new-acl/operations-badge.tsx +++ /dev/null @@ -1,106 +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 { - formatLabel, - getIdFromRule, - getOperationsForResourceType, - getPluralResourceName, - getResourceName, - type OperationType, - type Rule, -} from './acl.model'; - -type OperationsBadgesProps = { - rule: Rule; - showResourceDescription?: boolean; -}; - -type PermissionBadgeProps = { - isAllow: boolean; - children: React.ReactNode; - testId?: string; -}; - -const PermissionBadge = ({ isAllow, children, testId }: PermissionBadgeProps) => { - const colorClasses = isAllow ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'; - - return ( - - {children} - - ); -}; - -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complex business logic -export const OperationsBadge = ({ rule, showResourceDescription = true }: OperationsBadgesProps) => { - const enabledOperations = Object.entries(rule.operations).map(([op, value]: [string, OperationType]) => ({ - name: formatLabel(op), - originName: op, - value, - })); - - // Check if all operations have the same permission - const availableRules = Object.entries(getOperationsForResourceType(rule.resourceType)).length; - const allAllow = - enabledOperations.length > 0 && - availableRules === enabledOperations.length && - enabledOperations.every((op) => op.value === 'allow'); - const allDeny = - enabledOperations.length > 0 && - availableRules === enabledOperations.length && - enabledOperations.every((op) => op.value === 'deny'); - const showSummary = allAllow || allDeny; - - // Generate resource description text - let resourceText = ''; - if (showResourceDescription) { - if (rule.resourceType === 'cluster' || rule.resourceType === 'schemaRegistry') { - resourceText = getResourceName(rule.resourceType); - } else if (rule.selectorType === 'any') { - resourceText = `All ${getPluralResourceName(rule.resourceType)}`; - } else { - const matchType = rule.selectorType === 'literal' ? 'matching' : 'starting with'; - resourceText = `${getPluralResourceName(rule.resourceType)} ${matchType}: "${rule.selectorValue}"`; - } - resourceText = resourceText.charAt(0).toUpperCase() + resourceText.slice(1); - } - - return ( -
- {Boolean(showResourceDescription) &&
{resourceText}
} -
- {enabledOperations.length === 0 ? ( - No operations configured - ) : ( -
- {showSummary ? ( - {allAllow ? 'Allow all' : 'Deny all'} - ) : ( - enabledOperations.map((op) => ( - - {op.name}: {op.value} - - )) - )} -
- )} -
-
- ); -}; diff --git a/frontend/src/components/pages/acls/new-acl/principal-utils.test.ts b/frontend/src/components/pages/acls/new-acl/principal-utils.test.ts deleted file mode 100644 index 7389048063..0000000000 --- a/frontend/src/components/pages/acls/new-acl/principal-utils.test.ts +++ /dev/null @@ -1,46 +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 { describe, expect, test } from 'vitest'; - -import { parsePrincipalFromParam } from './principal-utils'; - -describe('parsePrincipalFromParam', () => { - test('bare name defaults to User principal type', () => { - expect(parsePrincipalFromParam('alice')).toEqual({ principalType: 'User', principalName: 'alice' }); - }); - - test('User: prefix is parsed correctly', () => { - expect(parsePrincipalFromParam('User:alice')).toEqual({ principalType: 'User', principalName: 'alice' }); - }); - - test('Group: prefix is parsed correctly', () => { - expect(parsePrincipalFromParam('Group:mygroup')).toEqual({ principalType: 'Group', principalName: 'mygroup' }); - }); - - test('RedpandaRole: prefix is parsed correctly', () => { - expect(parsePrincipalFromParam('RedpandaRole:some-role')).toEqual({ - principalType: 'RedpandaRole', - principalName: 'some-role', - }); - }); - - test('only the first colon is used as the separator', () => { - expect(parsePrincipalFromParam('User:alice:with:colons')).toEqual({ - principalType: 'User', - principalName: 'alice:with:colons', - }); - }); - - test('Group with colon in name uses first colon as separator', () => { - expect(parsePrincipalFromParam('Group:team:a')).toEqual({ principalType: 'Group', principalName: 'team:a' }); - }); -}); diff --git a/frontend/src/components/pages/acls/new-acl/principal-utils.ts b/frontend/src/components/pages/acls/new-acl/principal-utils.ts deleted file mode 100644 index 381b317f89..0000000000 --- a/frontend/src/components/pages/acls/new-acl/principal-utils.ts +++ /dev/null @@ -1,22 +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 - */ - -/** - * Parses a principal from a route parameter. - * aclName may be a full principal like "Group:mygroup" or just a bare name (defaults to User). - */ -export function parsePrincipalFromParam(aclName: string): { principalType: string; principalName: string } { - const colonIdx = aclName.indexOf(':'); - if (colonIdx >= 0) { - return { principalType: aclName.slice(0, colonIdx), principalName: aclName.slice(colonIdx + 1) }; - } - return { principalType: 'User', principalName: aclName }; -} diff --git a/frontend/src/components/pages/acls/operation.tsx b/frontend/src/components/pages/acls/operation.tsx deleted file mode 100644 index 5fa62b850f..0000000000 --- a/frontend/src/components/pages/acls/operation.tsx +++ /dev/null @@ -1,77 +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 { Flex } from '@redpanda-data/ui'; -import { CheckIcon, CloseIcon, MinusIcon } from 'components/icons'; -import type { CSSProperties, FC, ReactElement, ReactNode } from 'react'; - -import { AclOperation, type AclOperationType, type AclStrPermission } from '../../../state/rest-interfaces'; -import { Label } from '../../../utils/tsx-utils'; -import { SingleSelect } from '../../misc/select'; - -const icons = { - minus: , - check: , - cross: , -}; - -const OptionContent: FC<{ - children: ReactNode; - icon: ReactElement; -}> = ({ children, icon }) => ( - - {icon} - {children} - -); - -export const Operation = (p: { - operation: string | AclOperationType; - value: AclStrPermission; - disabled?: boolean; - onChange: (v: AclStrPermission) => void; - style?: CSSProperties; -}) => { - const disabled = p.disabled ?? false; - - const operationName = - typeof p.operation === 'string' - ? p.operation - : Object.keys(AclOperation).find((key) => AclOperation[key as keyof typeof AclOperation] === p.operation) || - 'Unknown'; - - return ( - - ); -}; diff --git a/frontend/src/components/pages/acls/principal-group-editor.tsx b/frontend/src/components/pages/acls/principal-group-editor.tsx deleted file mode 100644 index 79339f7ce2..0000000000 --- a/frontend/src/components/pages/acls/principal-group-editor.tsx +++ /dev/null @@ -1,150 +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 - */ - -'use no memo'; - -import { Box, Button, Flex, FormField, Grid, Icon, Input, InputGroup, InputLeftAddon, Text } from '@redpanda-data/ui'; -import { TrashIcon } from 'components/icons'; - -import type { ResourceACLs } from './models'; -import { Operation } from './operation'; -import { AclOperation, type AclStrResourceType } from '../../../state/rest-interfaces'; -import { Label } from '../../../utils/tsx-utils'; -import { SingleSelect } from '../../misc/select'; - -export const ResourceACLsEditor = (p: { - resource: ResourceACLs; - resourceType: AclStrResourceType; - setIsFormValid: (isValid: boolean) => void; - onDelete?: () => void; - onChange: (resource: ResourceACLs) => void; -}) => { - const res = p.resource; - if (!res) { - // Happens for clusterAcls? - return null; - } - - const isCluster = !('selector' in res); - const isAllSet = res.all === 'Allow' || res.all === 'Deny'; - - let resourceName = 'Cluster'; - if (p.resourceType === 'Topic') { - resourceName = 'Topic'; - } - if (p.resourceType === 'Group') { - resourceName = 'Consumer Group'; - } - if (p.resourceType === 'TransactionalID') { - resourceName = 'Transactional ID'; - } - - const isInvalid = - (!isCluster && res.patternType === 'Literal' && res.selector === '') || - (!isCluster && res.patternType === 'Prefixed' && res.selector === ''); - - const errorText = 'Selector cannot be empty'; - p.setIsFormValid(!isInvalid); - - return ( - - - {isCluster ? ( - - Applies to whole cluster - - ) : ( - - - - - chakraStyles={{ - container: () => ({ - flexGrow: 1, - marginLeft: '-1px', - marginRight: '-1px', - cursor: 'pointer', - }), - }} - isSearchable={false} - onChange={(e) => { - p.onChange({ ...res, patternType: e, selector: e === 'Any' ? '*' : '' } as ResourceACLs); - }} - options={[ - { value: 'Any', label: 'Any' }, - { value: 'Literal', label: 'Literal' }, - { value: 'Prefixed', label: 'Prefixed' }, - ]} - value={res.patternType as 'Any' | 'Literal' | 'Prefixed'} - /> - - { - p.onChange({ ...res, selector: e.target.value } as ResourceACLs); - }} - spellCheck={false} - value={res.selector} - /> - - - )} - - - - - {Boolean(p.onDelete) && ( - - - - - )} - - ); -}; diff --git a/frontend/src/components/pages/acls/role-create.tsx b/frontend/src/components/pages/acls/role-create.tsx deleted file mode 100644 index 240ffab751..0000000000 --- a/frontend/src/components/pages/acls/role-create.tsx +++ /dev/null @@ -1,55 +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 { Text } from '@redpanda-data/ui'; - -import { RoleForm } from './role-form'; -import { appGlobal } from '../../../state/app-global'; -import { api, rolesApi } from '../../../state/backend-api'; -import { DefaultSkeleton } from '../../../utils/tsx-utils'; -import PageContent from '../../misc/page-content'; -import { PageComponent, type PageInitHelper } from '../page'; - -class RoleCreatePage extends PageComponent { - initPage(p: PageInitHelper): void { - p.title = 'Create role'; - p.addBreadcrumb('Access Control', '/security'); - p.addBreadcrumb('Roles', '/security/roles'); - p.addBreadcrumb('Create role', '/security/roles/create'); - - // biome-ignore lint/suspicious/noConsole: error logging for unhandled promise rejections - this.refreshData().catch(console.error); - // biome-ignore lint/suspicious/noConsole: error logging for unhandled promise rejections - appGlobal.onRefresh = () => this.refreshData().catch(console.error); - } - - async refreshData() { - await Promise.allSettled([api.refreshServiceAccounts(), rolesApi.refreshRoles()]); - } - - render() { - if (!api.serviceAccounts?.users) { - return DefaultSkeleton; - } - - return ( - - - A role is a named collection of ACLs which may have users (security principals) assigned to it. You can assign - any number of roles to a given user. - - - - ); - } -} - -export default RoleCreatePage; diff --git a/frontend/src/components/pages/acls/role-edit-page.tsx b/frontend/src/components/pages/acls/role-edit-page.tsx deleted file mode 100644 index dd68fc73d8..0000000000 --- a/frontend/src/components/pages/acls/role-edit-page.tsx +++ /dev/null @@ -1,93 +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 { useEffect, useState } from 'react'; - -import { principalGroupsView } from './models'; -import { RoleForm } from './role-form'; -import { appGlobal } from '../../../state/app-global'; -import { api, rolesApi } from '../../../state/backend-api'; -import { AclRequestDefault } from '../../../state/rest-interfaces'; -import { DefaultSkeleton } from '../../../utils/tsx-utils'; -import PageContent from '../../misc/page-content'; -import { PageComponent, type PageInitHelper } from '../page'; - -async function refreshEditData(force: boolean) { - if (api.userData !== null && api.userData !== undefined && !api.userData.canListAcls) { - return; - } - - await Promise.allSettled([ - api.refreshAcls(AclRequestDefault, force), - api.refreshServiceAccounts(), - rolesApi.refreshRoles(), - rolesApi.refreshRoleMembers(), - ]); -} - -class RoleEditPage extends PageComponent<{ roleName: string }> { - initPage(p: PageInitHelper): void { - const roleName = decodeURIComponent(this.props.roleName); - p.title = 'Edit role'; - p.addBreadcrumb('Access Control', '/security'); - p.addBreadcrumb('Roles', '/security/roles'); - p.addBreadcrumb(roleName, `/security/roles/${encodeURIComponent(this.props.roleName)}`); - - // biome-ignore lint/suspicious/noConsole: error logging for unhandled promise rejections - refreshEditData(true).catch(console.error); - // biome-ignore lint/suspicious/noConsole: error logging for unhandled promise rejections - appGlobal.onRefresh = () => refreshEditData(true).catch(console.error); - } - - render() { - return ; - } -} - -const RoleEditPageContent = ({ roleName: encodedRoleName }: { roleName: string }) => { - const roleName = decodeURIComponent(encodedRoleName); - const [allDataLoaded, setAllDataLoaded] = useState(false); - - useEffect(() => { - refreshEditData(true) - .then(() => setAllDataLoaded(true)) - // biome-ignore lint/suspicious/noConsole: error logging for unhandled promise rejections - .catch(console.error); - }, []); - - if (!allDataLoaded) { - return DefaultSkeleton; - } - - const aclPrincipalGroup = principalGroupsView.principalGroups.find( - ({ principalType, principalName }) => principalType === 'RedpandaRole' && principalName === roleName - ); - - const principals = rolesApi.roleMembers.get(roleName); - - return ( - - - - ); -}; - -export default RoleEditPage; diff --git a/frontend/src/components/pages/acls/role-form.tsx b/frontend/src/components/pages/acls/role-form.tsx deleted file mode 100644 index b56c10413e..0000000000 --- a/frontend/src/components/pages/acls/role-form.tsx +++ /dev/null @@ -1,494 +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 - */ - -'use no memo'; - -import { useNavigate } from '@tanstack/react-router'; -import { Button } from 'components/redpanda-ui/components/button'; -import { Combobox } from 'components/redpanda-ui/components/combobox'; -import { Field, FieldDescription, FieldError, FieldLabel } from 'components/redpanda-ui/components/field'; -import { Input } from 'components/redpanda-ui/components/input'; -import { Heading } from 'components/redpanda-ui/components/typography'; -import { X } from 'lucide-react'; -import { useEffect, useMemo, useState } from 'react'; -import { toast } from 'sonner'; - -import { - type AclPrincipalGroup, - type ClusterACLs, - type ConsumerGroupACLs, - createEmptyClusterAcl, - createEmptyConsumerGroupAcl, - createEmptyTopicAcl, - createEmptyTransactionalIdAcl, - principalGroupsView, - type TopicACLs, - type TransactionalIdACLs, - unpackPrincipalGroup, -} from './models'; -import { ResourceACLsEditor } from './principal-group-editor'; -import { appGlobal } from '../../../state/app-global'; -import { api, type RolePrincipal, rolesApi, useApiStoreHook } from '../../../state/backend-api'; -import type { AclStrOperation, AclStrResourceType } from '../../../state/rest-interfaces'; - -type CreateRoleFormState = { - roleName: string; - allowAllOperations: boolean; - host: string; - topicACLs: TopicACLs[]; - consumerGroupsACLs: ConsumerGroupACLs[]; - transactionalIDACLs: TransactionalIdACLs[]; - clusterACLs: ClusterACLs; - principals: RolePrincipal[]; -}; - -type RoleFormProps = { - initialData?: Partial; -}; - -export const RoleForm = ({ initialData }: RoleFormProps) => { - const navigate = useNavigate(); - const [formState, setFormState] = useState(() => { - const initial: CreateRoleFormState = { - roleName: '', - allowAllOperations: false, - host: '', - topicACLs: [createEmptyTopicAcl()], - consumerGroupsACLs: [createEmptyConsumerGroupAcl()], - transactionalIDACLs: [createEmptyTransactionalIdAcl()], - clusterACLs: createEmptyClusterAcl(), - principals: [], - ...initialData, - }; - if (!initial.clusterACLs) { - initial.clusterACLs = createEmptyClusterAcl(); - } - return initial; - }); - - const [isFormValid, setIsFormValid] = useState(true); - const [isLoading, setIsLoading] = useState(false); - - const originalPrincipals = useMemo(() => initialData?.principals ?? [], [initialData?.principals]); - const editMode: boolean = Boolean(initialData?.roleName); - - const roleNameAlreadyExist = rolesApi.roles.includes(formState.roleName) && !editMode; - - const handleSubmit = async () => { - setIsLoading(true); - const principalsToRemove = originalPrincipals.filter( - (op) => !formState.principals.some((cp) => cp.principalType === op.principalType && cp.name === op.name) - ); - const principalType: AclStrResourceType = 'RedpandaRole'; - const isEditMode = editMode; - const roleName = formState.roleName; - const aclPrincipalGroup: AclPrincipalGroup = { - principalType: 'RedpandaRole', - principalName: roleName, - host: formState.host, - topicAcls: formState.topicACLs, - consumerGroupAcls: formState.consumerGroupsACLs, - transactionalIdAcls: formState.transactionalIDACLs, - clusterAcls: formState.clusterACLs, - sourceEntries: [], - }; - const deleteAclsArgs = isEditMode - ? { - resourceType: 'Any' as const, - resourceName: undefined, - principal: `${principalType}:${roleName}`, - resourcePatternType: 'Any' as const, - operation: 'Any' as const, - permissionType: 'Any' as const, - } - : null; - const actionLabel = isEditMode ? 'updated' : 'created'; - const deleteAclsPromise = deleteAclsArgs ? api.deleteACLs(deleteAclsArgs) : Promise.resolve(); - let newRoleResult: Awaited> | null = null; - try { - await deleteAclsPromise; - newRoleResult = await rolesApi.updateRoleMembership(roleName, formState.principals, principalsToRemove, true); - } catch (err) { - toast.error(`Failed to update role ${formState.roleName}`, { description: String(err) }); - setIsLoading(false); - return; - } - - const roleResponse = newRoleResult ? newRoleResult.response : null; - if (roleResponse) { - const unpackedPrincipalGroup = unpackPrincipalGroup(aclPrincipalGroup); - const aclCreatePromises = unpackedPrincipalGroup.map((aclFlat) => - api.createACL({ - host: aclFlat.host, - principal: aclFlat.principal, - resourceType: aclFlat.resourceType, - resourceName: aclFlat.resourceName, - resourcePatternType: aclFlat.resourcePatternType as unknown as 'Literal' | 'Prefixed', - operation: aclFlat.operation as unknown as Exclude, - permissionType: aclFlat.permissionType as unknown as 'Allow' | 'Deny', - }) - ); - try { - await Promise.all(aclCreatePromises); - } catch (err) { - toast.error(`Failed to update role ${formState.roleName}`, { description: String(err) }); - setIsLoading(false); - return; - } - setIsLoading(false); - toast.success(`Role ${roleResponse.roleName} successfully ${actionLabel}`); - navigate({ to: `/security/roles/${encodeURIComponent(roleResponse.roleName)}/details` }); - } - }; - - return ( -
-
-
-
-
- - Role name - { - setFormState((prev) => ({ ...prev, roleName: v.target.value })); - }} - pattern="^[^,=]+$" - required - title="Please avoid using commas or equal signs." - value={formState.roleName} - /> - {roleNameAlreadyExist && Role name already exist} - -
- - -
- - - Host - - The host the user needs to connect from in order for the permissions to apply. - - { - setFormState((prev) => ({ ...prev, host: v.target.value })); - }} - value={formState.host} - /> - - -
- Topics - {formState.topicACLs.map((topicACL, index) => ( - - setFormState((prev) => ({ - ...prev, - topicACLs: prev.topicACLs.map((t, i) => (i === index ? (updated as TopicACLs) : t)), - })) - } - onDelete={() => { - setFormState((prev) => ({ - ...prev, - topicACLs: prev.topicACLs.filter((_, i) => i !== index), - })); - }} - resource={topicACL} - resourceType="Topic" - setIsFormValid={setIsFormValid} - /> - ))} - -
- -
-
- -
- Consumer Groups - {formState.consumerGroupsACLs.map((acl, index) => ( - - setFormState((prev) => ({ - ...prev, - consumerGroupsACLs: prev.consumerGroupsACLs.map((t, i) => - i === index ? (updated as ConsumerGroupACLs) : t - ), - })) - } - onDelete={() => { - setFormState((prev) => ({ - ...prev, - consumerGroupsACLs: prev.consumerGroupsACLs.filter((_, i) => i !== index), - })); - }} - resource={acl} - resourceType="Group" - setIsFormValid={setIsFormValid} - /> - ))} - -
- -
-
- -
- Transactional IDs - {formState.transactionalIDACLs.map((acl, index) => ( - - setFormState((prev) => ({ - ...prev, - transactionalIDACLs: prev.transactionalIDACLs.map((t, i) => - i === index ? (updated as TransactionalIdACLs) : t - ), - })) - } - onDelete={() => { - setFormState((prev) => ({ - ...prev, - transactionalIDACLs: prev.transactionalIDACLs.filter((_, i) => i !== index), - })); - }} - resource={acl} - resourceType="TransactionalID" - setIsFormValid={setIsFormValid} - /> - ))} - -
- -
-
- -
- Cluster -
-
- setFormState((prev) => ({ ...prev, clusterACLs: updated as ClusterACLs }))} - resource={formState.clusterACLs} - resourceType="Cluster" - setIsFormValid={setIsFormValid} - /> -
-
-
- -
- Principals - - Assign this role to principals - This can be edited later - setFormState((prev) => ({ ...prev, principals }))} - principals={formState.principals} - /> - -
-
- -
- - -
-
-
- ); -}; - -const PrincipalSelector = (p: { - principals: RolePrincipal[]; - onPrincipalsChange: (principals: RolePrincipal[]) => void; -}) => { - const enterpriseFeaturesUsed = useApiStoreHook((s) => s.enterpriseFeaturesUsed); - const gbacEnabled = enterpriseFeaturesUsed.some((f) => f.name === 'gbac' && f.enabled); - - useEffect(() => { - api.refreshServiceAccounts().catch(() => { - // Error handling managed by API layer - }); - }, []); - - const { principals, onPrincipalsChange } = p; - - const availableUsers = - api.serviceAccounts?.users.map((u) => ({ - value: u, - })) ?? []; - - // Add all inferred users - // In addition, find all principals that are referenced by roles, or acls, that are not service accounts - for (const g of principalGroupsView.principalGroups) { - if ( - g.principalType === 'User' && - !g.principalName.includes('*') && - !availableUsers.any((u) => u.value === g.principalName) - ) { - // is it a user that is being referenced? - // is the user already listed as a service account? - availableUsers.push({ value: g.principalName }); - } - } - - for (const [_, roleMembers] of rolesApi.roleMembers) { - for (const roleMember of roleMembers) { - if (roleMember.principalType === 'User' && !availableUsers.any((u) => u.value === roleMember.name)) { - availableUsers.push({ value: roleMember.name }); - } - } - } - - const availableGroups: { value: string }[] = []; - for (const [_, roleMembers] of rolesApi.roleMembers) { - for (const roleMember of roleMembers) { - if (roleMember.principalType === 'Group' && !availableGroups.any((g) => g.value === roleMember.name)) { - availableGroups.push({ value: roleMember.name }); - } - } - } - - const addPrincipal = (name: string, principalType: RolePrincipal['principalType']) => { - if (name && !principals.some((p) => p.name === name && p.principalType === principalType)) { - onPrincipalsChange([...principals, { name, principalType }]); - } - }; - - return ( -
-
-
- { - if (val) addPrincipal(val, 'User'); - }} - options={availableUsers.map((u) => ({ value: u.value, label: u.value }))} - placeholder="Add user" - /> -
- {gbacEnabled && ( -
- { - if (val) addPrincipal(val, 'Group'); - }} - options={availableGroups.map((g) => ({ value: g.value, label: g.value }))} - placeholder="Add group" - /> -
- )} -
- -
- {principals.map((principal) => ( - - - {principal.principalType}: {principal.name} - - - - ))} -
-
- ); -}; diff --git a/frontend/src/components/pages/acls/user-create.tsx b/frontend/src/components/pages/acls/user-create.tsx deleted file mode 100644 index 674aba38fe..0000000000 --- a/frontend/src/components/pages/acls/user-create.tsx +++ /dev/null @@ -1,498 +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 - */ - -'use no memo'; - -import { - Alert, - AlertIcon, - Box, - Button, - Checkbox, - CopyButton, - createStandaloneToast, - Flex, - FormField, - Grid, - Heading, - IconButton, - Input, - isMultiValue, - PasswordInput, - redpandaTheme, - redpandaToastOptions, - Select, - Text, - Tooltip, -} from '@redpanda-data/ui'; -import { Link } from '@tanstack/react-router'; -import { RotateCwIcon } from 'components/icons'; -import { useCallback, useEffect, useMemo, useState } from 'react'; - -import { useListRolesQuery } from '../../../react-query/api/security'; -import { invalidateUsersCache, useLegacyListUsersQuery } from '../../../react-query/api/user'; -import { appGlobal } from '../../../state/app-global'; -import { api, rolesApi } from '../../../state/backend-api'; -import { AclRequestDefault } from '../../../state/rest-interfaces'; -import { useSupportedFeaturesStore } from '../../../state/supported-features'; -import { uiState } from '../../../state/ui-state'; -import { - PASSWORD_MAX_LENGTH, - PASSWORD_MIN_LENGTH, - SASL_MECHANISMS, - type SaslMechanism, - validatePassword, - validateUsername, -} from '../../../utils/user'; -import PageContent from '../../misc/page-content'; -import { SingleSelect } from '../../misc/select'; - -const { ToastContainer, toast } = createStandaloneToast({ - theme: redpandaTheme, - defaultOptions: { - ...redpandaToastOptions.defaultOptions, - isClosable: false, - duration: 2000, - }, -}); - -const UserCreatePage = () => { - const [formState, setFormState] = useState({ - username: '', - password: generatePassword(30, false), - mechanism: 'SCRAM-SHA-256' as SaslMechanism, - generateWithSpecialChars: false, - selectedRoles: [] as string[], - }); - const [step, setStep] = useState<'CREATE_USER' | 'CREATE_USER_CONFIRMATION'>('CREATE_USER'); - const [isCreating, setIsCreating] = useState(false); - - const { username, password, mechanism, generateWithSpecialChars, selectedRoles } = formState; - const setUsername = (v: string) => setFormState((prev) => ({ ...prev, username: v })); - const setPassword = (v: string) => setFormState((prev) => ({ ...prev, password: v })); - const setMechanism = (v: SaslMechanism) => setFormState((prev) => ({ ...prev, mechanism: v })); - const setGenerateWithSpecialChars = (v: boolean) => - setFormState((prev) => ({ ...prev, generateWithSpecialChars: v })); - const setSelectedRoles = (v: string[]) => setFormState((prev) => ({ ...prev, selectedRoles: v })); - - const { data: usersData } = useLegacyListUsersQuery(); - const users = usersData?.users?.map((u) => u.name) ?? []; - - const isValidUsername = validateUsername(username); - const isValidPassword = validatePassword(password); - - useEffect(() => { - uiState.pageTitle = 'Create user'; - uiState.pageBreadcrumbs = []; - uiState.pageBreadcrumbs.push({ title: 'Access Control', linkTo: '/security' }); - uiState.pageBreadcrumbs.push({ title: 'Create user', linkTo: '/security/users/create' }); - - const refreshData = async () => { - if (api.userData !== null && api.userData !== undefined && !api.userData.canListAcls) { - return; - } - await Promise.allSettled([api.refreshAcls(AclRequestDefault, true), api.refreshServiceAccounts()]); - }; - - refreshData().catch(() => { - // Silently ignore refresh errors - }); - appGlobal.onRefresh = () => - refreshData().catch(() => { - // Silently ignore refresh errors - }); - }, []); - - const onCreateUser = useCallback(async (): Promise => { - setIsCreating(true); - let success = false; - try { - await api.createServiceAccount({ - username, - password, - mechanism, - }); - } catch (err) { - toast({ - status: 'error', - duration: null, - isClosable: true, - title: 'Failed to create user', - description: String(err), - }); - setIsCreating(false); - return false; - } - - const userData = api.userData; - if (userData) { - const cannotListAcls = !userData.canListAcls; - if (cannotListAcls) { - setIsCreating(false); - return false; - } - } - - const roleAddPromises: Promise[] = selectedRoles.map((r) => - rolesApi.updateRoleMembership(r, [{ name: username, principalType: 'User' }], [], false) - ); - - try { - await Promise.allSettled([api.refreshAcls(AclRequestDefault, true), invalidateUsersCache()]); - await Promise.allSettled(roleAddPromises); - setStep('CREATE_USER_CONFIRMATION'); - success = true; - } catch (err) { - toast({ - status: 'error', - duration: null, - isClosable: true, - title: 'Failed to create user', - description: String(err), - }); - } - setIsCreating(false); - return success; - }, [username, password, mechanism, selectedRoles]); - - const onCancel = () => appGlobal.historyPush('/security/users'); - - const state = { - username, - setUsername, - password, - setPassword, - mechanism, - setMechanism, - generateWithSpecialChars, - setGenerateWithSpecialChars, - isCreating, - isValidUsername, - isValidPassword, - selectedRoles, - setSelectedRoles, - users, - }; - - return ( - <> - - - - - {step === 'CREATE_USER' ? ( - - ) : ( - - )} - - - - ); -}; - -export default UserCreatePage; - -type CreateUserModalProps = { - state: { - username: string; - setUsername: (v: string) => void; - password: string; - setPassword: (v: string) => void; - mechanism: SaslMechanism; - setMechanism: (v: SaslMechanism) => void; - generateWithSpecialChars: boolean; - setGenerateWithSpecialChars: (v: boolean) => void; - 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); - const userAlreadyExists = state.users.includes(state.username); - - const errorText = useMemo(() => { - if (!state.isValidUsername) { - return 'The username contains invalid characters. Use only letters, numbers, dots, underscores, at symbols, and hyphens.'; - } - - if (userAlreadyExists) { - return 'User already exists'; - } - }, [state.isValidUsername, userAlreadyExists]); - - return ( - - - 0} - label="Username" - showRequiredIndicator - > - { - state.setUsername(v.target.value); - }} - placeholder="Username" - spellCheck={false} - value={state.username} - width="100%" - /> - - - - - - { - state.setPassword(e.target.value); - }} - value={state.password} - /> - - - } - onClick={() => { - state.setPassword(generatePassword(30, state.generateWithSpecialChars)); - }} - variant="ghost" - /> - - - {/* Wrapper needed: CopyButton doesn't forward refs, so Chakra Tooltip can't position itself without a DOM element to measure */} - - - - - - { - state.setGenerateWithSpecialChars(e.target.checked); - state.setPassword(generatePassword(30, e.target.checked)); - }} - > - Generate with special characters - - - - - - - onChange={(e) => { - state.setMechanism(e); - }} - options={SASL_MECHANISMS.map((mechanism) => ({ - value: mechanism, - label: mechanism, - }))} - value={state.mechanism} - /> - - - {Boolean(featureRolesApi) && ( - - - - )} - - - - - - - - ); -}; - -type CreateUserConfirmationModalProps = { - username: string; - password: string; - mechanism: SaslMechanism; - closeModal: () => void; -}; - -const CreateUserConfirmationModal = ({ - username, - password, - mechanism, - closeModal, -}: CreateUserConfirmationModalProps) => ( - <> - - User created successfully - - - - - You will not be able to view this password again. Make sure that it is copied and saved. - - - - - Username - - - - - {username} - - - - - - - - - - Password - - - - - - - {/* Wrapper needed: CopyButton doesn't forward refs, so Chakra Tooltip can't position itself without a DOM element to measure */} - - - - - - - - - Mechanism - - - - {mechanism} - - - - - - - - - -); - -export const StateRoleSelector = ({ roles, setRoles }: { roles: string[]; setRoles: (roles: string[]) => void }) => { - const [searchValue, setSearchValue] = useState(''); - const { - data: { roles: allRoles }, - } = useListRolesQuery(); - const availableRoles = (allRoles ?? []) - .filter((r: { name: string }) => !roles.includes(r.name)) - .map((r: { name: string }) => ({ value: r.name })); - - return ( - - - - inputValue={searchValue} - isMulti={true} - noOptionsMessage={() => 'No roles found'} - onChange={(val) => { - if (val && isMultiValue(val)) { - setRoles([...val.map((selectedRole) => selectedRole.value)]); - setSearchValue(''); - } - }} - onInputChange={setSearchValue} - options={availableRoles} - // TODO: Selecting an entry triggers onChange properly. - // But there is no way to prevent the component from showing no value as intended - // Seems to be a bug with the component. - // On 'undefined' it should handle selection on its own (this works properly) - // On 'null' the component should NOT show any selection after a selection has been made (does not work!) - // The override doesn't work either (isOptionSelected={()=>false}) - placeholder="Select roles..." - value={roles.map((r) => ({ value: r }))} - /> - - - ); -}; - -export function generatePassword(length: number, allowSpecialChars: boolean): string { - if (length <= 0) { - return ''; - } - - const lowercase = 'abcdefghijklmnopqrstuvwxyz'; - const uppercase = lowercase.toUpperCase(); - const numbers = '0123456789'; - const special = '.,&_+|[]/-()'; - - let alphabet = lowercase + uppercase + numbers; - if (allowSpecialChars) { - alphabet += special; - } - - const randomValues = new Uint32Array(length); - crypto.getRandomValues(randomValues); - - let result = ''; - for (const n of randomValues) { - const index = n % alphabet.length; - const sym = alphabet[index]; - - result += sym; - } - - return result; -} diff --git a/frontend/src/components/pages/acls/user-details.tsx b/frontend/src/components/pages/acls/user-details.tsx deleted file mode 100644 index 2b1f0cbde2..0000000000 --- a/frontend/src/components/pages/acls/user-details.tsx +++ /dev/null @@ -1,361 +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 { Box, DataTable, Text } from '@redpanda-data/ui'; -import { UserAclsCard } from 'components/pages/roles/user-acls-card'; -import { UserInformationCard } from 'components/pages/roles/user-information-card'; -import { UserRolesCard } from 'components/pages/roles/user-roles-card'; -import { Button } from 'components/redpanda-ui/components/button'; -import { isServerless } from 'config'; -import type { UpdateRoleMembershipResponse } from 'protogen/redpanda/api/console/v1alpha1/security_pb'; -import { useEffect, useState } from 'react'; - -import { DeleteUserConfirmModal } from './delete-user-confirm-modal'; -import type { AclPrincipalGroup } from './models'; -import { ChangePasswordModal, ChangeRolesModal } from './user-edit-modals'; -import { useGetAclsByPrincipal } from '../../../react-query/api/acl'; -import { useListRolesQuery } from '../../../react-query/api/security'; -import { invalidateUsersCache, useLegacyListUsersQuery } from '../../../react-query/api/user'; -import { appGlobal } from '../../../state/app-global'; -import { api, rolesApi } from '../../../state/backend-api'; -import { AclRequestDefault } from '../../../state/rest-interfaces'; -import { useSupportedFeaturesStore } from '../../../state/supported-features'; -import { uiState } from '../../../state/ui-state'; -import { DefaultSkeleton } from '../../../utils/tsx-utils'; -import PageContent from '../../misc/page-content'; - -type UserDetailsPageProps = { - userName: string; -}; - -const UserDetailsPage = ({ userName }: UserDetailsPageProps) => { - const [isChangePasswordModalOpen, setIsChangePasswordModalOpen] = useState(false); - const [isChangeRolesModalOpen, setIsChangeRolesModalOpen] = useState(false); - const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); - - const { data: usersData, isLoading: isUsersLoading } = useLegacyListUsersQuery(); - const users = usersData?.users?.map((u) => u.name) ?? []; - - useEffect(() => { - uiState.pageTitle = 'User details'; - uiState.pageBreadcrumbs = []; - uiState.pageBreadcrumbs.push({ title: 'Access Control', linkTo: '/security' }); - uiState.pageBreadcrumbs.push({ title: 'Users', linkTo: '/security/users' }); - uiState.pageBreadcrumbs.push({ title: userName, linkTo: `/security/users/${userName}` }); - - const refreshData = async () => { - if (api.userData !== null && api.userData !== undefined && !api.userData.canListAcls) { - return; - } - await Promise.allSettled([ - api.refreshAcls(AclRequestDefault, true), - api.refreshServiceAccounts(), - rolesApi.refreshRoles(), - ]); - await rolesApi.refreshRoleMembers(); - }; - - // biome-ignore lint/suspicious/noConsole: error logging for unhandled promise rejections - refreshData().catch(console.error); - appGlobal.onRefresh = () => - // biome-ignore lint/suspicious/noConsole: error logging for unhandled promise rejections - refreshData().catch(console.error); - }, [userName]); - - if (isUsersLoading) { - return DefaultSkeleton; - } - - const isServiceAccount = users.includes(userName); - - return ( - -
- { - setIsChangePasswordModalOpen(true); - } - : undefined - } - username={userName} - /> - { - setIsChangeRolesModalOpen(true); - } - : undefined - } - userName={userName} - /> -
- {Boolean(isServiceAccount) && ( - - Delete user - - } - onConfirm={async () => { - await api.deleteServiceAccount(userName); - - // Remove user from all its roles - 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.all([invalidateUsersCache(), rolesApi.refreshRoleMembers()]); - appGlobal.historyPush('/security/users/'); - }} - userName={userName} - /> - )} -
- - {Boolean(api.isAdminApiConfigured) && !isServerless() && ( - - )} - - {Boolean(featureRolesApi) && ( - - )} -
-
- ); -}; - -export default UserDetailsPage; - -const UserPermissionDetailsContent = ({ - userName, - onChangeRoles, -}: { - userName: string; - onChangeRoles?: () => void; -}) => { - const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); - const { data: rolesData } = useListRolesQuery({ filter: { principal: userName } }); - const { data: acls } = useGetAclsByPrincipal(`User:${userName}`); - - const roles = featureRolesApi - ? (rolesData?.roles ?? []).map((r) => ({ - principalType: 'RedpandaRole', - principalName: r.name, - })) - : []; - - return ( -
- - -
- ); -}; - -// TODO: remove this component when we update RoleDetails -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complexity 70, refactor later -export const AclPrincipalGroupPermissionsTable = ({ group }: { group: AclPrincipalGroup }) => { - const entries: { - type: string; - selector: string; - operations: { - allow: string[]; - deny: string[]; - }; - }[] = []; - - // Convert all entries of the group into a table row - for (const topicAcl of group.topicAcls) { - const allow: string[] = []; - const deny: string[] = []; - - if (topicAcl.all === 'Allow') { - allow.push('All'); - } else if (topicAcl.all === 'Deny') { - deny.push('All'); - } else { - for (const [permName, value] of Object.entries(topicAcl.permissions)) { - if (value === 'Allow') { - allow.push(permName); - } - if (value === 'Deny') { - deny.push(permName); - } - } - } - - if (allow.length === 0 && deny.length === 0) { - continue; - } - - entries.push({ - type: 'Topic', - selector: topicAcl.selector, - operations: { allow, deny }, - }); - } - - for (const groupAcl of group.consumerGroupAcls) { - const allow: string[] = []; - const deny: string[] = []; - - if (groupAcl.all === 'Allow') { - allow.push('All'); - } else if (groupAcl.all === 'Deny') { - deny.push('All'); - } else { - for (const [permName, value] of Object.entries(groupAcl.permissions)) { - if (value === 'Allow') { - allow.push(permName); - } - if (value === 'Deny') { - deny.push(permName); - } - } - } - - if (allow.length === 0 && deny.length === 0) { - continue; - } - - entries.push({ - type: 'ConsumerGroup', - selector: groupAcl.selector, - operations: { allow, deny }, - }); - } - - for (const transactId of group.transactionalIdAcls) { - const allow: string[] = []; - const deny: string[] = []; - - if (transactId.all === 'Allow') { - allow.push('All'); - } else if (transactId.all === 'Deny') { - deny.push('All'); - } else { - for (const [permName, value] of Object.entries(transactId.permissions)) { - if (value === 'Allow') { - allow.push(permName); - } - if (value === 'Deny') { - deny.push(permName); - } - } - } - - if (allow.length === 0 && deny.length === 0) { - continue; - } - - entries.push({ - type: 'TransactionalID', - selector: transactId.selector, - operations: { allow, deny }, - }); - } - - // Cluster - { - const clusterAcls = group.clusterAcls; - - const allow: string[] = []; - const deny: string[] = []; - - if (clusterAcls.all === 'Allow') { - allow.push('All'); - } else if (clusterAcls.all === 'Deny') { - deny.push('All'); - } else { - for (const [permName, value] of Object.entries(clusterAcls.permissions)) { - if (value === 'Allow') { - allow.push(permName); - } - if (value === 'Deny') { - deny.push(permName); - } - } - } - - // Cluster only appears once, so it won't be filtered automatically, - // we need to manually skip this entry if there isn't any content - if (allow.length + deny.length > 0) { - entries.push({ - type: 'Cluster', - selector: '', - operations: { allow, deny }, - }); - } - } - - if (entries.length === 0) { - return 'No permissions assigned'; - } - - return ( - { - const allow = record.operations.allow; - const deny = record.operations.deny; - - return ( - - {allow.length > 0 && ( - - - Allow:{' '} - - {allow.join(', ')} - - )} - {deny.length > 0 && ( - - - Deny:{' '} - - {deny.join(', ')} - - )} - - ); - }, - }, - ]} - data={entries} - /> - ); -}; diff --git a/frontend/src/components/pages/acls/user-edit-modals.tsx b/frontend/src/components/pages/acls/user-edit-modals.tsx deleted file mode 100644 index f9ff585c24..0000000000 --- a/frontend/src/components/pages/acls/user-edit-modals.tsx +++ /dev/null @@ -1,294 +0,0 @@ -'use no memo'; - -import { create } from '@bufbuild/protobuf'; -import { ConnectError } from '@connectrpc/connect'; -import { - Box, - Button, - Checkbox, - CopyButton, - Flex, - FormField, - IconButton, - Modal, - ModalBody, - ModalContent, - ModalFooter, - ModalHeader, - ModalOverlay, - PasswordInput, - Tooltip, - useToast, -} from '@redpanda-data/ui'; -import { RotateCwIcon } from 'components/icons'; -import { - UpdateRoleMembershipRequestSchema, - type UpdateRoleMembershipResponse, -} from 'protogen/redpanda/api/dataplane/v1/security_pb'; -import { SASLMechanism, UpdateUserRequestSchema } from 'protogen/redpanda/api/dataplane/v1/user_pb'; -import { useEffect, useState } from 'react'; - -import { generatePassword, StateRoleSelector } from './user-create'; -import { useListRolesQuery, useUpdateRoleMembershipMutation } from '../../../react-query/api/security'; -import { useUpdateUserMutationWithToast } from '../../../react-query/api/user'; -import { rolesApi } from '../../../state/backend-api'; -import { useSupportedFeaturesStore } from '../../../state/supported-features'; -import { formatToastErrorMessageGRPC, showToast } from '../../../utils/toast.utils'; -import { SingleSelect } from '../../misc/select'; - -type ChangePasswordModalProps = { - userName: string; - isOpen: boolean; - setIsOpen: (isOpen: boolean) => void; -}; - -export const ChangePasswordModal = ({ userName, isOpen, setIsOpen }: ChangePasswordModalProps) => { - const toast = useToast(); - const [password, setPassword] = useState(() => generatePassword(30, false)); - const [mechanism, setMechanism] = useState(); - const [generateWithSpecialChars, setGenerateWithSpecialChars] = useState(false); - const isValidPassword = password && password.length >= 4 && password.length <= 64; - const { mutateAsync: updateUser, isPending: isUpdateUserPending } = useUpdateUserMutationWithToast(); - - const onSavePassword = async () => { - const updateRequest = create(UpdateUserRequestSchema, { - user: { - name: userName, - mechanism, - password, - }, - }); - try { - await updateUser(updateRequest); - toast({ - status: 'success', - title: `Password for user ${userName} updated`, - }); - setIsOpen(false); - } catch (error) { - showToast({ - title: formatToastErrorMessageGRPC({ - error: ConnectError.from(error), - action: 'update', - entity: 'user', - }), - status: 'error', - }); - } - }; - - return ( - { - if (isUpdateUserPending) { - setIsOpen(false); - } - }} - > - - - {`Change ${userName} password`} - - - - - - setPassword(e.target.value)} - value={password} - /> - - - } - onClick={() => setPassword(generatePassword(30, generateWithSpecialChars))} - variant="ghost" - /> - - - {/* Wrapper needed: CopyButton doesn't forward refs, so Chakra Tooltip can't position itself without a DOM element to measure */} - - - - - - { - setGenerateWithSpecialChars(e.target.checked); - setPassword(generatePassword(30, e.target.checked)); - }} - > - Generate with special characters - - - - - - onChange={(e) => { - setMechanism(e); - }} - options={[ - { - value: SASLMechanism.SASL_MECHANISM_SCRAM_SHA_256, - label: 'SCRAM-SHA-256', - }, - { - value: SASLMechanism.SASL_MECHANISM_SCRAM_SHA_512, - label: 'SCRAM-SHA-512', - }, - ]} - value={mechanism} - /> - - - - - - - - - - ); -}; - -type ChangeRolesModalProps = { - userName: string; - isOpen: boolean; - setIsOpen: (isOpen: boolean) => void; -}; - -export const ChangeRolesModal = ({ userName, isOpen, setIsOpen }: ChangeRolesModalProps) => { - const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); - const toast = useToast(); - const [selectedRoles, setSelectedRoles] = useState(undefined); - const { mutateAsync: updateRoleMembership, isPending: isUpdateMembershipPending } = useUpdateRoleMembershipMutation(); - const { data, isLoading } = useListRolesQuery({ filter: { principal: userName } }); - const originalRoles = data.roles.map((r) => r.name); - - useEffect(() => { - if (!isLoading && selectedRoles === undefined) { - queueMicrotask(() => setSelectedRoles([...originalRoles])); - } - }, [originalRoles, isLoading, selectedRoles]); - - const onSaveRoles = async () => { - if (!featureRolesApi) { - return; - } - let formattedSelectedRoles: string[] = []; - if (selectedRoles) { - formattedSelectedRoles = selectedRoles; - } - const addedRoles = formattedSelectedRoles.except(originalRoles); - const removedRoles = originalRoles.except(formattedSelectedRoles); - const promises: Promise[] = []; - - // Remove user from "removedRoles" - for (const r of removedRoles) { - const membership = create(UpdateRoleMembershipRequestSchema, { - roleName: r, - remove: [{ principal: userName }], - }); - promises.push(updateRoleMembership(membership)); - } - // Add to newly selected roles - for (const r of addedRoles) { - const membership = create(UpdateRoleMembershipRequestSchema, { - roleName: r, - add: [{ principal: userName }], - }); - promises.push(updateRoleMembership(membership)); - } - - try { - await Promise.allSettled(promises); - // TODO: Until we haven't migrated everything from mobx is better to not remove this - await Promise.all([rolesApi.refreshRoles(), rolesApi.refreshRoleMembers()]); - - toast({ - status: 'success', - title: `${addedRoles.length} roles added, ${removedRoles.length} removed from user ${userName}`, - }); - setIsOpen(false); - } catch (error) { - showToast({ - title: formatToastErrorMessageGRPC({ - error: ConnectError.from(error), - action: 'update', - entity: 'role', - }), - status: 'error', - }); - } - }; - return ( - { - if (isUpdateMembershipPending) { - setIsOpen(false); - } - }} - > - - - {`Change ${userName} roles`} - - - - - - - - - - - - ); -}; diff --git a/frontend/src/components/pages/acls/user-permission-assignments.tsx b/frontend/src/components/pages/acls/user-permission-assignments.tsx deleted file mode 100644 index 69e73cee08..0000000000 --- a/frontend/src/components/pages/acls/user-permission-assignments.tsx +++ /dev/null @@ -1,91 +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 { useQuery } from '@connectrpc/connect-query'; -import { useNavigate } from '@tanstack/react-router'; -import { TagsValue } from 'components/redpanda-ui/components/tags'; - -import type { ListACLsRequest } from '../../../protogen/redpanda/api/dataplane/v1/acl_pb'; -import { listACLs } from '../../../protogen/redpanda/api/dataplane/v1/acl-ACLService_connectquery'; -import { rolesApi } from '../../../state/backend-api'; -import { useSupportedFeaturesStore } from '../../../state/supported-features'; - -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complex business logic -export const UserRoleTags = ({ - userName, - showMaxItems = Number.POSITIVE_INFINITY, - verticalView = true, -}: { - userName: string; - showMaxItems?: number; - verticalView?: boolean; -}) => { - const elements: JSX.Element[] = []; - let numberOfVisibleElements = 0; - let numberOfHiddenElements = 0; - - const featureRolesApi = useSupportedFeaturesStore((s) => s.rolesApi); - const navigate = useNavigate(); - - const { data: hasAcls } = useQuery( - listACLs, - { - filter: { - principal: `User:${userName}`, - }, - } as ListACLsRequest, - { - enabled: !!userName, - select: (response) => response.resources.length > 0, - } - ); - - if (hasAcls) { - elements.push( - navigate({ to: `/security/acls/${userName}/details` })}>{`User:${userName}`} - ); - } - - if (featureRolesApi) { - // Get all roles, and ACL sets that apply to this user - const roles: string[] = []; - for (const [roleName, members] of rolesApi.roleMembers) { - if (!members.any((m) => m.name === userName)) { - continue; // this role doesn't contain our user - } - roles.push(roleName); - } - - numberOfVisibleElements = Math.min(roles.length, showMaxItems); - numberOfHiddenElements = showMaxItems === Number.POSITIVE_INFINITY ? 0 : Math.max(0, roles.length - showMaxItems); - - for (let i = 0; i < numberOfVisibleElements; i++) { - const r = roles[i]; - elements.push( -
- navigate({ to: `/security/roles/${r}/details` })} - >{`RedpandaRole:${r}`} -
- ); - } - - if (elements.length === 0) { - elements.push(

No roles

); - } - if (numberOfHiddenElements > 0) { - elements.push(

{`+${numberOfHiddenElements} more`}

); - } - } - - return
{elements}
; -}; diff --git a/frontend/src/components/pages/roles/matching-users-card.tsx b/frontend/src/components/pages/roles/matching-users-card.tsx deleted file mode 100644 index 4dc5dc6336..0000000000 --- a/frontend/src/components/pages/roles/matching-users-card.tsx +++ /dev/null @@ -1,273 +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 { parsePrincipal } from 'components/pages/acls/new-acl/acl.model'; -import { Button } from 'components/redpanda-ui/components/button'; -import { Card, CardContent, CardHeader } from 'components/redpanda-ui/components/card'; -import { Empty, EmptyTitle } from 'components/redpanda-ui/components/empty'; -import { Input } from 'components/redpanda-ui/components/input'; -import { Text } from 'components/redpanda-ui/components/typography'; -import { Check, Plus, Trash2, X } 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 { useState } from 'react'; -import { toast } from 'sonner'; - -import { useListRoleMembersQuery, useUpdateRoleMembershipMutation } from '../../../react-query/api/security'; -import { useListUsersQuery } from '../../../react-query/api/user'; -import { useApiStoreHook } from '../../../state/backend-api'; -import { AutocompleteInput } from '../acls/new-acl/autocomplete-input'; - -type MatchingUsersCardProps = { - principalType: string; - principal: string; -}; - -export function MatchingUsersCard({ principalType, principal }: MatchingUsersCardProps) { - const roleName = principalType === 'RedpandaRole' ? parsePrincipal(principal).name : ''; - - const [isAddingUser, setIsAddingUser] = useState(false); - const [newUserName, setNewUserName] = useState(''); - const [principalTypeToAdd, setPrincipalTypeToAdd] = useState<'User' | 'Group'>('User'); - const [deletingPrincipal, setDeletingPrincipal] = useState(null); - - const enterpriseFeaturesUsed = useApiStoreHook((s) => s.enterpriseFeaturesUsed); - const gbacEnabled = enterpriseFeaturesUsed.some((f) => f.name === 'gbac' && f.enabled); - - const { data: membersData, isLoading } = useListRoleMembersQuery(create(ListRoleMembersRequestSchema, { roleName })); - - const { data: usersData } = useListUsersQuery(create(ListUsersRequestSchema)); - - const { mutateAsync: updateMembership, isPending: isSubmitting } = useUpdateRoleMembershipMutation(); - - const userMembers = membersData?.members?.filter((m) => parsePrincipal(m.principal).type === 'User') ?? []; - const groupMembers = membersData?.members?.filter((m) => parsePrincipal(m.principal).type === 'Group') ?? []; - - const handleAddMember = async () => { - if (!newUserName.trim()) { - toast.error(`Please enter a ${principalTypeToAdd === 'Group' ? 'group name' : 'username'}`); - return; - } - - try { - await updateMembership( - create(UpdateRoleMembershipRequestSchema, { - roleName, - add: [{ principal: `${principalTypeToAdd}:${newUserName.trim()}` }], - remove: [], - create: true, - }) - ); - toast.success(`${principalTypeToAdd} "${newUserName}" added to role successfully`); - setNewUserName(''); - setIsAddingUser(false); - setPrincipalTypeToAdd('User'); - } catch (_error) { - // Error handling is done in onError callback - } - }; - - 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'); - setDeletingPrincipal(null); - } catch (_error) { - // Error handling is done in onError callback - } - }; - - const handleCancel = () => { - setNewUserName(''); - setIsAddingUser(false); - setPrincipalTypeToAdd('User'); - }; - - const membersCount = - principalType === 'RedpandaRole' ? userMembers.length + (gbacEnabled ? groupMembers.length : 0) : 3; - - const renderMemberRow = (memberPrincipal: string, displayName: string, testIdPrefix: string) => { - const isDeleting = deletingPrincipal === memberPrincipal; - return ( -
- {displayName} - -
- ); - }; - - return ( -
- - -

Matching users / principals ({membersCount})

-
- -
- {isLoading && principalType === 'RedpandaRole' && ( -
Loading members...
- )} - - {principalType === 'RedpandaRole' && !isLoading && ( - <> - {/* Users section */} - - Users - - {userMembers.length > 0 ? ( - userMembers.map((member) => { - const name = parsePrincipal(member.principal).name || member.principal; - return renderMemberRow(member.principal, name, 'user'); - }) - ) : ( - - No user members - - )} - - {/* Groups section — only when GBAC is enabled */} - {gbacEnabled && ( - <> - - Groups - - {groupMembers.length > 0 ? ( - groupMembers.map((member) => { - const name = parsePrincipal(member.principal).name || member.principal; - return renderMemberRow(member.principal, name, 'group'); - }) - ) : ( - - No group members - - )} - - )} - - )} -
- - {/* Add member — only for RedpandaRole */} - {principalType === 'RedpandaRole' && ( -
- {isAddingUser ? ( -
- {gbacEnabled && ( -
- - -
- )} -
- {principalTypeToAdd === 'User' ? ( - { - const userPrincipal = `User:${user.name}`; - return !membersData?.members?.some((member) => member.principal === userPrincipal); - }) - .map((user) => user.name) || [] - } - value={newUserName} - /> - ) : ( - setNewUserName(e.target.value)} - placeholder="Enter group name..." - size="sm" - value={newUserName} - /> - )} - - -
-
- ) : ( - - )} -
- )} -
-
-
- ); -} diff --git a/frontend/src/components/pages/roles/role-create-page.tsx b/frontend/src/components/pages/roles/role-create-page.tsx deleted file mode 100644 index 990435e8d4..0000000000 --- a/frontend/src/components/pages/roles/role-create-page.tsx +++ /dev/null @@ -1,89 +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 { create } from '@bufbuild/protobuf'; -import { useNavigate } from '@tanstack/react-router'; -import { - convertRulesToCreateACLRequests, - handleResponses, - PrincipalTypeRedpandaRole, - parsePrincipal, - type Rule, -} from 'components/pages/acls/new-acl/acl.model'; -import CreateACL from 'components/pages/acls/new-acl/create-acl'; -import { CreateRoleRequestSchema } from 'protogen/redpanda/api/dataplane/v1/security_pb'; -import { useEffect } from 'react'; -import { toast } from 'sonner'; - -import { useCreateAcls } from '../../../react-query/api/acl'; -import { useCreateRoleMutation } from '../../../react-query/api/security'; -import { uiState } from '../../../state/ui-state'; -import PageContent from '../../misc/page-content'; - -const RoleCreatePage = () => { - const navigate = useNavigate(); - - useEffect(() => { - uiState.pageBreadcrumbs = [ - { title: 'Security', linkTo: '/security' }, - { title: 'Roles', linkTo: '/security/roles' }, - { title: 'Create Role', linkTo: '' }, - ]; - }, []); - - const { createAcls } = useCreateAcls(); - const { mutateAsync: createRole } = useCreateRoleMutation(); - - const createRoleAclMutation = async (principal: string, host: string, rules: Rule[]) => { - // Extract the role name from the principal (format: "RedpandaRole:roleName") - const roleName = parsePrincipal(principal).name; - - if (!roleName || roleName.trim() === '') { - toast.error('Please enter a role name'); - return; - } - - try { - // First create the role - await createRole( - create(CreateRoleRequestSchema, { - role: { - name: roleName, - }, - }) - ); - - toast.success(`Role "${roleName}" created successfully`); - - // Then create the ACLs for the role - const result = convertRulesToCreateACLRequests(rules, principal, host); - const applyResult = await createAcls(result); - handleResponses(applyResult.errors, applyResult.created); - - navigate({ to: `/security/roles/${roleName}/details` }); - } catch (error) { - toast.error(`Failed to create role: ${error}`); - } - }; - - return ( - - navigate({ to: '/security/$tab', params: { tab: 'roles' } })} - onSubmit={createRoleAclMutation} - principalType={PrincipalTypeRedpandaRole} - /> - - ); -}; - -export default RoleCreatePage; diff --git a/frontend/src/components/pages/roles/role-detail-page.tsx b/frontend/src/components/pages/roles/role-detail-page.tsx deleted file mode 100644 index 988f72b671..0000000000 --- a/frontend/src/components/pages/roles/role-detail-page.tsx +++ /dev/null @@ -1,181 +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/details'); - -import { Eye, Pencil } from 'lucide-react'; -import { useEffect, useMemo } from 'react'; -import { uiState } from 'state/ui-state'; - -import { MatchingUsersCard } from './matching-users-card'; -import { useGetAclsByPrincipal } from '../../../react-query/api/acl'; -import PageContent from '../../misc/page-content'; -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 type { AclDetail } from '../acls/new-acl/acl.model'; -import { ACLDetails } from '../acls/new-acl/acl-details'; - -type SecurityAclRulesTableProps = { - data: AclDetail[]; - roleName: string; -}; - -// Table to display multiple ACL rules for a role -const SecurityAclRulesTable = ({ data, roleName }: SecurityAclRulesTableProps) => { - const navigate = useNavigate(); - - return ( - - -

Security ACL rules

-
- - - - - Principal - Host - ACLs count - {''} - - - - {data.map((aclData) => ( - - - {aclData.sharedConfig.principal} - - {aclData.sharedConfig.host} - {aclData.rules.length} - -
- - -
-
-
- ))} -
-
-
-
- ); -}; - -const RoleDetailPage = () => { - const { roleName } = routeApi.useParams(); - const navigate = useNavigate({ from: '/security/roles/$roleName/details' }); - const search = routeApi.useSearch(); - const host = search.host ?? undefined; - - useEffect(() => { - uiState.pageBreadcrumbs = [ - { title: 'Security', linkTo: '/security' }, - { title: 'Roles', linkTo: '/security/roles' }, - { title: roleName, linkTo: `/security/roles/${roleName}/details` }, - { title: 'Role Configuration Details', linkTo: '' }, - ]; - }, [roleName]); - - // Fetch ACLs for the role - const { data, isLoading } = useGetAclsByPrincipal(`RedpandaRole:${roleName}`, host); - - const renderACLInformation = useMemo(() => { - if (!data || data.length === 0) { - return ( -
-
No Role data found.
-
- ); - } - - if (data.length === 1) { - const acl = data[0]; - return ; - } - return ; - }, [data, roleName]); - - if (isLoading) { - return ( - -
-
Loading role details...
-
-
- ); - } - - return ( - -
- - Configuration for role: {roleName} - - {(!!host || data?.length === 1) && ( -
- -
- )} -
- -
-
{renderACLInformation}
- -
-
- ); -}; - -export default RoleDetailPage; diff --git a/frontend/src/components/pages/roles/role-update-page.tsx b/frontend/src/components/pages/roles/role-update-page.tsx deleted file mode 100644 index f3884b0ccc..0000000000 --- a/frontend/src/components/pages/roles/role-update-page.tsx +++ /dev/null @@ -1,133 +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 { - getOperationsForResourceType, - handleResponses, - ModeAllowAll, - ModeDenyAll, - OperationTypeAllow, - OperationTypeDeny, - PrincipalTypeRedpandaRole, - type Rule, - type SharedConfig, -} from 'components/pages/acls/new-acl/acl.model'; -import CreateACL from 'components/pages/acls/new-acl/create-acl'; -import { HostSelector } from 'components/pages/acls/new-acl/host-selector'; -import { useEffect } from 'react'; - -import { useGetAclsByPrincipal, useUpdateAclMutation } from '../../../react-query/api/acl'; -import { uiState } from '../../../state/ui-state'; -import PageContent from '../../misc/page-content'; - -const RoleUpdatePage = () => { - const navigate = useNavigate({ from: '/security/roles/$roleName/update' }); - const { roleName } = routeApi.useParams(); - const search = routeApi.useSearch(); - const host = search.host ?? undefined; - - const { applyUpdates } = useUpdateAclMutation(); - - useEffect(() => { - uiState.pageBreadcrumbs = [ - { title: 'Security', linkTo: '/security' }, - { title: 'Roles', linkTo: '/security/roles' }, - { title: roleName, linkTo: `/security/roles/${roleName}/details` }, - { title: 'Update Role', linkTo: '', heading: '' }, - ]; - }, [roleName]); - - // 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[]) => { - const applyResult = await applyUpdates(actualRules, sharedConfig, rules); - handleResponses(applyResult.errors, applyResult.created); - - navigate({ - to: `/security/roles/${roleName}/details`, - search: { host }, - }); - }; - - 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 ( - - - navigate({ - to: `/security/roles/${roleName}/details`, - search: { host }, - }) - } - onSubmit={updateRoleAclMutation(acl?.rules ?? [], acl?.sharedConfig ?? emptySharedConfig)} - principalType={PrincipalTypeRedpandaRole} - rules={rulesWithAllOperations.length > 0 ? rulesWithAllOperations : undefined} - sharedConfig={acl?.sharedConfig ?? emptySharedConfig} - /> - - ); -}; - -export default RoleUpdatePage; diff --git a/frontend/src/components/pages/roles/user-acls-card.test.tsx b/frontend/src/components/pages/roles/user-acls-card.test.tsx deleted file mode 100644 index 40676d876b..0000000000 --- a/frontend/src/components/pages/roles/user-acls-card.test.tsx +++ /dev/null @@ -1,108 +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 } from 'test-utils'; - -import { UserAclsCard } from './user-acls-card'; -import type { AclDetail } from '../acls/new-acl/acl.model'; - -const mockAcls: AclDetail[] = [ - { - sharedConfig: { - principal: 'User:test-user', - host: '*', - }, - rules: [ - { - id: 1, - resourceType: 'topic', - mode: 'custom', - selectorType: 'literal', - selectorValue: 'test-topic', - operations: { - READ: 'allow', - WRITE: 'allow', - }, - }, - ], - }, - { - sharedConfig: { - principal: 'User:test-user', - host: '192.168.1.1', - }, - rules: [ - { - id: 2, - resourceType: 'cluster', - mode: 'custom', - selectorType: 'any', - selectorValue: '', - operations: { - DESCRIBE: 'allow', - }, - }, - ], - }, -]; - -describe('UserAclsCard', () => { - test('should render empty state when no ACLs provided', () => { - renderWithFileRoutes(); - - expect(screen.getByText('ACLs (0)')).toBeInTheDocument(); - expect(screen.getByText('No ACLs assigned to this user.')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Create ACL' })).toBeInTheDocument(); - }); - - test('should render empty state when acls is undefined', () => { - renderWithFileRoutes(); - - expect(screen.getByText('ACLs (0)')).toBeInTheDocument(); - expect(screen.getByText('No ACLs assigned to this user.')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Create ACL' })).toBeInTheDocument(); - }); - - test('should render ACL table with correct count', () => { - renderWithFileRoutes(); - - expect(screen.getByText('ACLs (2)')).toBeInTheDocument(); - }); - - test('should render ACL rows with principal and host', () => { - renderWithFileRoutes(); - - 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'); - }); - - test('should render action buttons for each ACL', () => { - renderWithFileRoutes(); - - // Check toggle buttons - expect(screen.getByTestId('toggle-acl-User:test-user-*')).toBeInTheDocument(); - expect(screen.getByTestId('toggle-acl-User:test-user-192.168.1.1')).toBeInTheDocument(); - - // Check edit buttons - expect(screen.getByTestId('edit-acl-User:test-user-*')).toBeInTheDocument(); - expect(screen.getByTestId('edit-acl-User:test-user-192.168.1.1')).toBeInTheDocument(); - }); - - test('should render table headers', () => { - renderWithFileRoutes(); - - expect(screen.getByText('Name')).toBeInTheDocument(); - expect(screen.getByText('Hosts')).toBeInTheDocument(); - expect(screen.getByText('Actions')).toBeInTheDocument(); - }); -}); diff --git a/frontend/src/components/pages/roles/user-acls-card.tsx b/frontend/src/components/pages/roles/user-acls-card.tsx deleted file mode 100644 index 0510f02832..0000000000 --- a/frontend/src/components/pages/roles/user-acls-card.tsx +++ /dev/null @@ -1,159 +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 { useNavigate } from '@tanstack/react-router'; -import { Eye, EyeOff, Pencil } from 'lucide-react'; -import { useState } from 'react'; - -import { Button } from '../../redpanda-ui/components/button'; -import { Card, CardAction, CardContent, CardHeader, CardTitle } from '../../redpanda-ui/components/card'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../redpanda-ui/components/table'; -import { type AclDetail, getRuleDataTestId, parsePrincipal } from '../acls/new-acl/acl.model'; -import { OperationsBadge } from '../acls/new-acl/operations-badge'; - -type UserAclsCardProps = { - acls?: AclDetail[]; -}; - -type AclTableRowProps = { - acl: AclDetail; - isExpanded: boolean; - onToggle: () => void; -}; - -const AclTableRow = ({ acl, isExpanded, onToggle }: AclTableRowProps) => { - const rowKey = `${acl.sharedConfig.principal}-${acl.sharedConfig.host}`; - const navigate = useNavigate(); - - return [ - - {acl.sharedConfig.principal} - {acl.sharedConfig.host} - -
- - -
-
-
, - isExpanded && ( - - -
-
ACL Rules ({acl.rules.length})
- {acl.rules.map((rule) => ( -
- -
- ))} -
-
-
- ), - ]; -}; - -export const UserAclsCard = ({ acls }: UserAclsCardProps) => { - const navigate = useNavigate(); - const [expandedRows, setExpandedRows] = useState>(new Set()); - - const toggleRow = (key: string) => { - setExpandedRows((prev) => { - const next = new Set(prev); - if (next.has(key)) { - next.delete(key); - } else { - next.add(key); - } - return next; - }); - }; - - if (!acls || acls.length === 0) { - return ( - - - ACLs (0) - - - - - -

No ACLs assigned to this user.

-
-
- ); - } - - return ( - - - ACLs ({acls.length}) - - - - - - Name - Hosts - Actions - - - - {acls.flatMap((acl) => { - const rowKey = `${acl.sharedConfig.principal}-${acl.sharedConfig.host}`; - const isExpanded = expandedRows.has(rowKey); - - return ( - toggleRow(rowKey)} - /> - ); - })} - -
-
-
- ); -}; diff --git a/frontend/src/components/pages/roles/user-information-card.tsx b/frontend/src/components/pages/roles/user-information-card.tsx deleted file mode 100644 index 29ec6f0d41..0000000000 --- a/frontend/src/components/pages/roles/user-information-card.tsx +++ /dev/null @@ -1,52 +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 { Button } from '../../redpanda-ui/components/button'; -import { Card, CardContent, CardHeader, CardTitle } from '../../redpanda-ui/components/card'; -import { Separator } from '../../redpanda-ui/components/separator'; -import { Text } from '../../redpanda-ui/components/typography'; - -type UserInformationCardProps = { - username: string; - onEditPassword?: () => void; -}; - -export const UserInformationCard = ({ username, onEditPassword }: UserInformationCardProps) => { - return ( - - - User information - - - {/* Username Row */} -
- Username - {username} -
- - - - {/* Password Row */} -
- Password - Passwords cannot be viewed -
- {Boolean(onEditPassword) && ( - - )} -
-
-
-
- ); -}; diff --git a/frontend/src/components/pages/roles/user-roles-card.test.tsx b/frontend/src/components/pages/roles/user-roles-card.test.tsx deleted file mode 100644 index 821950d09f..0000000000 --- a/frontend/src/components/pages/roles/user-roles-card.test.tsx +++ /dev/null @@ -1,75 +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 } from 'test-utils'; - -import { UserRolesCard } from './user-roles-card'; - -const mockRoles = [ - { - principalType: 'RedpandaRole', - principalName: 'admin', - }, - { - principalType: 'RedpandaRole', - principalName: 'viewer', - }, -]; - -describe('UserRolesCard', () => { - test('should render empty state when no roles provided', () => { - renderWithFileRoutes(); - - expect(screen.getByText('Roles')).toBeInTheDocument(); - expect(screen.getByText('No permissions assigned to this user.')).toBeInTheDocument(); - }); - - test('should render Assign Role button in empty state when onChangeRoles is provided', () => { - const mockOnChangeRoles = vi.fn(); - renderWithFileRoutes(); - - expect(screen.getByTestId('assign-role-button')).toBeInTheDocument(); - }); - - test('should not render Assign Role button in empty state when onChangeRoles is not provided', () => { - renderWithFileRoutes(); - - expect(screen.queryByTestId('assign-role-button')).not.toBeInTheDocument(); - }); - - test('should render roles table with role names', () => { - renderWithFileRoutes(); - - expect(screen.getByTestId('role-name-admin')).toHaveTextContent('admin'); - expect(screen.getByTestId('role-name-viewer')).toHaveTextContent('viewer'); - }); - - test('should render action buttons for each role', () => { - renderWithFileRoutes(); - - expect(screen.getByTestId('view-role-admin')).toBeInTheDocument(); - expect(screen.getByTestId('view-role-viewer')).toBeInTheDocument(); - }); - - test('should render table headers', () => { - renderWithFileRoutes(); - - expect(screen.getByText('Name')).toBeInTheDocument(); - expect(screen.getByText('Actions')).toBeInTheDocument(); - }); - - test('should render Change Role button when roles exist and onChangeRoles is provided', () => { - const mockOnChangeRoles = vi.fn(); - renderWithFileRoutes(); - - expect(screen.getByTestId('change-role-button')).toBeInTheDocument(); - }); -}); diff --git a/frontend/src/components/pages/roles/user-roles-card.tsx b/frontend/src/components/pages/roles/user-roles-card.tsx deleted file mode 100644 index b3d206ffac..0000000000 --- a/frontend/src/components/pages/roles/user-roles-card.tsx +++ /dev/null @@ -1,187 +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 { useNavigate } from '@tanstack/react-router'; -import { Eye, EyeOff, Pencil } from 'lucide-react'; -import { useState } from 'react'; - -import { useGetAclsByPrincipal } from '../../../react-query/api/acl'; -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 '../acls/new-acl/acl.model'; -import { getRuleDataTestId } from '../acls/new-acl/acl.model'; -import { OperationsBadge } from '../acls/new-acl/operations-badge'; - -type Role = { - principalType: string; - principalName: string; -}; - -type UserRolesCardProps = { - roles: Role[]; - onChangeRoles?: () => void; -}; - -type RoleTableRowProps = { - role: Role; - isExpanded: boolean; - onToggle: () => void; -}; - -const RoleTableRow = ({ role, isExpanded, onToggle }: RoleTableRowProps) => { - const navigate = useNavigate(); - const { data: acls, isLoading } = useGetAclsByPrincipal( - `RedpandaRole:${role.principalName}`, - undefined, - undefined, - { - enabled: isExpanded, - } - ); - const rowKey = role.principalName; - - 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) => ( -
- -
- ))} -
- ))} -
-
-
- ), - ]; -}; - -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; - }); - }; - - if (roles.length === 0) { - return ( - - - Roles - - {Boolean(onChangeRoles) && ( - - )} - - - -

No permissions assigned to this user.

-
-
- ); - } - - return ( - - - Roles - - {Boolean(onChangeRoles) && ( - - )} - - - - - - - Name - Actions - - - - {roles.flatMap((r) => { - const rowKey = r.principalName; - const isExpanded = expandedRows.has(rowKey); - - return ( - toggleRow(rowKey)} - role={r} - /> - ); - })} - -
-
-
- ); -}; 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 af1e50bffd..66cab4d057 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 @@ -5,7 +5,7 @@ import { createConnectQueryKey } from '@connectrpc/connect-query'; import { zodResolver } from '@hookform/resolvers/zod'; import { useQueryClient } from '@tanstack/react-query'; import { Link as TanStackRouterLink } from '@tanstack/react-router'; -import { generatePassword } from 'components/pages/acls/user-create'; +import { generatePassword } from 'components/pages/security/create-user-dialog'; import { Alert, AlertDescription, AlertTitle } from 'components/redpanda-ui/components/alert'; import { Button } from 'components/redpanda-ui/components/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from 'components/redpanda-ui/components/card'; @@ -544,8 +544,8 @@ export const AddUserStep = forwardRef ACLs {' '} diff --git a/frontend/src/components/pages/security/acl-editor.tsx b/frontend/src/components/pages/security/acl-editor.tsx new file mode 100644 index 0000000000..a67cad634d --- /dev/null +++ b/frontend/src/components/pages/security/acl-editor.tsx @@ -0,0 +1,600 @@ +/** + * 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 { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from 'components/redpanda-ui/components/alert-dialog'; +import { Badge } from 'components/redpanda-ui/components/badge'; +import { Button } from 'components/redpanda-ui/components/button'; +import { Combobox, type ComboboxOption } from 'components/redpanda-ui/components/combobox'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from 'components/redpanda-ui/components/dialog'; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from 'components/redpanda-ui/components/empty'; +import { Input } from 'components/redpanda-ui/components/input'; +import { Label } from 'components/redpanda-ui/components/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from 'components/redpanda-ui/components/select'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'components/redpanda-ui/components/table'; +import { Text } from 'components/redpanda-ui/components/typography'; +import { Info, Plus, Shield, Trash2 } from 'lucide-react'; +import { ACL_ResourcePatternType } from 'protogen/redpanda/api/dataplane/v1/acl_pb'; +import { useEffect, useState } from 'react'; + +// ─── Shared helpers ───────────────────────────────────────────────────────── + +export function truncateText(text: string, maxLen: number): string { + if (text.length <= maxLen) { + return text; + } + return `${text.slice(0, maxLen)}\u2026`; +} + +export const RESOURCE_NAME_MAX = 64; +export const PRINCIPAL_MAX = 60; + +export function getPatternTypeLabel(type?: number): string | null { + if (type === ACL_ResourcePatternType.PREFIXED) { + return 'Prefixed'; + } + return null; +} + +// ─── Shared ACL types & constants ──────────────────────────────────────────── + +export interface ACLEntry { + resourceType: string; + resourceName: string; + operation: string; + permission: string; + host: string; + resourcePatternType?: number; +} + +const resourceTypes: readonly string[] = ['Topic', 'Group', 'Cluster', 'TransactionalId']; + +const operationsByResourceType: Record = { + Topic: ['All', 'Read', 'Write', 'Describe', 'Create', 'Delete', 'Alter', 'DescribeConfigs', 'AlterConfigs'], + Group: ['All', 'Read', 'Describe', 'Delete'], + Cluster: [ + 'All', + 'Create', + 'Describe', + 'Alter', + 'ClusterAction', + 'DescribeConfigs', + 'AlterConfigs', + 'IdempotentWrite', + ], + TransactionalId: ['All', 'Describe', 'Write'], +}; + +const permissions: readonly string[] = ['Allow', 'Deny']; + +type PatternType = 'Literal' | 'Prefixed' | 'Any'; + +function validateHost(host: string): string | null { + const trimmed = host.trim(); + if (!trimmed) { + return 'Host is required'; + } + if (trimmed === '*') { + return null; + } + if (trimmed.includes('/')) { + return 'CIDR notation is not supported. The Kafka ACL API only accepts a single IP address or * for all hosts.'; + } + const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; + const ipv6 = /^[0-9a-fA-F:]+$/; + if (ipv4.test(trimmed)) { + const parts = trimmed.split('.').map(Number); + if (parts.every((p) => p >= 0 && p <= 255)) { + return null; + } + return 'Invalid IPv4 address'; + } + if (ipv6.test(trimmed) && trimmed.includes(':')) { + return null; + } + return 'Host must be * (all hosts) or a valid IP address. CIDR notation is not supported.'; +} + +// ─── ACL Dialog (create) ───────────────────────────────────────────────────── + +interface ACLDialogProps { + open: boolean; + context?: 'role' | 'user'; + onSave: (acl: ACLEntry) => void; + onClose: () => void; + resourceOptionsByType?: Partial>; +} + +export function ACLDialog({ open, context = 'role', onSave, onClose, resourceOptionsByType = {} }: ACLDialogProps) { + const [resourceType, setResourceType] = useState('Topic'); + const [resourceName, setResourceName] = useState(''); + const [operation, setOperation] = useState('All'); + const [permission, setPermission] = useState('Allow'); + const [host, setHost] = useState('*'); + const [patternType, setPatternType] = useState('Literal'); + const [error, setError] = useState(null); + + const resourceOptions = resourceOptionsByType[resourceType as keyof typeof resourceOptionsByType] ?? []; + + useEffect(() => { + if (!open) { + return; + } + setResourceType('Topic'); + setResourceName(''); + setOperation('All'); + setPermission('Allow'); + setHost('*'); + setPatternType('Literal'); + setError(null); + }, [open]); + + const handleSave = () => { + let resolvedResourceName = ''; + if (resourceType === 'Cluster') { + resolvedResourceName = 'kafka-cluster'; + } else if (patternType === 'Any') { + resolvedResourceName = '*'; + } else { + if (!resourceName.trim()) { + setError('Resource name is required'); + return; + } + resolvedResourceName = resourceName.trim(); + } + const hostError = validateHost(host); + if (hostError) { + setError(hostError); + return; + } + const patternTypeMap: Record = { + Literal: ACL_ResourcePatternType.LITERAL, + Prefixed: ACL_ResourcePatternType.PREFIXED, + Any: ACL_ResourcePatternType.LITERAL, + }; + + onSave({ + resourceType, + resourceName: resolvedResourceName, + operation, + permission, + host: host.trim(), + resourcePatternType: patternTypeMap[patternType], + }); + }; + + const entityLabel = context === 'user' ? 'user' : 'role'; + + return ( + !o && onClose()} open={open}> + + + Add ACL + Define a new access control rule for this {entityLabel}. + + +
+ {/* Resource Type */} +
+ + +
+ + {/* Pattern Type — hidden for Cluster */} + {resourceType !== 'Cluster' && ( +
+ +
+ {(['Literal', 'Prefixed', 'Any'] as const).map((pt) => ( + + ))} +
+

+ {patternType === 'Literal' && 'Matches the exact resource name.'} + {patternType === 'Prefixed' && 'Matches any resource whose name starts with this prefix.'} + {patternType === 'Any' && 'Matches all resources of this type (wildcard).'} +

+
+ )} + + {resourceType === 'Cluster' && ( +
+ +

+ Cluster ACLs apply to the entire Kafka cluster. No resource name is needed. +

+
+ )} + + {/* Resource Name — hidden for Cluster and "Any" pattern */} + {resourceType !== 'Cluster' && patternType !== 'Any' && ( +
+ + { + setResourceName(value); + setError(null); + }} + options={resourceOptions} + placeholder={patternType === 'Prefixed' ? 'e.g. com.company.events' : 'e.g. my-topic'} + value={resourceName} + /> +
+ )} + + {/* Operation */} +
+ + +
+ + {/* Permission */} +
+ + +
+ + {/* Host */} +
+
+ +

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

+
+ { + setHost(e.target.value); + setError(null); + }} + placeholder="*" + type="text" + value={host} + /> +
+ + {Boolean(error) &&

{error}

} +
+ + + + + +
+
+ ); +} + +// ─── ACL Remove Confirmation Dialog ────────────────────────────────────────── + +interface ACLRemoveDialogProps { + open: boolean; + acl: ACLEntry | null; + context?: 'role' | 'user'; + onConfirm: () => void; + onClose: () => void; +} + +export function ACLRemoveDialog({ open, acl, context = 'role', onConfirm, onClose }: ACLRemoveDialogProps) { + return ( + !o && onClose()} open={open}> + + + Remove ACL? + + {context === 'user' + ? 'Remove this access control rule from the user?' + : 'Remove this access control rule from the role? Principals assigned to this role will lose this permission.'} + + + {acl && ( +
+
+ + {acl.resourceType} + + {acl.resourceName} +
+

+ {acl.operation} / {acl.permission} / Host: {acl.host} +

+
+ )} + + + + + + + + +
+
+ ); +} + +// ─── ACL Table with actions ────────────────────────────────────────────────── + +interface ACLTableSectionProps { + acls: ACLEntry[]; + context?: 'role' | 'user'; + onAdd: () => void; + onRemove: (index: number) => void; +} + +export function ACLTableSection({ acls, context = 'role', onAdd, onRemove }: ACLTableSectionProps) { + return ( +
+
+
+ + + ACLs + + + {acls.length} {acls.length === 1 ? 'rule' : 'rules'} + +
+ {acls.length > 0 && ( + + )} +
+
+ {acls.length > 0 ? ( + + + + Resource Type + Resource Name + Operation + Permission + Host + + Actions + + + + + {acls.map((acl, idx) => ( + + + + {acl.resourceType} + + + + + + {truncateText(acl.resourceName, RESOURCE_NAME_MAX)} + + {Boolean(getPatternTypeLabel(acl.resourcePatternType)) && ( + + {getPatternTypeLabel(acl.resourcePatternType)} + + )} + + + {acl.operation} + + + {acl.permission} + + + {acl.host} + + + + + ))} + +
+ ) : ( + + + + + + No ACLs defined + + {context === 'user' + ? 'This user has no direct ACLs. Permissions may be inherited through assigned roles.' + : 'Add ACLs to define what this role can access.'} + + + + + )} +
+
+ ); +} + +// ─── Principal step dialog (used by permissions tab) ───────────────────────── + +interface PrincipalStepDialogProps { + options: ComboboxOption[]; + value: string; + onChange: (v: string) => void; + onContinue: () => void; + onClose: () => void; +} + +export function PrincipalStepDialog({ options, value, onChange, onContinue, onClose }: PrincipalStepDialogProps) { + const [error, setError] = useState(null); + + const handleSubmit = () => { + const trimmed = value.trim(); + if (!trimmed) { + setError('Principal is required'); + return; + } + if (!trimmed.includes(':')) { + setError('Principal must include a type prefix (e.g. User:name)'); + return; + } + onContinue(); + }; + + return ( + !o && onClose()} open> + + + Create ACL + + Enter the principal this ACL will apply to, then define the access rule. + + +
+
+
+ +

+ Type a principal in the format Type:name. + Supported types: User, Group, RedpandaRole. +

+
+ { + onChange(nextValue); + setError(null); + }} + options={options} + placeholder="e.g. User:ben or Group:my-team" + value={value} + /> + {Boolean(error) &&

{error}

} +
+
+ + + + +
+
+ ); +} diff --git a/frontend/src/components/pages/security/change-password-dialog.tsx b/frontend/src/components/pages/security/change-password-dialog.tsx new file mode 100644 index 0000000000..7d1d67ee88 --- /dev/null +++ b/frontend/src/components/pages/security/change-password-dialog.tsx @@ -0,0 +1,233 @@ +/** + * 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 { create } from '@bufbuild/protobuf'; +import { Button } from 'components/redpanda-ui/components/button'; +import { Checkbox } from 'components/redpanda-ui/components/checkbox'; +import { CopyButton } from 'components/redpanda-ui/components/copy-button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from 'components/redpanda-ui/components/dialog'; +import { Input } from 'components/redpanda-ui/components/input'; +import { Label } from 'components/redpanda-ui/components/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from 'components/redpanda-ui/components/select'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'components/redpanda-ui/components/tooltip'; +import { Text } from 'components/redpanda-ui/components/typography'; +import { RefreshCw } from 'lucide-react'; +import { UpdateUserRequest_UserSchema, UpdateUserRequestSchema } from 'protogen/redpanda/api/dataplane/v1/user_pb'; +import { useEffect, useState } from 'react'; +import { getSASLMechanism, useUpdateUserMutationWithToast } from 'react-query/api/user'; +import { toast } from 'sonner'; + +import { generatePassword } from './create-user-dialog'; + +const saslMechanisms = [ + { id: 'SCRAM-SHA-256', name: 'SCRAM-SHA-256', description: 'Salted Challenge Response with SHA-256' }, + { id: 'SCRAM-SHA-512', name: 'SCRAM-SHA-512', description: 'Salted Challenge Response with SHA-512 (recommended)' }, +] as const; + +interface ChangePasswordDialogProps { + open: boolean; + userName: string; + currentMechanism?: string | null; + onClose: () => void; +} + +export function ChangePasswordDialog({ open, userName, currentMechanism, onClose }: ChangePasswordDialogProps) { + const [newPassword, setNewPassword] = useState(() => generatePassword(24, true)); + const [selectedMechanism, setSelectedMechanism] = useState<'SCRAM-SHA-256' | 'SCRAM-SHA-512'>( + currentMechanism === 'SCRAM-SHA-256' || currentMechanism === 'SCRAM-SHA-512' ? currentMechanism : 'SCRAM-SHA-512' + ); + const [error, setError] = useState(null); + const [includeSpecialChars, setIncludeSpecialChars] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { mutateAsync: updateUser } = useUpdateUserMutationWithToast(); + + const resetForm = () => { + setNewPassword(generatePassword(24, true)); + setSelectedMechanism( + currentMechanism === 'SCRAM-SHA-256' || currentMechanism === 'SCRAM-SHA-512' ? currentMechanism : 'SCRAM-SHA-512' + ); + setError(null); + setIncludeSpecialChars(true); + setIsSubmitting(false); + }; + + useEffect(() => { + if (!open) { + return; + } + setNewPassword(generatePassword(24, true)); + setSelectedMechanism( + currentMechanism === 'SCRAM-SHA-256' || currentMechanism === 'SCRAM-SHA-512' ? currentMechanism : 'SCRAM-SHA-512' + ); + setError(null); + setIncludeSpecialChars(true); + setIsSubmitting(false); + }, [currentMechanism, open]); + + const handleClose = () => { + resetForm(); + onClose(); + }; + + const handleGenerate = () => { + const pwd = generatePassword(24, includeSpecialChars); + setNewPassword(pwd); + setError(null); + }; + + const handleSubmit = async () => { + if (!newPassword) { + setError('Password is required'); + return; + } + if (newPassword.length < 8) { + setError('Password must be at least 8 characters'); + return; + } + if (newPassword.length > 64) { + setError('Password should not exceed 64 characters'); + return; + } + setError(null); + setIsSubmitting(true); + + try { + await updateUser( + create(UpdateUserRequestSchema, { + user: create(UpdateUserRequest_UserSchema, { + name: userName, + password: newPassword, + mechanism: getSASLMechanism(selectedMechanism), + }), + }) + ); + toast.success('Password updated successfully'); + handleClose(); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to update password'; + toast.error(message); + } finally { + setIsSubmitting(false); + } + }; + + return ( + !o && handleClose()} open={open}> + + + Change Password + +
+

Set a new password for this user.

+

{userName}

+
+
+
+ +
+ {/* Mechanism Selection */} +
+ + +
+ + {/* New Password */} +
+
+ + Must be at least 8 characters and should not exceed 64 characters. +
+
+ { + setNewPassword(e.target.value); + setError(null); + }} + placeholder="Enter new password" + type="password" + value={newPassword} + /> + + + + + + Generate password + + + +
+
+ setIncludeSpecialChars(checked === true)} + /> + +
+
+ + {Boolean(error) &&

{error}

} +
+ + + + + +
+
+ ); +} diff --git a/frontend/src/components/pages/security/create-user-dialog.tsx b/frontend/src/components/pages/security/create-user-dialog.tsx new file mode 100644 index 0000000000..51dd7bea1c --- /dev/null +++ b/frontend/src/components/pages/security/create-user-dialog.tsx @@ -0,0 +1,359 @@ +/** + * 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 { create } from '@bufbuild/protobuf'; +import { Alert, AlertDescription } from 'components/redpanda-ui/components/alert'; +import { Button } from 'components/redpanda-ui/components/button'; +import { Checkbox } from 'components/redpanda-ui/components/checkbox'; +import { CopyButton } from 'components/redpanda-ui/components/copy-button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from 'components/redpanda-ui/components/dialog'; +import { Input } from 'components/redpanda-ui/components/input'; +import { Label } from 'components/redpanda-ui/components/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from 'components/redpanda-ui/components/select'; +import { Separator } from 'components/redpanda-ui/components/separator'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'components/redpanda-ui/components/tooltip'; +import { Text } from 'components/redpanda-ui/components/typography'; +import { Info, RefreshCw, Shield, UserCog } from 'lucide-react'; +import { CreateUserRequest_UserSchema, CreateUserRequestSchema } from 'protogen/redpanda/api/dataplane/v1/user_pb'; +import { useState } from 'react'; +import { getSASLMechanism, useCreateUserMutation } from 'react-query/api/user'; +import { toast } from 'sonner'; + +const saslMechanisms = [ + { id: 'SCRAM-SHA-256', name: 'SCRAM-SHA-256', description: 'Salted Challenge Response with SHA-256' }, + { id: 'SCRAM-SHA-512', name: 'SCRAM-SHA-512', description: 'Salted Challenge Response with SHA-512 (recommended)' }, +] as const; + +export function generatePassword(length: number, includeSpecial: boolean): string { + const lowercase = 'abcdefghijklmnopqrstuvwxyz'; + const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + const numbers = '0123456789'; + const special = '!@#$%^&*()_+-=[]{}|;:,.<>?'; + let chars = lowercase + uppercase + numbers; + if (includeSpecial) { + chars += special; + } + let pwd = ''; + for (let i = 0; i < length; i++) { + pwd += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return pwd; +} + +function createGeneratedPassword(includeSpecial: boolean): string { + return generatePassword(24, includeSpecial); +} + +interface CreateUserDialogProps { + open: boolean; + onClose: () => void; + onNavigateToTab: (tab: string) => void; +} + +export function CreateUserDialog({ open, onClose, onNavigateToTab }: CreateUserDialogProps) { + const [step, setStep] = useState<'form' | 'success'>('form'); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(() => createGeneratedPassword(true)); + const [mechanism, setMechanism] = useState<'SCRAM-SHA-256' | 'SCRAM-SHA-512'>('SCRAM-SHA-256'); + const [error, setError] = useState(null); + const [includeSpecial, setIncludeSpecial] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { mutateAsync: createUserMutation } = useCreateUserMutation(); + + const resetForm = () => { + setStep('form'); + setUsername(''); + setPassword(createGeneratedPassword(true)); + setMechanism('SCRAM-SHA-256'); + setError(null); + setIncludeSpecial(true); + setIsSubmitting(false); + }; + + const handleClose = () => { + resetForm(); + onClose(); + }; + + const handleGeneratePassword = () => { + const pwd = createGeneratedPassword(includeSpecial); + setPassword(pwd); + setError(null); + }; + + const handleCreate = async () => { + if (!username.trim()) { + setError('Username is required'); + return; + } + if (/\s/.test(username)) { + setError('Username must not contain whitespace'); + return; + } + if (!/^[a-zA-Z0-9._-]+$/.test(username)) { + setError('Only letters, numbers, dots, hyphens, and underscores are allowed'); + return; + } + if (!password) { + setError('Password is required'); + return; + } + if (password.length < 4) { + setError('Password must be at least 4 characters'); + return; + } + if (password.length > 64) { + setError('Password should not exceed 64 characters'); + return; + } + setError(null); + setIsSubmitting(true); + + try { + await createUserMutation( + create(CreateUserRequestSchema, { + user: create(CreateUserRequest_UserSchema, { + name: username.trim(), + password, + mechanism: getSASLMechanism(mechanism), + }), + }) + ); + setStep('success'); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to create user'; + toast.error(message); + } finally { + setIsSubmitting(false); + } + }; + + return ( + !o && handleClose()} open={open}> + + {step === 'form' ? ( + <> + + Create User + Create a new SASL-SCRAM user for your cluster. + + +
+ {/* Username */} +
+
+ + + Must not contain any whitespace. Dots, hyphens, and underscores may be used. + +
+ { + setUsername(e.target.value); + setError(null); + }} + placeholder="Username" + type="text" + value={username} + /> +
+ + {/* Password */} +
+
+ + Must be at least 4 characters and should not exceed 64 characters. +
+
+ { + setPassword(e.target.value); + setError(null); + }} + placeholder="Enter password" + type="password" + value={password} + /> + + + + + + Generate password + + + +
+
+ setIncludeSpecial(checked === true)} + /> + +
+
+ + {/* SASL Mechanism */} +
+ + +
+ + {Boolean(error) &&

{error}

} +
+ + + + + + + ) : ( + <> + + User Created + The user has been created. Make sure to save the credentials below. + + +
+ + + + You will not be able to view this password again. Make sure that it is copied and saved. + + + + {/* Username */} +
+ + Username + +
+ {username} + +
+
+ + {/* Password */} +
+ + Password + +
+ + +
+
+ + {/* Mechanism */} +
+ + Mechanism + +

{mechanism}

+
+ + + + {/* Next steps hint */} +
+ + What's next? + +

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

+
+ + +
+
+
+ + + + + + )} +
+
+ ); +} diff --git a/frontend/src/components/pages/security/permissions-tab.tsx b/frontend/src/components/pages/security/permissions-tab.tsx new file mode 100644 index 0000000000..147c7dcdbb --- /dev/null +++ b/frontend/src/components/pages/security/permissions-tab.tsx @@ -0,0 +1,794 @@ +/** + * 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 { create } from '@bufbuild/protobuf'; +import { createQueryOptions, useTransport } from '@connectrpc/connect-query'; +import { useQueries } from '@tanstack/react-query'; +import { Link } from '@tanstack/react-router'; +import { getAclFromAclListResponse } from 'components/pages/acls/new-acl/acl.model'; +import { Badge } from 'components/redpanda-ui/components/badge'; +import { Button } from 'components/redpanda-ui/components/button'; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from 'components/redpanda-ui/components/empty'; +import { Input } from 'components/redpanda-ui/components/input'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'components/redpanda-ui/components/tooltip'; +import { Text } from 'components/redpanda-ui/components/typography'; +import { ChevronDown, ChevronRight, ExternalLink, Lock, Plus, Search, Shield, Trash2, X } from 'lucide-react'; +import { + ACL_Operation, + ACL_PermissionType, + ACL_ResourcePatternType, + ACL_ResourceType, + CreateACLRequestSchema, + DeleteACLsRequestSchema, + type ListACLsResponse, +} from 'protogen/redpanda/api/dataplane/v1/acl_pb'; +import { listACLs } from 'protogen/redpanda/api/dataplane/v1/acl-ACLService_connectquery'; +import { getRole } from 'protogen/redpanda/api/dataplane/v1/security-SecurityService_connectquery'; +import { useMemo, useState } from 'react'; +import { + getACLOperation, + useDeleteAclMutation, + useLegacyCreateACLMutation, + useListACLsQuery, +} from 'react-query/api/acl'; +import { useListRolesQuery } from 'react-query/api/security'; +import { useLegacyListUsersQuery } from 'react-query/api/user'; +import { toast } from 'sonner'; + +import { + ACLDialog, + type ACLEntry, + ACLRemoveDialog, + getPatternTypeLabel, + PRINCIPAL_MAX, + PrincipalStepDialog, + RESOURCE_NAME_MAX, + truncateText, +} from './acl-editor'; +import { + buildPrincipalAutocompleteOptions, + buildResourceOptionsByType, + flattenAclDetails, + getAclResourceTypeLabel, + sortAclEntries, + sortByName, + sortByPrincipal, +} from './security-acl-utils'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +type DirectACL = ACLEntry & { + principal: string; + resourcePatternType: number; +}; + +type InheritedACL = ACLEntry & { + roleName: string; +}; + +type PrincipalGroup = { + principal: string; + isBrokerManaged: boolean; + assignedRoles: { name: string }[]; + directAcls: DirectACL[]; + inheritedAcls: InheritedACL[]; + denyCount: number; +}; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function parsePrincipal(principal: string): { type: string; name: string } { + const colonIndex = principal.indexOf(':'); + if (colonIndex === -1) { + return { type: 'User', name: principal }; + } + return { + type: principal.substring(0, colonIndex), + name: principal.substring(colonIndex + 1), + }; +} + +function getOperationStr(op: ACL_Operation): string { + return getACLOperation(op); +} + +function getPermissionStr(pt: ACL_PermissionType): string { + switch (pt) { + case ACL_PermissionType.ALLOW: + return 'Allow'; + case ACL_PermissionType.DENY: + return 'Deny'; + default: + return 'Allow'; + } +} + +function getAclSummaryText(directCount: number, inheritedCount: number): string { + const directLabel = `${directCount} direct ${directCount === 1 ? 'ACL' : 'ACLs'}`; + const inheritedLabel = `${inheritedCount} ${inheritedCount === 1 ? 'ACL' : 'ACLs'} inherited from roles`; + + if (directCount > 0 && inheritedCount > 0) { + return `${directLabel}, ${inheritedLabel}`; + } + if (inheritedCount > 0) { + return inheritedLabel; + } + return directLabel; +} + +function getPermissionColorClass(permission: string, inherited: boolean): string { + if (inherited) { + return permission === 'Allow' ? 'text-emerald-600/50' : 'text-destructive/50'; + } + return permission === 'Allow' ? 'text-emerald-600' : 'text-destructive'; +} + +// ─── Component ────────────────────────────────────────────────────────────── + +export function PermissionsTab() { + const [searchQuery, setSearchQuery] = useState(''); + const [collapsed, setCollapsed] = useState>({}); + + // Dialog state + const [dialogOpen, setDialogOpen] = useState(false); + const [removeTarget, setRemoveTarget] = useState(null); + const [createPrincipal, setCreatePrincipal] = useState(''); + const [createStep, setCreateStep] = useState<'principal' | 'acl'>('principal'); + + // Fetch data + const { data: usersData } = useLegacyListUsersQuery(); + const { data: aclsData } = useListACLsQuery(); + const { data: rolesData } = useListRolesQuery(); + + const users = useMemo(() => sortByName(usersData?.users ?? []), [usersData]); + const aclResources = useMemo(() => aclsData?.aclResources ?? [], [aclsData]); + const roles = useMemo(() => sortByName(rolesData?.roles ?? []), [rolesData]); + const resourceOptionsByType = useMemo(() => buildResourceOptionsByType(aclResources), [aclResources]); + const { mutateAsync: createACL } = useLegacyCreateACLMutation(); + const { mutateAsync: deleteACL } = useDeleteAclMutation(); + const transport = useTransport(); + + const roleDetailQueries = useQueries({ + queries: roles.map((role) => + createQueryOptions( + getRole, + { + roleName: role.name, + }, + { transport } + ) + ), + }); + + const roleAclQueries = useQueries({ + queries: roles.map((role) => ({ + ...createQueryOptions( + listACLs, + { + filter: { + principal: `RedpandaRole:${role.name}`, + }, + }, + { transport } + ), + select: (aclList: ListACLsResponse) => flattenAclDetails(getAclFromAclListResponse(aclList)), + })), + }); + + const principalOptions = useMemo(() => { + const aclPrincipals = aclResources + .flatMap((resource) => resource.acls.map((acl) => acl.principal || '')) + .filter(Boolean); + const roleMembershipPrincipals = roleDetailQueries.flatMap((query) => + (query.data?.members ?? []).map((member) => member.principal || '').filter(Boolean) + ); + + return buildPrincipalAutocompleteOptions({ + principals: [...aclPrincipals, ...roleMembershipPrincipals], + roles: roles.map((role) => role.name), + users: users.map((user) => user.name), + }); + }, [aclResources, roleDetailQueries, roles, users]); + + // Build principal groups from ACL data + const allGroups = useMemo(() => { + const map = new Map(); + const userNames = new Set(users.map((u) => u.name)); + + const getOrCreate = (principal: string): PrincipalGroup => { + let group = map.get(principal); + if (!group) { + const parsed = parsePrincipal(principal); + const isBrokerManaged = parsed.type === 'User' && userNames.has(parsed.name); + group = { + principal, + isBrokerManaged, + assignedRoles: [], + directAcls: [], + inheritedAcls: [], + denyCount: 0, + }; + map.set(principal, group); + } + return group; + }; + + // Add direct ACLs from the ACL list response + for (const resource of aclResources) { + for (const acl of resource.acls) { + const principal = acl.principal || ''; + if (!principal) { + continue; + } + const group = getOrCreate(principal); + group.directAcls.push({ + principal, + resourceType: getAclResourceTypeLabel(resource.resourceType) ?? 'Unknown', + resourceName: resource.resourceName, + operation: getOperationStr(acl.operation), + permission: getPermissionStr(acl.permissionType), + host: acl.host || '*', + resourcePatternType: resource.resourcePatternType, + }); + } + } + + for (const [index, role] of roles.entries()) { + const members = roleDetailQueries[index]?.data?.members ?? []; + const inheritedAcls = roleAclQueries[index]?.data ?? []; + + for (const member of members) { + const principal = member.principal; + if (!principal) { + continue; + } + + const group = getOrCreate(principal); + if (!group.assignedRoles.some((assignedRole) => assignedRole.name === role.name)) { + group.assignedRoles.push({ name: role.name }); + } + + for (const acl of inheritedAcls) { + group.inheritedAcls.push({ + ...acl, + roleName: role.name, + }); + } + } + } + + // Compute deny counts + for (const group of map.values()) { + group.assignedRoles = sortByName(group.assignedRoles); + group.directAcls = sortAclEntries(group.directAcls); + group.inheritedAcls = sortAclEntries(group.inheritedAcls); + group.denyCount = + group.directAcls.filter((a) => a.permission === 'Deny').length + + group.inheritedAcls.filter((a) => a.permission === 'Deny').length; + } + + return sortByPrincipal(Array.from(map.values())); + }, [aclResources, roleAclQueries, roleDetailQueries, roles, users]); + + // Filter groups + const groups = useMemo(() => { + if (!searchQuery) { + return allGroups; + } + const q = searchQuery.toLowerCase(); + return allGroups + .map((group) => { + const principalMatch = group.principal.toLowerCase().includes(q); + const roleMatch = group.assignedRoles.some((r) => r.name.toLowerCase().includes(q)); + const matchingDirect = group.directAcls.filter( + (a) => + a.resourceName.toLowerCase().includes(q) || + a.operation.toLowerCase().includes(q) || + a.resourceType.toLowerCase().includes(q) || + a.host.toLowerCase().includes(q) + ); + const matchingInherited = group.inheritedAcls.filter( + (a) => + a.resourceName.toLowerCase().includes(q) || + a.operation.toLowerCase().includes(q) || + a.resourceType.toLowerCase().includes(q) || + a.host.toLowerCase().includes(q) || + a.roleName.toLowerCase().includes(q) + ); + + if (principalMatch || roleMatch) { + return group; + } + if (matchingDirect.length > 0 || matchingInherited.length > 0) { + return { ...group, directAcls: matchingDirect, inheritedAcls: matchingInherited }; + } + return null; + }) + .filter(Boolean) as PrincipalGroup[]; + }, [allGroups, searchQuery]); + + const toggleGroup = (principal: string) => { + setCollapsed((prev) => ({ ...prev, [principal]: !(prev[principal] ?? false) })); + }; + + // ─── CRUD handlers ────────────────────────────────────────────────────── + + const handleCreate = (preselectedPrincipal?: string) => { + if (preselectedPrincipal) { + setCreatePrincipal(preselectedPrincipal); + setCreateStep('acl'); + } else { + setCreatePrincipal(''); + setCreateStep('principal'); + } + setDialogOpen(true); + }; + + const handleSave = async (entry: ACLEntry) => { + const principal = createPrincipal; + + try { + // Map string values back to proto enums for the API call + const resourceTypeMap: Record = { + Topic: ACL_ResourceType.TOPIC, + Group: ACL_ResourceType.GROUP, + Cluster: ACL_ResourceType.CLUSTER, + TransactionalId: ACL_ResourceType.TRANSACTIONAL_ID, + }; + + const operationMap: Record = { + All: ACL_Operation.ALL, + Read: ACL_Operation.READ, + Write: ACL_Operation.WRITE, + Create: ACL_Operation.CREATE, + Delete: ACL_Operation.DELETE, + Alter: ACL_Operation.ALTER, + Describe: ACL_Operation.DESCRIBE, + ClusterAction: ACL_Operation.CLUSTER_ACTION, + DescribeConfigs: ACL_Operation.DESCRIBE_CONFIGS, + AlterConfigs: ACL_Operation.ALTER_CONFIGS, + IdempotentWrite: ACL_Operation.IDEMPOTENT_WRITE, + }; + + const permissionMap: Record = { + Allow: ACL_PermissionType.ALLOW, + Deny: ACL_PermissionType.DENY, + }; + + await createACL( + create(CreateACLRequestSchema, { + resourceType: resourceTypeMap[entry.resourceType] ?? ACL_ResourceType.TOPIC, + resourceName: entry.resourceName, + resourcePatternType: entry.resourcePatternType ?? ACL_ResourcePatternType.LITERAL, + principal, + host: entry.host, + operation: operationMap[entry.operation] ?? ACL_Operation.ALL, + permissionType: permissionMap[entry.permission] ?? ACL_PermissionType.ALLOW, + }) + ); + toast.success('ACL created'); + setDialogOpen(false); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to create ACL'; + toast.error(message); + } + }; + + const handleRemove = async () => { + if (!removeTarget) { + return; + } + + try { + const resourceTypeMap: Record = { + Topic: ACL_ResourceType.TOPIC, + Group: ACL_ResourceType.GROUP, + Cluster: ACL_ResourceType.CLUSTER, + TransactionalId: ACL_ResourceType.TRANSACTIONAL_ID, + }; + + const operationMap: Record = { + All: ACL_Operation.ALL, + Read: ACL_Operation.READ, + Write: ACL_Operation.WRITE, + Create: ACL_Operation.CREATE, + Delete: ACL_Operation.DELETE, + Alter: ACL_Operation.ALTER, + Describe: ACL_Operation.DESCRIBE, + ClusterAction: ACL_Operation.CLUSTER_ACTION, + DescribeConfigs: ACL_Operation.DESCRIBE_CONFIGS, + AlterConfigs: ACL_Operation.ALTER_CONFIGS, + IdempotentWrite: ACL_Operation.IDEMPOTENT_WRITE, + }; + + const permissionMap: Record = { + Allow: ACL_PermissionType.ALLOW, + Deny: ACL_PermissionType.DENY, + }; + + await deleteACL( + create(DeleteACLsRequestSchema, { + filter: { + principal: removeTarget.principal, + resourceType: resourceTypeMap[removeTarget.resourceType] ?? ACL_ResourceType.TOPIC, + resourceName: removeTarget.resourceName, + host: removeTarget.host, + operation: operationMap[removeTarget.operation] ?? ACL_Operation.ALL, + permissionType: permissionMap[removeTarget.permission] ?? ACL_PermissionType.ALLOW, + resourcePatternType: removeTarget.resourcePatternType || ACL_ResourcePatternType.LITERAL, + }, + }) + ); + toast.success('ACL removed'); + setRemoveTarget(null); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to remove ACL'; + toast.error(message); + } + }; + + 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. + + + {/* Toolbar */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search principals, resources, roles..." + type="text" + value={searchQuery} + /> +
+ {Boolean(searchQuery) && ( + + )} + +
+ + {/* Grouped list */} + {groups.length > 0 ? ( +
+ {groups.map((group) => ( + toggleGroup(group.principal)} + /> + ))} +
+ ) : ( + + + + + {allGroups.length === 0 ? ( + <> + + No principals found + Create ACLs or assign roles to principals to see them here. + + + + ) : ( + <> + + No matching principals + Try a different search query. + + + + )} + + )} + + {/* Table Footer */} + {groups.length > 0 && ( +
+ {searchQuery + ? `${groups.length} of ${allGroups.length} ${allGroups.length === 1 ? 'principal' : 'principals'}` + : `${allGroups.length} ${allGroups.length === 1 ? 'principal' : 'principals'}`} +
+ )} + + {/* Create ACL — principal step */} + {dialogOpen && createStep === 'principal' && ( + setDialogOpen(false)} + onContinue={() => setCreateStep('acl')} + options={principalOptions} + value={createPrincipal} + /> + )} + + {/* Create ACL — ACL fields step */} + {dialogOpen && createStep === 'acl' && ( + setDialogOpen(false)} + onSave={handleSave} + open + resourceOptionsByType={resourceOptionsByType} + /> + )} + + {/* Remove confirmation */} + setRemoveTarget(null)} + onConfirm={handleRemove} + open={removeTarget !== null} + /> +
+ ); +} + +// ─── Principal Group Card ───────────────────────────────────────────────────── + +function PrincipalGroupCard({ + group, + collapsed, + onToggle, + onCreate, + onRemove, +}: { + group: PrincipalGroup; + collapsed: boolean; + onToggle: () => void; + onCreate: (principal: string) => void; + onRemove: (acl: DirectACL) => void; +}) { + const parsed = parsePrincipal(group.principal); + const hasAcls = group.directAcls.length > 0 || group.inheritedAcls.length > 0; + + return ( +
+ {/* Group header */} + + {Boolean(group.isBrokerManaged) && ( + + )} +
+ + + {/* Group body */} + {!collapsed && ( +
+ {hasAcls ? ( + + + + + + + + + + + + + {group.directAcls.map((acl, idx) => ( + onRemove(acl)} + /> + ))} + + {group.inheritedAcls.length > 0 && ( + + + + )} + + {group.inheritedAcls.map((acl, idx) => ( + + ))} + +
TypeResourceOperationPermissionHost + Actions +
+
+ + + Via {group.assignedRoles.length === 1 ? 'role' : 'roles'} + + {group.assignedRoles.slice(0, 3).map((role) => ( + e.stopPropagation()} + params={{ roleName: encodeURIComponent(role.name) }} + to="/security/roles/$roleName" + > + + {role.name} + + + ))} + {group.assignedRoles.length > 3 && ( + +{group.assignedRoles.length - 3} more + )} +
+
+ ) : ( + + + + + + No ACLs defined + No ACLs defined for this principal. + + + + )} +
+ )} +
+ ); +} + +// ─── Shared ACL Row ───────────────────────────────────────────────────────── + +function ACLRow({ + acl, + inherited, + onRemove, +}: { + acl: { + resourceType: string; + resourceName: string; + operation: string; + permission: string; + host: string; + resourcePatternType?: number; + }; + inherited?: boolean; + onRemove?: () => void; +}) { + return ( + + + + {acl.resourceType} + + + + + + {truncateText(acl.resourceName, RESOURCE_NAME_MAX)} + + {Boolean(getPatternTypeLabel(acl.resourcePatternType)) && ( + + {getPatternTypeLabel(acl.resourcePatternType)} + + )} + + + {acl.operation} + + + {acl.permission} + + + {acl.host} + + {inherited ? ( + + + +
+ +
+
+ +

Inherited from a role. Edit on the role page.

+
+
+
+ ) : ( + + )} + + + ); +} diff --git a/frontend/src/components/pages/security/role-detail-page.tsx b/frontend/src/components/pages/security/role-detail-page.tsx new file mode 100644 index 0000000000..49e530d550 --- /dev/null +++ b/frontend/src/components/pages/security/role-detail-page.tsx @@ -0,0 +1,515 @@ +/** + * 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 { create } from '@bufbuild/protobuf'; +import { Link } from '@tanstack/react-router'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from 'components/redpanda-ui/components/alert-dialog'; +import { Badge } from 'components/redpanda-ui/components/badge'; +import { Button } from 'components/redpanda-ui/components/button'; +import { Combobox } from 'components/redpanda-ui/components/combobox'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from 'components/redpanda-ui/components/dialog'; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from 'components/redpanda-ui/components/empty'; +import { Input } from 'components/redpanda-ui/components/input'; +import { Text } from 'components/redpanda-ui/components/typography'; +import { ArrowLeft, Plus, Search, Trash2, Users } from 'lucide-react'; +import { + ACL_Operation, + ACL_PermissionType, + ACL_ResourcePatternType, + ACL_ResourceType, + CreateACLRequestSchema, + DeleteACLsRequestSchema, +} from 'protogen/redpanda/api/dataplane/v1/acl_pb'; +import { + RoleMembershipSchema, + UpdateRoleMembershipRequestSchema, +} from 'protogen/redpanda/api/dataplane/v1/security_pb'; +import { useEffect, useMemo, useState } from 'react'; +import { + useDeleteAclMutation, + useGetAclsByPrincipal, + useLegacyCreateACLMutation, + useListACLsQuery, +} from 'react-query/api/acl'; +import { useGetRoleQuery, useListRolesQuery, useUpdateRoleMembershipMutation } from 'react-query/api/security'; +import { useLegacyListUsersQuery } from 'react-query/api/user'; +import { toast } from 'sonner'; +import { uiState } from 'state/ui-state'; + +import { ACLDialog, type ACLEntry, ACLRemoveDialog, ACLTableSection } from './acl-editor'; +import { + buildPrincipalAutocompleteOptions, + buildResourceOptionsByType, + flattenAclDetails, + sortByPrincipal, +} from './security-acl-utils'; + +const PRINCIPAL_SEARCH_THRESHOLD = 5; + +interface RoleDetailPageProps { + roleName: string; +} + +export function RoleDetailPage({ roleName }: RoleDetailPageProps) { + // ACL dialog state + const [aclDialogOpen, setAclDialogOpen] = useState(false); + const [aclRemoveIndex, setAclRemoveIndex] = useState(null); + + // Principal management state + const [addPrincipalDialogOpen, setAddPrincipalDialogOpen] = useState(false); + const [newPrincipal, setNewPrincipal] = useState(''); + const [principalError, setPrincipalError] = useState(null); + const [isAddingPrincipal, setIsAddingPrincipal] = useState(false); + const [removePrincipal, setRemovePrincipal] = useState(null); + const [isRemovingPrincipal, setIsRemovingPrincipal] = useState(false); + const [principalSearch, setPrincipalSearch] = useState(''); + + // Fetch role data + const { data: roleData } = useGetRoleQuery({ roleName }); + const members = useMemo(() => sortByPrincipal(roleData?.members ?? []), [roleData]); + const { data: usersData } = useLegacyListUsersQuery(); + const { data: rolesData } = useListRolesQuery(); + const { data: allAclsData } = useListACLsQuery(); + + // Fetch ACLs for this role + const { data: aclsData } = useGetAclsByPrincipal(`RedpandaRole:${roleName}`); + + // Mutations + const { mutateAsync: updateMembership } = useUpdateRoleMembershipMutation(); + const { mutateAsync: createACLMutation } = useLegacyCreateACLMutation(); + const { mutateAsync: deleteACLMutation } = useDeleteAclMutation(); + + // Transform ACLs to display format + const acls: ACLEntry[] = useMemo(() => flattenAclDetails(aclsData), [aclsData]); + const principalOptions = useMemo( + () => + buildPrincipalAutocompleteOptions({ + excludePrincipals: members.map((member) => member.principal), + principals: [ + ...members.map((member) => member.principal), + ...((allAclsData?.aclResources ?? []).flatMap((resource) => + resource.acls.map((acl) => acl.principal || '').filter(Boolean) + ) ?? []), + ], + roles: rolesData?.roles?.map((role) => role.name) ?? [], + users: usersData?.users?.map((user) => user.name) ?? [], + }), + [allAclsData, members, rolesData, usersData] + ); + const resourceOptionsByType = useMemo( + () => buildResourceOptionsByType(allAclsData?.aclResources ?? []), + [allAclsData] + ); + + // Filter members by search + const filteredMembers = useMemo(() => { + if (!principalSearch) { + return members; + } + const q = principalSearch.toLowerCase(); + return members.filter((m) => m.principal.toLowerCase().includes(q)); + }, [members, principalSearch]); + + useEffect(() => { + uiState.pageTitle = roleName; + uiState.pageBreadcrumbs = [ + { title: 'Security', linkTo: '/security' }, + { title: 'Roles', linkTo: '/security/roles' }, + { + title: roleName, + linkTo: `/security/roles/${encodeURIComponent(roleName)}`, + options: { canBeTruncated: true, canBeCopied: true }, + }, + ]; + }, [roleName]); + + // ─── Principal handlers ─────────────────────────────────────────────── + + const handleAddPrincipal = async () => { + const trimmed = newPrincipal.trim(); + if (!trimmed) { + setPrincipalError('Principal is required'); + return; + } + if (!trimmed.includes(':')) { + setPrincipalError('Principal must include a type prefix (e.g. User:name)'); + return; + } + if (members.some((m) => m.principal === trimmed)) { + setPrincipalError('This principal is already assigned to this role'); + return; + } + + setIsAddingPrincipal(true); + try { + await updateMembership( + create(UpdateRoleMembershipRequestSchema, { + roleName, + create: false, + add: [create(RoleMembershipSchema, { principal: trimmed })], + remove: [], + }) + ); + toast.success(`Added "${trimmed}" to role`); + setAddPrincipalDialogOpen(false); + setNewPrincipal(''); + setPrincipalError(null); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to add principal'; + toast.error(message); + } finally { + setIsAddingPrincipal(false); + } + }; + + const handleRemovePrincipal = async () => { + if (!removePrincipal) { + return; + } + setIsRemovingPrincipal(true); + try { + await updateMembership( + create(UpdateRoleMembershipRequestSchema, { + roleName, + create: false, + add: [], + remove: [create(RoleMembershipSchema, { principal: removePrincipal })], + }) + ); + toast.success(`Removed "${removePrincipal}" from role`); + setRemovePrincipal(null); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to remove principal'; + toast.error(message); + } finally { + setIsRemovingPrincipal(false); + } + }; + + // ─── ACL handlers ───────────────────────────────────────────────────── + + const resourceTypeMap: Record = { + Topic: ACL_ResourceType.TOPIC, + Group: ACL_ResourceType.GROUP, + Cluster: ACL_ResourceType.CLUSTER, + TransactionalId: ACL_ResourceType.TRANSACTIONAL_ID, + }; + const operationMap: Record = { + All: ACL_Operation.ALL, + Read: ACL_Operation.READ, + Write: ACL_Operation.WRITE, + Create: ACL_Operation.CREATE, + Delete: ACL_Operation.DELETE, + Alter: ACL_Operation.ALTER, + Describe: ACL_Operation.DESCRIBE, + ClusterAction: ACL_Operation.CLUSTER_ACTION, + DescribeConfigs: ACL_Operation.DESCRIBE_CONFIGS, + AlterConfigs: ACL_Operation.ALTER_CONFIGS, + IdempotentWrite: ACL_Operation.IDEMPOTENT_WRITE, + }; + const permissionMap: Record = { + Allow: ACL_PermissionType.ALLOW, + Deny: ACL_PermissionType.DENY, + }; + + const handleSaveAcl = async (entry: ACLEntry) => { + try { + await createACLMutation( + create(CreateACLRequestSchema, { + resourceType: resourceTypeMap[entry.resourceType] ?? ACL_ResourceType.TOPIC, + resourceName: entry.resourceName, + resourcePatternType: entry.resourcePatternType ?? ACL_ResourcePatternType.LITERAL, + principal: `RedpandaRole:${roleName}`, + host: entry.host, + operation: operationMap[entry.operation] ?? ACL_Operation.ALL, + permissionType: permissionMap[entry.permission] ?? ACL_PermissionType.ALLOW, + }) + ); + toast.success('ACL created'); + setAclDialogOpen(false); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to create ACL'; + toast.error(message); + } + }; + + const handleRemoveAcl = (idx: number) => { + setAclRemoveIndex(idx); + }; + + const confirmRemoveAcl = async () => { + if (aclRemoveIndex === null) { + return; + } + const acl = acls[aclRemoveIndex]; + if (!acl) { + return; + } + + try { + await deleteACLMutation( + create(DeleteACLsRequestSchema, { + filter: { + principal: `RedpandaRole:${roleName}`, + resourceType: resourceTypeMap[acl.resourceType] ?? ACL_ResourceType.TOPIC, + resourceName: acl.resourceName, + host: acl.host, + operation: operationMap[acl.operation] ?? ACL_Operation.ALL, + permissionType: permissionMap[acl.permission] ?? ACL_PermissionType.ALLOW, + resourcePatternType: acl.resourcePatternType ?? ACL_ResourcePatternType.LITERAL, + }, + }) + ); + toast.success('ACL removed'); + setAclRemoveIndex(null); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to remove ACL'; + toast.error(message); + } + }; + + if (!roleData) { + return ( +
+ Role not found. + +
+ ); + } + + return ( + <> +
+ {/* Page Header */} +
+ +

+ {roleName} +

+

Manage the ACLs and principals assigned to this role.

+
+ + {/* ACLs Section */} + setAclDialogOpen(true)} onRemove={handleRemoveAcl} /> + + {/* Principals Section */} +
+
+
+ + + Principals + + + {members.length} {members.length === 1 ? 'principal' : 'principals'} + +
+ {members.length > 0 && ( + + )} +
+ + {/* Principal search (shown when > threshold) */} + {members.length > PRINCIPAL_SEARCH_THRESHOLD && ( +
+ + setPrincipalSearch(e.target.value)} + placeholder="Search principals..." + type="text" + value={principalSearch} + /> +
+ )} + +
+ {filteredMembers.length > 0 ? ( +
+ {filteredMembers.map((member, idx) => ( +
+
+ + {member.principal.split(':')[0] || 'User'} + + + {member.principal.includes(':') + ? member.principal.split(':').slice(1).join(':') + : member.principal} + +
+ +
+ ))} +
+ ) : ( + + + + + + {principalSearch ? 'No principals found' : 'No principals assigned'} + + {principalSearch + ? 'Try adjusting your search query.' + : 'Add principals to grant them the permissions defined by this role.'} + + + {!principalSearch && ( + + )} + + )} +
+
+
+ + {/* ACL Create Dialog */} + setAclDialogOpen(false)} + onSave={handleSaveAcl} + open={aclDialogOpen} + resourceOptionsByType={resourceOptionsByType} + /> + + {/* ACL Remove Confirmation */} + setAclRemoveIndex(null)} + onConfirm={confirmRemoveAcl} + open={aclRemoveIndex !== null} + /> + + {/* Add Principal Dialog */} + !open && setAddPrincipalDialogOpen(false)} open={addPrincipalDialogOpen}> + + + Add Principal + Add a principal to this role to grant them its permissions. + +
+
+ { + setNewPrincipal(value); + setPrincipalError(null); + }} + options={principalOptions} + placeholder="e.g. User:alice" + value={newPrincipal} + /> +

+ Enter a principal in the format Type:name (e.g. + User:alice, Group:my-team). +

+ {Boolean(principalError) &&

{principalError}

} +
+
+ + + + +
+
+ + {/* Remove Principal Confirmation Dialog */} + { + if (!open) { + setRemovePrincipal(null); + } + }} + open={Boolean(removePrincipal)} + > + + + Remove principal "{removePrincipal}"? + + This principal will lose all permissions granted by this role. This action cannot be undone. + + + + + + + + + + + + + + ); +} diff --git a/frontend/src/components/pages/security/roles-tab.tsx b/frontend/src/components/pages/security/roles-tab.tsx new file mode 100644 index 0000000000..0f016e4197 --- /dev/null +++ b/frontend/src/components/pages/security/roles-tab.tsx @@ -0,0 +1,346 @@ +/** + * 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 { create } from '@bufbuild/protobuf'; +import { createQueryOptions, useTransport } from '@connectrpc/connect-query'; +import { useQueries } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from 'components/redpanda-ui/components/alert-dialog'; +import { Button } from 'components/redpanda-ui/components/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from 'components/redpanda-ui/components/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from 'components/redpanda-ui/components/dropdown-menu'; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from 'components/redpanda-ui/components/empty'; +import { Input } from 'components/redpanda-ui/components/input'; +import { Label } from 'components/redpanda-ui/components/label'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'components/redpanda-ui/components/table'; +import { Text } from 'components/redpanda-ui/components/typography'; +import { MoreHorizontal, Pencil, Plus, Search, Shield, Trash2, Users } from 'lucide-react'; +import { CreateRoleRequestSchema } from 'protogen/redpanda/api/dataplane/v1/security_pb'; +import { getRole } from 'protogen/redpanda/api/dataplane/v1/security-SecurityService_connectquery'; +import { useMemo, useState } from 'react'; +import { useCreateRoleMutation, useDeleteRoleMutation, useListRolesQuery } from 'react-query/api/security'; +import { toast } from 'sonner'; + +import { sortByName } from './security-acl-utils'; + +export function RolesTab() { + const navigate = useNavigate(); + const [searchQuery, setSearchQuery] = useState(''); + const [deleteConfirmRole, setDeleteConfirmRole] = useState<{ name: string; memberCount: number } | null>(null); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [newRoleName, setNewRoleName] = useState(''); + const [createError, setCreateError] = useState(null); + const [isCreating, setIsCreating] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const { data: rolesData } = useListRolesQuery(); + const { mutateAsync: createRole } = useCreateRoleMutation(); + const { mutateAsync: deleteRole } = useDeleteRoleMutation(); + + const roles = useMemo(() => sortByName(rolesData?.roles ?? []), [rolesData]); + const transport = useTransport(); + const roleDetailsQueries = useQueries({ + queries: roles.map((role) => + createQueryOptions( + getRole, + { + roleName: role.name, + }, + { transport } + ) + ), + }); + + const filteredRoles = useMemo(() => { + if (!searchQuery) { + return roles; + } + const q = searchQuery.toLowerCase(); + return roles.filter((role) => role.name.toLowerCase().includes(q)); + }, [roles, searchQuery]); + const memberCountByRole = useMemo( + () => new Map(roles.map((role, index) => [role.name, roleDetailsQueries[index]?.data?.members?.length ?? 0])), + [roleDetailsQueries, roles] + ); + + const handleCreateRole = async () => { + const name = newRoleName.trim(); + if (!name) { + setCreateError('Role name is required'); + return; + } + if (roles.some((r) => r.name === name)) { + setCreateError('A role with this name already exists'); + return; + } + + setIsCreating(true); + try { + await createRole(create(CreateRoleRequestSchema, { role: { name } })); + toast.success(`Role "${name}" created`); + setCreateDialogOpen(false); + setNewRoleName(''); + setCreateError(null); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to create role'; + toast.error(message); + } finally { + setIsCreating(false); + } + }; + + const handleDeleteRole = async () => { + if (!deleteConfirmRole) { + return; + } + setIsDeleting(true); + try { + await deleteRole({ deleteAcls: true, roleName: deleteConfirmRole.name }); + toast.success(`Role "${deleteConfirmRole.name}" deleted`); + setDeleteConfirmRole(null); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to delete role'; + toast.error(message); + } finally { + setIsDeleting(false); + } + }; + + const navigateToRole = (roleName: string) => { + navigate({ to: '/security/roles/$roleName', params: { roleName: encodeURIComponent(roleName) } }); + }; + + 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). + + + {/* Toolbar */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search by name..." + type="text" + value={searchQuery} + /> +
+ +
+ + {/* Roles Table */} +
+ {filteredRoles.length > 0 ? ( + + + + Role name + Assigned principals + + Actions + + + + + {filteredRoles.map((role) => ( + navigateToRole(role.name)}> + + {role.name} + + +
+ + {memberCountByRole.get(role.name) ?? 0} +
+
+ e.stopPropagation()}> + + + + + + navigateToRole(role.name)}> + + Edit role + + + + setDeleteConfirmRole({ + memberCount: memberCountByRole.get(role.name) ?? 0, + name: role.name, + }) + } + > + + Delete role + + + + +
+ ))} +
+
+ ) : ( + + + + + + {searchQuery ? 'No roles found' : 'No roles yet'} + + {searchQuery + ? 'Try adjusting your search query.' + : 'Create your first role to group ACLs and assign them to principals.'} + + + {!searchQuery && ( + + )} + + )} +
+ + {filteredRoles.length > 0 && ( +
+ {filteredRoles.length} {filteredRoles.length === 1 ? 'role' : 'roles'} +
+ )} + + {/* Create Role Dialog */} + !open && setCreateDialogOpen(false)} open={createDialogOpen}> + + + Create Role + Create a new role to group ACLs. + +
+
+
+ + Must not contain whitespace or special characters. +
+ { + setNewRoleName(e.target.value); + setCreateError(null); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleCreateRole(); + } + }} + placeholder="e.g. producer, consumer, admin" + type="text" + value={newRoleName} + /> + {Boolean(createError) &&

{createError}

} +
+
+ + + + +
+
+ + {/* Delete Confirmation Dialog */} + { + if (!open) { + setDeleteConfirmRole(null); + } + }} + open={Boolean(deleteConfirmRole)} + > + + + Delete role "{deleteConfirmRole?.name}"? + + This will permanently delete the role and remove all its ACL bindings. + {Boolean(deleteConfirmRole && deleteConfirmRole.memberCount > 0) && + ` ${deleteConfirmRole?.memberCount} assigned ${ + deleteConfirmRole?.memberCount === 1 ? 'principal' : 'principals' + } will lose the permissions granted by this role.`}{' '} + This action cannot be undone. + + + + + + + + + + + + +
+ ); +} diff --git a/frontend/src/components/pages/security/security-acl-utils.test.ts b/frontend/src/components/pages/security/security-acl-utils.test.ts new file mode 100644 index 0000000000..26e8f1c2d3 --- /dev/null +++ b/frontend/src/components/pages/security/security-acl-utils.test.ts @@ -0,0 +1,218 @@ +import type { AclDetail } from 'components/pages/acls/new-acl/acl.model'; +import { ACL_ResourcePatternType, ACL_ResourceType } from 'protogen/redpanda/api/dataplane/v1/acl_pb'; +import { describe, expect, it } from 'vitest'; + +import { + buildPrincipalAutocompleteOptions, + buildResourceOptionsByType, + compareDisplayText, + flattenAclDetails, + sortAclEntries, + sortByName, + sortByPrincipal, +} from './security-acl-utils'; + +describe('security-acl-utils sorting', () => { + it('sorts display text case-insensitively and numerically', () => { + const values = ['role-10', 'Role-2', 'role-1']; + + expect([...values].sort(compareDisplayText)).toEqual(['role-1', 'Role-2', 'role-10']); + }); + + it('uses a raw-string fallback when visible labels compare equally', () => { + const values = ['alice', 'ALICE', 'Alice']; + + expect([...values].sort(compareDisplayText)).toEqual(['ALICE', 'Alice', 'alice']); + }); + + it('sorts name-based collections alphabetically', () => { + const roles = [{ name: 'role-10' }, { name: 'Role-2' }, { name: 'role-1' }]; + + expect(sortByName(roles).map((role) => role.name)).toEqual(['role-1', 'Role-2', 'role-10']); + }); + + it('sorts principal-based collections alphabetically', () => { + const principals = [{ principal: 'User:zeta' }, { principal: 'OIDC:alpha' }, { principal: 'User:beta' }]; + + expect(sortByPrincipal(principals).map((principal) => principal.principal)).toEqual([ + 'OIDC:alpha', + 'User:beta', + 'User:zeta', + ]); + }); + + it('preserves resourcePatternType for literal, prefix, and any selector types', () => { + const details: AclDetail[] = [ + { + sharedConfig: { principal: 'User:test', host: '*' }, + rules: [ + { + id: 1, + resourceType: 'topic', + mode: 'custom', + selectorType: 'literal', + selectorValue: 'my-topic', + operations: { READ: 'allow' }, + }, + { + id: 2, + resourceType: 'topic', + mode: 'custom', + selectorType: 'prefix', + selectorValue: 'events.', + operations: { WRITE: 'allow' }, + }, + { + id: 3, + resourceType: 'consumerGroup', + mode: 'custom', + selectorType: 'any', + selectorValue: '*', + operations: { READ: 'allow' }, + }, + ], + }, + ]; + + const entries = flattenAclDetails(details); + + expect(entries).toHaveLength(3); + + const literal = entries.find((e) => e.resourceName === 'my-topic'); + expect(literal?.resourcePatternType).toBe(ACL_ResourcePatternType.LITERAL); + + const prefixed = entries.find((e) => e.resourceName === 'events.'); + expect(prefixed?.resourcePatternType).toBe(ACL_ResourcePatternType.PREFIXED); + + const any = entries.find((e) => e.resourceType === 'Group'); + expect(any?.resourcePatternType).toBe(ACL_ResourcePatternType.LITERAL); + }); + + it('sorts ACL rows by the displayed tuple, including wildcard and inherited metadata', () => { + const entries = [ + { + host: '*', + operation: 'Read', + permission: 'Allow', + principal: 'User:zeta', + resourceName: 'topic-10', + resourceType: 'Topic', + }, + { + host: '*', + operation: 'All', + permission: 'Allow', + resourceName: 'kafka-cluster', + resourceType: 'Cluster', + }, + { + host: '*', + operation: 'Read', + permission: 'Allow', + roleName: 'role-10', + resourceName: '*', + resourceType: 'Group', + }, + { + host: '*', + operation: 'Read', + permission: 'Allow', + roleName: 'role-2', + resourceName: '*', + resourceType: 'Group', + }, + { + host: '*', + operation: 'Read', + permission: 'Allow', + principal: 'User:alpha', + resourceName: 'topic-2', + resourceType: 'Topic', + }, + ]; + + expect( + sortAclEntries(entries).map((entry) => ({ + principal: entry.principal, + resourceName: entry.resourceName, + resourceType: entry.resourceType, + roleName: entry.roleName, + })) + ).toEqual([ + { + principal: undefined, + resourceName: 'kafka-cluster', + resourceType: 'Cluster', + roleName: undefined, + }, + { + principal: undefined, + resourceName: '*', + resourceType: 'Group', + roleName: 'role-2', + }, + { + principal: undefined, + resourceName: '*', + resourceType: 'Group', + roleName: 'role-10', + }, + { + principal: 'User:alpha', + resourceName: 'topic-2', + resourceType: 'Topic', + roleName: undefined, + }, + { + principal: 'User:zeta', + resourceName: 'topic-10', + resourceType: 'Topic', + roleName: undefined, + }, + ]); + }); + + it('builds principal autocomplete options from users, roles, live principals, and the User: helper', () => { + expect( + buildPrincipalAutocompleteOptions({ + principals: ['Group:team-a', 'User:zoe', 'Group:team-a'], + roles: ['role-10', 'role-2'], + users: ['bob', 'alice'], + }).map((option) => option.value) + ).toEqual([ + 'Group:team-a', + 'RedpandaRole:role-2', + 'RedpandaRole:role-10', + 'User:', + 'User:alice', + 'User:bob', + 'User:zoe', + ]); + }); + + it('excludes already assigned principals from principal autocomplete options', () => { + expect( + buildPrincipalAutocompleteOptions({ + excludePrincipals: ['User:alice', 'Group:team-a'], + principals: ['User:alice', 'Group:team-a', 'Group:team-b'], + users: ['alice', 'bob'], + }).map((option) => option.value) + ).toEqual(['Group:team-b', 'User:', 'User:bob']); + }); + + it('groups resource autocomplete options by displayed resource type', () => { + const optionsByType = buildResourceOptionsByType([ + { resourceName: 'topic-2', resourceType: ACL_ResourceType.TOPIC }, + { resourceName: 'topic-10', resourceType: ACL_ResourceType.TOPIC }, + { resourceName: 'group-a', resourceType: ACL_ResourceType.GROUP }, + { resourceName: 'txn-1', resourceType: ACL_ResourceType.TRANSACTIONAL_ID }, + { resourceName: 'kafka-cluster', resourceType: ACL_ResourceType.CLUSTER }, + { resourceName: 'topic-2', resourceType: ACL_ResourceType.TOPIC }, + ]); + + expect(optionsByType.Topic?.map((option) => option.value)).toEqual(['topic-2', 'topic-10']); + expect(optionsByType.Group?.map((option) => option.value)).toEqual(['group-a']); + expect(optionsByType.TransactionalId?.map((option) => option.value)).toEqual(['txn-1']); + expect(optionsByType.Cluster?.map((option) => option.value)).toEqual(['kafka-cluster']); + }); +}); diff --git a/frontend/src/components/pages/security/security-acl-utils.ts b/frontend/src/components/pages/security/security-acl-utils.ts new file mode 100644 index 0000000000..b274dc54e9 --- /dev/null +++ b/frontend/src/components/pages/security/security-acl-utils.ts @@ -0,0 +1,224 @@ +import { + type AclDetail, + getGRPCResourcePatternType, + getResourceNameValue, +} from 'components/pages/acls/new-acl/acl.model'; +import type { ComboboxOption } from 'components/redpanda-ui/components/combobox'; +import { ACL_ResourcePatternType, ACL_ResourceType } from 'protogen/redpanda/api/dataplane/v1/acl_pb'; + +import type { ACLEntry } from './acl-editor'; + +const displayTextCollator = new Intl.Collator(undefined, { + numeric: true, + sensitivity: 'base', + usage: 'sort', +}); + +const resourceTypeLabels: Record = { + cluster: 'Cluster', + consumerGroup: 'Group', + schemaRegistry: 'SchemaRegistry', + subject: 'Subject', + topic: 'Topic', + transactionalId: 'TransactionalId', +}; + +const operationLabels: Record = { + ALTER: 'Alter', + ALTER_CONFIGS: 'AlterConfigs', + ALL: 'All', + CLUSTER_ACTION: 'ClusterAction', + CREATE: 'Create', + DELETE: 'Delete', + DESCRIBE: 'Describe', + DESCRIBE_CONFIGS: 'DescribeConfigs', + IDEMPOTENT_WRITE: 'IdempotentWrite', + READ: 'Read', + WRITE: 'Write', +}; + +type NamedItem = { name: string }; +type PrincipalItem = { principal: string }; +type SortableAclEntry = Pick & { + host?: string; + principal?: string; + roleName?: string; +}; +type ResourceOptionSource = { + resourceName?: string; + resourceType?: ACL_ResourceType | string; +}; + +export type ResourceOptionsByType = Partial< + Record<'Cluster' | 'Group' | 'Topic' | 'TransactionalId', ComboboxOption[]> +>; + +export function compareDisplayText(a: string, b: string): number { + const displayComparison = displayTextCollator.compare(a, b); + if (displayComparison !== 0) { + return displayComparison; + } + if (a === b) { + return 0; + } + return a < b ? -1 : 1; +} + +export function sortByName(items: readonly T[]): T[] { + return [...items].sort((a, b) => compareDisplayText(a.name, b.name)); +} + +export function sortByPrincipal(items: readonly T[]): T[] { + return [...items].sort((a, b) => compareDisplayText(a.principal, b.principal)); +} + +export function sortAclEntries(entries: readonly T[]): T[] { + return [...entries].sort((a, b) => { + const comparisons = [ + compareDisplayText(a.resourceType, b.resourceType), + compareDisplayText(a.resourceName, b.resourceName), + compareDisplayText(a.operation, b.operation), + compareDisplayText(a.permission, b.permission), + compareDisplayText(a.host ?? '', b.host ?? ''), + compareDisplayText(a.roleName ?? '', b.roleName ?? ''), + compareDisplayText(a.principal ?? '', b.principal ?? ''), + ]; + + return comparisons.find((result) => result !== 0) ?? 0; + }); +} + +function toComboboxOptions(values: Iterable): ComboboxOption[] { + return [...values].sort(compareDisplayText).map((value) => ({ + label: value, + value, + })); +} + +export function getAclResourceTypeLabel(resourceType: ACL_ResourceType | string | undefined): string | undefined { + if (resourceType === undefined) { + return; + } + + switch (resourceType) { + case ACL_ResourceType.TOPIC: + case 'topic': + return 'Topic'; + case ACL_ResourceType.GROUP: + case 'consumerGroup': + return 'Group'; + case ACL_ResourceType.CLUSTER: + case 'cluster': + return 'Cluster'; + case ACL_ResourceType.TRANSACTIONAL_ID: + case 'transactionalId': + return 'TransactionalId'; + default: + return; + } +} + +export function buildPrincipalAutocompleteOptions({ + excludePrincipals = [], + includeUserPrefix = true, + principals = [], + roles = [], + users = [], +}: { + excludePrincipals?: readonly string[]; + includeUserPrefix?: boolean; + principals?: readonly string[]; + roles?: readonly string[]; + users?: readonly string[]; +}): ComboboxOption[] { + const values = new Set(); + const excluded = new Set(excludePrincipals); + + if (includeUserPrefix) { + values.add('User:'); + } + + for (const user of users) { + if (user) { + values.add(`User:${user}`); + } + } + + for (const role of roles) { + if (role) { + values.add(`RedpandaRole:${role}`); + } + } + + for (const principal of principals) { + if (principal) { + values.add(principal); + } + } + + for (const principal of excluded) { + values.delete(principal); + } + + return toComboboxOptions(values); +} + +export function buildResourceOptionsByType(resources: readonly ResourceOptionSource[]): ResourceOptionsByType { + const valuesByType = new Map>(); + + for (const resource of resources) { + const label = getAclResourceTypeLabel(resource.resourceType); + if (!(label && resource.resourceName)) { + continue; + } + + const existing = valuesByType.get(label as keyof ResourceOptionsByType) ?? new Set(); + existing.add(resource.resourceName); + valuesByType.set(label as keyof ResourceOptionsByType, existing); + } + + return { + Cluster: toComboboxOptions(valuesByType.get('Cluster') ?? []), + Group: toComboboxOptions(valuesByType.get('Group') ?? []), + Topic: toComboboxOptions(valuesByType.get('Topic') ?? []), + TransactionalId: toComboboxOptions(valuesByType.get('TransactionalId') ?? []), + }; +} + +export function flattenAclDetails(details?: AclDetail[]): ACLEntry[] { + if (!(details && Array.isArray(details))) { + return []; + } + + const entries: ACLEntry[] = []; + + for (const detail of details) { + const host = detail.sharedConfig?.host || '*'; + + for (const rule of detail.rules ?? []) { + for (const [operation, permission] of Object.entries(rule.operations ?? {})) { + if (permission === 'not-set') { + continue; + } + + entries.push({ + resourceType: + getAclResourceTypeLabel(rule.resourceType) ?? + resourceTypeLabels[rule.resourceType] ?? + rule.resourceType ?? + 'Unknown', + resourceName: getResourceNameValue(rule), + operation: operationLabels[operation] ?? operation, + permission: permission === 'allow' ? 'Allow' : 'Deny', + host, + resourcePatternType: + rule.selectorType === 'any' + ? ACL_ResourcePatternType.LITERAL + : getGRPCResourcePatternType(rule.selectorType), + }); + } + } + } + + return sortAclEntries(entries); +} diff --git a/frontend/src/components/pages/security/security-page.tsx b/frontend/src/components/pages/security/security-page.tsx new file mode 100644 index 0000000000..d0b928e497 --- /dev/null +++ b/frontend/src/components/pages/security/security-page.tsx @@ -0,0 +1,80 @@ +/** + * 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 { useNavigate } from '@tanstack/react-router'; +import { Tabs, TabsContent, TabsContents, TabsList, TabsTrigger } from 'components/redpanda-ui/components/tabs'; +import { useEffect } from 'react'; +import { Features } from 'state/supported-features'; +import { uiState } from 'state/ui-state'; + +import { PermissionsTab } from './permissions-tab'; +import { RolesTab } from './roles-tab'; +import { UsersTab } from './users-tab'; + +export type SecurityTab = 'users' | 'roles' | 'permissions'; + +const tabs: { id: SecurityTab; label: string; requiresFeature?: () => boolean }[] = [ + { id: 'users', label: 'Users' }, + { id: 'roles', label: 'Roles', requiresFeature: () => Boolean(Features.rolesApi) }, + { id: 'permissions', label: 'Permissions' }, +]; + +interface SecurityPageProps { + tab: SecurityTab; +} + +export function SecurityPage({ tab }: SecurityPageProps) { + const navigate = useNavigate(); + + // Validate tab — fall back to 'users' if invalid + const validTabs: SecurityTab[] = ['users', 'roles', 'permissions']; + const activeTab = validTabs.includes(tab) ? tab : 'users'; + + const activeTabLabel = tabs.find((t) => t.id === activeTab)?.label ?? 'Users'; + + useEffect(() => { + uiState.pageTitle = 'Security'; + uiState.pageBreadcrumbs = [ + { title: 'Security', linkTo: `/security/${activeTab}` }, + { title: activeTabLabel, linkTo: `/security/${activeTab}` }, + ]; + }, [activeTab, activeTabLabel]); + + const setActiveTab = (newTab: SecurityTab) => { + navigate({ to: '/security/$tab', params: { tab: newTab }, replace: true }); + }; + + const visibleTabs = tabs.filter((t) => !t.requiresFeature || t.requiresFeature()); + + return ( + setActiveTab(v as SecurityTab)} value={activeTab}> + + {visibleTabs.map((t) => ( + + {t.label} + + ))} + + + + + setActiveTab(tab as SecurityTab)} /> + + + + + + + + + + ); +} diff --git a/frontend/src/components/pages/security/user-detail-page.tsx b/frontend/src/components/pages/security/user-detail-page.tsx new file mode 100644 index 0000000000..7cd0257fcc --- /dev/null +++ b/frontend/src/components/pages/security/user-detail-page.tsx @@ -0,0 +1,548 @@ +/** + * 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 { create } from '@bufbuild/protobuf'; +import { Link, useNavigate } from '@tanstack/react-router'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from 'components/redpanda-ui/components/alert-dialog'; +import { Badge } from 'components/redpanda-ui/components/badge'; +import { Button } from 'components/redpanda-ui/components/button'; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from 'components/redpanda-ui/components/empty'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from 'components/redpanda-ui/components/select'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'components/redpanda-ui/components/tooltip'; +import { Text } from 'components/redpanda-ui/components/typography'; +import { ArrowLeft, Check, ChevronRight, Copy, Key, Shield, Trash2 } from 'lucide-react'; +import { + ACL_Operation, + ACL_PermissionType, + ACL_ResourcePatternType, + ACL_ResourceType, + CreateACLRequestSchema, + DeleteACLsRequestSchema, +} from 'protogen/redpanda/api/dataplane/v1/acl_pb'; +import { + RoleMembershipSchema, + UpdateRoleMembershipRequestSchema, +} from 'protogen/redpanda/api/dataplane/v1/security_pb'; +import { SASLMechanism } from 'protogen/redpanda/api/dataplane/v1/user_pb'; +import { useEffect, useMemo, useState } from 'react'; +import { + useDeleteAclMutation, + useGetAclsByPrincipal, + useLegacyCreateACLMutation, + useListACLsQuery, +} from 'react-query/api/acl'; +import { useListRolesQuery, useUpdateRoleMembershipMutation } from 'react-query/api/security'; +import { useDeleteUserMutation, useLegacyListUsersQuery } from 'react-query/api/user'; +import { toast } from 'sonner'; +import { Features } from 'state/supported-features'; +import { uiState } from 'state/ui-state'; + +import { ACLDialog, type ACLEntry, ACLRemoveDialog, ACLTableSection } from './acl-editor'; +import { ChangePasswordDialog } from './change-password-dialog'; +import { buildResourceOptionsByType, compareDisplayText, flattenAclDetails, sortByName } from './security-acl-utils'; + +function getMechanismLabel(mechanism?: SASLMechanism): string { + switch (mechanism) { + case SASLMechanism.SASL_MECHANISM_SCRAM_SHA_256: + return 'SCRAM-SHA-256'; + case SASLMechanism.SASL_MECHANISM_SCRAM_SHA_512: + return 'SCRAM-SHA-512'; + default: + return 'SCRAM'; + } +} + +function PrincipalCopyField({ value }: { value: string }) { + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + ); +} + +interface UserDetailPageProps { + userName: string; +} + +export function UserDetailPage({ userName }: UserDetailPageProps) { + const navigate = useNavigate(); + const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + // ACL dialog state + const [aclDialogOpen, setAclDialogOpen] = useState(false); + const [aclRemoveIndex, setAclRemoveIndex] = useState(null); + + // Fetch user data + const { data: usersData } = useLegacyListUsersQuery(); + const user = useMemo(() => usersData?.users?.find((u) => u.name === userName), [usersData, userName]); + + // Fetch roles + const { data: rolesData } = useListRolesQuery(undefined, { enabled: Boolean(Features.rolesApi) }); + const allRoles = useMemo(() => sortByName(rolesData?.roles ?? []), [rolesData]); + const { data: assignedRolesData } = useListRolesQuery( + { + filter: { + principal: `User:${userName}`, + }, + }, + { enabled: Boolean(Features.rolesApi) } + ); + + // Fetch ACLs for this user + const { data: aclsData } = useGetAclsByPrincipal(`User:${userName}`); + const { data: allAclsData } = useListACLsQuery(); + + // Mutations + const { mutateAsync: updateMembership } = useUpdateRoleMembershipMutation(); + const { mutateAsync: createACLMutation } = useLegacyCreateACLMutation(); + const { mutateAsync: deleteACLMutation } = useDeleteAclMutation(); + const { mutateAsync: deleteUserMutation, isPending: isDeletingUser } = useDeleteUserMutation(); + + const acls: ACLEntry[] = useMemo(() => flattenAclDetails(aclsData), [aclsData]); + const resourceOptionsByType = useMemo( + () => buildResourceOptionsByType(allAclsData?.aclResources ?? []), + [allAclsData] + ); + const userRoles = useMemo( + () => [...(assignedRolesData?.roles?.map((role) => role.name) ?? [])].sort(compareDisplayText), + [assignedRolesData] + ); + + const mechanismLabel = getMechanismLabel(user?.mechanism); + + useEffect(() => { + uiState.pageTitle = userName; + uiState.pageBreadcrumbs = [ + { title: 'Security', linkTo: '/security' }, + { title: 'Users', linkTo: '/security/users' }, + { + title: userName, + linkTo: `/security/users/${encodeURIComponent(userName)}`, + options: { canBeTruncated: true, canBeCopied: true }, + }, + ]; + }, [userName]); + + const availableToAssign = useMemo(() => allRoles.filter((r) => !userRoles.includes(r.name)), [allRoles, userRoles]); + + const handleAssignRole = async (roleName: string) => { + try { + await updateMembership( + create(UpdateRoleMembershipRequestSchema, { + roleName, + create: false, + add: [create(RoleMembershipSchema, { principal: `User:${userName}` })], + remove: [], + }) + ); + toast.success(`Assigned role "${roleName}"`); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to assign role'; + toast.error(message); + } + }; + + const handleRemoveRole = async (roleName: string) => { + try { + await updateMembership( + create(UpdateRoleMembershipRequestSchema, { + roleName, + create: false, + add: [], + remove: [create(RoleMembershipSchema, { principal: `User:${userName}` })], + }) + ); + toast.success(`Removed role "${roleName}"`); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to remove role'; + toast.error(message); + } + }; + + const handleSaveAcl = async (entry: ACLEntry) => { + const resourceTypeMap: Record = { + Topic: ACL_ResourceType.TOPIC, + Group: ACL_ResourceType.GROUP, + Cluster: ACL_ResourceType.CLUSTER, + TransactionalId: ACL_ResourceType.TRANSACTIONAL_ID, + }; + const operationMap: Record = { + All: ACL_Operation.ALL, + Read: ACL_Operation.READ, + Write: ACL_Operation.WRITE, + Create: ACL_Operation.CREATE, + Delete: ACL_Operation.DELETE, + Alter: ACL_Operation.ALTER, + Describe: ACL_Operation.DESCRIBE, + ClusterAction: ACL_Operation.CLUSTER_ACTION, + DescribeConfigs: ACL_Operation.DESCRIBE_CONFIGS, + AlterConfigs: ACL_Operation.ALTER_CONFIGS, + IdempotentWrite: ACL_Operation.IDEMPOTENT_WRITE, + }; + const permissionMap: Record = { + Allow: ACL_PermissionType.ALLOW, + Deny: ACL_PermissionType.DENY, + }; + + try { + await createACLMutation( + create(CreateACLRequestSchema, { + resourceType: resourceTypeMap[entry.resourceType] ?? ACL_ResourceType.TOPIC, + resourceName: entry.resourceName, + resourcePatternType: entry.resourcePatternType ?? ACL_ResourcePatternType.LITERAL, + principal: `User:${userName}`, + host: entry.host, + operation: operationMap[entry.operation] ?? ACL_Operation.ALL, + permissionType: permissionMap[entry.permission] ?? ACL_PermissionType.ALLOW, + }) + ); + toast.success('ACL created'); + setAclDialogOpen(false); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to create ACL'; + toast.error(message); + } + }; + + const handleRemoveAcl = async (idx: number) => { + const acl = acls[idx]; + if (!acl) { + return; + } + // The actual deletion would use deleteACL mutation + // For now, trigger the confirm dialog + setAclRemoveIndex(idx); + }; + + const confirmRemoveAcl = async () => { + if (aclRemoveIndex === null) { + return; + } + const acl = acls[aclRemoveIndex]; + if (!acl) { + return; + } + + const resourceTypeMap: Record = { + Topic: ACL_ResourceType.TOPIC, + Group: ACL_ResourceType.GROUP, + Cluster: ACL_ResourceType.CLUSTER, + TransactionalId: ACL_ResourceType.TRANSACTIONAL_ID, + }; + const operationMap: Record = { + All: ACL_Operation.ALL, + Read: ACL_Operation.READ, + Write: ACL_Operation.WRITE, + Create: ACL_Operation.CREATE, + Delete: ACL_Operation.DELETE, + Alter: ACL_Operation.ALTER, + Describe: ACL_Operation.DESCRIBE, + ClusterAction: ACL_Operation.CLUSTER_ACTION, + DescribeConfigs: ACL_Operation.DESCRIBE_CONFIGS, + AlterConfigs: ACL_Operation.ALTER_CONFIGS, + IdempotentWrite: ACL_Operation.IDEMPOTENT_WRITE, + }; + const permissionMap: Record = { + Allow: ACL_PermissionType.ALLOW, + Deny: ACL_PermissionType.DENY, + }; + + try { + await deleteACLMutation( + create(DeleteACLsRequestSchema, { + filter: { + principal: `User:${userName}`, + resourceType: resourceTypeMap[acl.resourceType] ?? ACL_ResourceType.TOPIC, + resourceName: acl.resourceName, + host: acl.host, + operation: operationMap[acl.operation] ?? ACL_Operation.ALL, + permissionType: permissionMap[acl.permission] ?? ACL_PermissionType.ALLOW, + resourcePatternType: acl.resourcePatternType ?? ACL_ResourcePatternType.LITERAL, + }, + }) + ); + toast.success('ACL removed'); + setAclRemoveIndex(null); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to remove ACL'; + toast.error(message); + } + }; + + const handleDeleteUser = async () => { + try { + await deleteUserMutation({ name: userName }); + toast.success(`User "${userName}" deleted`); + navigate({ to: '/security/$tab', params: { tab: 'users' } }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to delete user'; + toast.error(message); + } + }; + + if (!user) { + return ( +
+ User not found. + +
+ ); + } + + return ( + <> +
+ {/* Page Header */} +
+ +
+

+ {userName} +

+
+ + {Boolean(Features.deleteUser) && ( + + )} +
+
+
+
+ Principal + +
+
+ Mechanism + + {mechanismLabel} + +
+
+
+ + {/* Roles Section */} + {Boolean(Features.rolesApi) && ( +
+
+
+ + + Roles + + + {userRoles.length} assigned + +
+ {userRoles.length > 0 && + (availableToAssign.length > 0 ? ( + + ) : ( + + + +
+ +
+
+ + {allRoles.length === 0 + ? 'No roles available. Create a role first.' + : 'All roles are already assigned to this user.'} + +
+
+ ))} +
+ + {userRoles.length === 0 ? ( +
+ + + + + + No roles assigned + Assign roles to grant this user predefined sets of permissions. + + {availableToAssign.length > 0 && ( + + )} + +
+ ) : ( +
+ {userRoles.map((role, idx) => ( +
+
+ + + {role} + +
+
+ + + + +
+
+ ))} +
+ )} +
+ )} + + {/* ACLs Section */} + setAclDialogOpen(true)} onRemove={handleRemoveAcl} /> +
+ + {/* ACL Create Dialog */} + setAclDialogOpen(false)} + onSave={handleSaveAcl} + open={aclDialogOpen} + resourceOptionsByType={resourceOptionsByType} + /> + + {/* ACL Remove Confirmation */} + setAclRemoveIndex(null)} + onConfirm={confirmRemoveAcl} + open={aclRemoveIndex !== null} + /> + + {/* Change Password Dialog */} + setPasswordDialogOpen(false)} + open={passwordDialogOpen} + userName={userName} + /> + + {/* Delete User Confirmation */} + + + + Delete user "{userName}"? + + This will permanently delete the user and revoke their credentials. This action cannot be undone. + + + + + + + + + + + + + + ); +} diff --git a/frontend/src/components/pages/security/users-tab.tsx b/frontend/src/components/pages/security/users-tab.tsx new file mode 100644 index 0000000000..6202127715 --- /dev/null +++ b/frontend/src/components/pages/security/users-tab.tsx @@ -0,0 +1,444 @@ +/** + * 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 { useNavigate } from '@tanstack/react-router'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from 'components/redpanda-ui/components/alert-dialog'; +import { Badge } from 'components/redpanda-ui/components/badge'; +import { Button } from 'components/redpanda-ui/components/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from 'components/redpanda-ui/components/dropdown-menu'; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from 'components/redpanda-ui/components/empty'; +import { HoverCard, HoverCardContent, HoverCardTrigger } from 'components/redpanda-ui/components/hover-card'; +import { Input } from 'components/redpanda-ui/components/input'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'components/redpanda-ui/components/table'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'components/redpanda-ui/components/tooltip'; +import { Text } from 'components/redpanda-ui/components/typography'; +import { Key, MoreHorizontal, Plus, Search, Trash2, UserCog, Users } from 'lucide-react'; +import { SASLMechanism } from 'protogen/redpanda/api/dataplane/v1/user_pb'; +import { useMemo, useState } from 'react'; +import { useListACLsQuery } from 'react-query/api/acl'; +import { useDeleteUserMutation, useLegacyListUsersQuery } from 'react-query/api/user'; +import { toast } from 'sonner'; +import { Features } from 'state/supported-features'; + +import { ChangePasswordDialog } from './change-password-dialog'; +import { CreateUserDialog } from './create-user-dialog'; +import { sortAclEntries, sortByName } from './security-acl-utils'; + +const ACL_HOVER_LIMIT = 8; + +function getMechanismLabel(mechanism?: SASLMechanism): string | null { + switch (mechanism) { + case SASLMechanism.SASL_MECHANISM_SCRAM_SHA_256: + return 'SCRAM-SHA-256'; + case SASLMechanism.SASL_MECHANISM_SCRAM_SHA_512: + return 'SCRAM-SHA-512'; + default: + return null; + } +} + +interface UserAcl { + resourceType: string; + resourceName: string; + operation: string; + permission: string; +} + +interface UsersTabProps { + onNavigateToTab: (tab: string) => void; +} + +export function UsersTab({ onNavigateToTab }: UsersTabProps) { + const navigate = useNavigate(); + const [searchQuery, setSearchQuery] = useState(''); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [passwordDialogUser, setPasswordDialogUser] = useState<{ + name: string; + mechanism: string | null; + } | null>(null); + const [deleteUserName, setDeleteUserName] = useState(null); + const { mutateAsync: deleteUserMutation, isPending: isDeletingUser } = useDeleteUserMutation(); + + // Fetch data + const { data: usersData } = useLegacyListUsersQuery(); + const { data: aclsData } = useListACLsQuery(); + + const users = useMemo(() => sortByName(usersData?.users ?? []), [usersData]); + + // Build a map of user -> ACLs from the ACL list + const userAclsMap = useMemo(() => { + const map = new Map(); + const resources = aclsData?.aclResources ?? []; + for (const resource of resources) { + for (const acl of resource.acls) { + const principal = acl.principal || ''; + // ACL principals are in format "User:name" + if (principal.startsWith('User:')) { + const userName = principal.substring(5); + if (!map.has(userName)) { + map.set(userName, []); + } + map.get(userName)?.push({ + resourceType: getResourceTypeLabel(resource.resourceType), + resourceName: resource.resourceName, + operation: getOperationLabel(acl.operation), + permission: getPermissionLabel(acl.permissionType), + }); + } + } + } + for (const [userName, acls] of map.entries()) { + map.set(userName, sortAclEntries(acls)); + } + return map; + }, [aclsData]); + + // Filter users + const filteredUsers = useMemo(() => { + if (!searchQuery) { + return users; + } + const q = searchQuery.toLowerCase(); + return users.filter((user) => user.name.toLowerCase().includes(q)); + }, [users, searchQuery]); + + const handleDeleteUser = async () => { + if (!deleteUserName) { + return; + } + try { + await deleteUserMutation({ name: deleteUserName }); + toast.success(`User "${deleteUserName}" deleted`); + setDeleteUserName(null); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to delete user'; + toast.error(message); + } + }; + + const navigateToUser = (userName: string) => { + navigate({ to: '/security/users/$userName', params: { userName: encodeURIComponent(userName) } }); + }; + + return ( +
+ + These users are SASL-SCRAM users managed by your cluster. View the full permissions picture for all identities + (including OIDC and mTLS) on the Permissions tab. + + + {/* Toolbar */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search by name..." + type="text" + value={searchQuery} + /> +
+ {Boolean(Features.createUser) && ( + + )} +
+ + {/* Users Table */} +
+ {filteredUsers.length > 0 ? ( + + + + User + Mechanism + ACLs + + Actions + + + + + {filteredUsers.map((user) => { + const mechanismLabel = getMechanismLabel(user.mechanism); + const userAcls = userAclsMap.get(user.name) ?? []; + + return ( + navigateToUser(user.name)}> + + + + + {user.name} + + +

{user.name}

+
+
+
+
+ + {mechanismLabel ? ( + + {mechanismLabel} + + ) : ( + Unknown + )} + + e.stopPropagation()}> + onNavigateToTab('permissions')} + principal={user.name} + /> + + e.stopPropagation()}> + + + + + + setPasswordDialogUser({ name: user.name, mechanism: mechanismLabel })} + > + + Change password + + navigateToUser(user.name)}> + + View details + + {Boolean(Features.deleteUser) && ( + <> + + setDeleteUserName(user.name)} + > + + Delete user + + + )} + + + +
+ ); + })} +
+
+ ) : ( + + + + + + No users found + + {searchQuery + ? `No users matching "${searchQuery}". Try adjusting your search.` + : 'Get started by creating your first SASL-SCRAM user.'} + + + {!searchQuery && Boolean(Features.createUser) && ( + + )} + + )} +
+ + {filteredUsers.length > 0 && ( +
+ {filteredUsers.length} {filteredUsers.length === 1 ? 'user' : 'users'} +
+ )} + + {/* Create User Dialog */} + setCreateDialogOpen(false)} + onNavigateToTab={onNavigateToTab} + open={createDialogOpen} + /> + + {/* Change Password Dialog */} + {passwordDialogUser !== null && ( + setPasswordDialogUser(null)} + open + userName={passwordDialogUser.name} + /> + )} + + {/* Delete User Confirmation */} + { + if (!open) { + setDeleteUserName(null); + } + }} + open={Boolean(deleteUserName)} + > + + + Delete user "{deleteUserName}"? + + This will permanently delete the user and revoke their credentials. This action cannot be undone. + + + + + + + + + + + + +
+ ); +} + +// ─── Helper components ──────────────────────────────────────────────────── + +function ACLSummary({ acls, principal, onViewAll }: { acls: UserAcl[]; principal: string; onViewAll?: () => void }) { + if (acls.length === 0) { + return ( + + No ACLs + + ); + } + + const visibleAcls = acls.slice(0, ACL_HOVER_LIMIT); + const remaining = acls.length - ACL_HOVER_LIMIT; + + return ( + + + + {acls.length} {acls.length === 1 ? 'ACL' : 'ACLs'} + + + +
+

Principal

+

+ User:{principal} +

+
+ + + + + + + + + + {visibleAcls.map((acl, idx) => ( + + + + + + ))} + +
ResourceOperationPermission
+
+ {acl.resourceType}: + + {acl.resourceName} + +
+
{acl.operation} + + {acl.permission} + +
+ {remaining > 0 && ( +
+ +
+ )} +
+
+ ); +} + +// ─── ACL data helpers ──────────────────────────────────────────────────── + +function getResourceTypeLabel(resourceType: number): string { + // These map to ACL_ResourceType enum values + const labels: Record = { + 1: 'Any', + 2: 'Topic', + 3: 'Group', + 4: 'Cluster', + 5: 'TransactionalId', + 6: 'DelegationToken', + 7: 'RedpandaRole', + }; + return labels[resourceType] ?? 'Unknown'; +} + +function getOperationLabel(operation: number): string { + const labels: Record = { + 1: 'Any', + 2: 'All', + 3: 'Read', + 4: 'Write', + 5: 'Create', + 6: 'Delete', + 7: 'Alter', + 8: 'Describe', + 9: 'ClusterAction', + 10: 'DescribeConfigs', + 11: 'AlterConfigs', + 12: 'IdempotentWrite', + }; + return labels[operation] ?? 'Unknown'; +} + +function getPermissionLabel(permissionType: number): string { + // ACL_PermissionType: DENY=2, ALLOW=3 + return permissionType === 2 ? 'Deny' : 'Allow'; +} diff --git a/frontend/src/components/redpanda-ui/components/copy-button.tsx b/frontend/src/components/redpanda-ui/components/copy-button.tsx index 0e900743ed..fa7c3c1f7f 100644 --- a/frontend/src/components/redpanda-ui/components/copy-button.tsx +++ b/frontend/src/components/redpanda-ui/components/copy-button.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { cn } from '../lib/utils'; const buttonVariants = cva( - "inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", + "inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm outline-none transition-all focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", { variants: { variant: { @@ -16,7 +16,7 @@ const buttonVariants = cva( destructive: 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40', outline: - 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50', + '!border-outline-primary border text-primary-inverse shadow-xs hover:border-outline-primary-hover hover:bg-primary-alpha-subtle active:border-outline-primary-pressed active:bg-primary-alpha-subtle-default disabled:border-outline-inverse-disabled disabled:text-disabled', secondary: 'bg-primary text-inverse shadow-xs hover:bg-primary/90', ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', }, @@ -103,8 +103,6 @@ function CopyButton({ data-slot="copy-button" data-testid={testId} onClick={handleCopy} - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} {...props} > diff --git a/frontend/src/components/redpanda-ui/components/input.tsx b/frontend/src/components/redpanda-ui/components/input.tsx index b993cd4b41..59fbdeb64b 100644 --- a/frontend/src/components/redpanda-ui/components/input.tsx +++ b/frontend/src/components/redpanda-ui/components/input.tsx @@ -47,7 +47,7 @@ const inputContainerVariants = cva('', { variants: { layout: { standard: 'relative flex items-center', - password: 'relative flex flex-1', + password: 'relative flex w-full flex-1', number: 'flex items-center gap-2', }, }, @@ -130,6 +130,10 @@ const Input = React.forwardRef( const { increment, decrement, handleInputChange } = useNumberInputHandlers(value, setValue, step, props.onChange); + // Map input size to a button icon size that fits comfortably inside the input + // sm (h-8/32px) → icon-xs (24px), md (h-9/36px) → icon-sm (32px), lg (h-10/40px) → icon-sm (32px) + const passwordToggleSize = size === 'sm' ? 'icon-xs' as const : 'icon-sm' as const; + let positionClasses = 'rounded-md'; if (attached && groupPosition === 'first') { positionClasses = 'rounded-r-none rounded-l-md border-r-0'; @@ -194,6 +198,7 @@ const Input = React.forwardRef(