diff --git a/frontend/src/components/layout/header.tsx b/frontend/src/components/layout/header.tsx index 8d4a7bf354..7cc87e026c 100644 --- a/frontend/src/components/layout/header.tsx +++ b/frontend/src/components/layout/header.tsx @@ -164,18 +164,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; } @@ -188,12 +176,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/license/feature-license-notification.tsx b/frontend/src/components/license/feature-license-notification.tsx index daaa862026..84cde22de0 100644 --- a/frontend/src/components/license/feature-license-notification.tsx +++ b/frontend/src/components/license/feature-license-notification.tsx @@ -1,4 +1,4 @@ -import { Alert, AlertDescription, AlertIcon, Box, Flex, Text } from '@redpanda-data/ui'; +import { Flex, Text } from '@redpanda-data/ui'; import { Link } from 'components/redpanda-ui/components/typography'; import { type FC, type ReactElement, useEffect, useState } from 'react'; @@ -20,13 +20,16 @@ import { const WARNING_THRESHOLD_DAYS = 5; -import { RegisterModal } from './register-modal'; +import { InfoIcon } from 'components/icons'; +import { Alert, AlertDescription, AlertTitle } from 'components/redpanda-ui/components/alert'; import { type License, License_Type, type ListEnterpriseFeaturesResponse_Feature, -} from '../../protogen/redpanda/api/console/v1alpha1/license_pb'; -import { api } from '../../state/backend-api'; +} from 'protogen/redpanda/api/console/v1alpha1/license_pb'; +import { api } from 'state/backend-api'; + +import { RegisterModal } from './register-modal'; // biome-ignore lint/nursery/useMaxParams: Refactoring to options object would require updating all call sites const getLicenseAlertContentForFeature = ( @@ -36,7 +39,7 @@ const getLicenseAlertContentForFeature = ( bakedInTrial: boolean, onRegisterModalOpen: () => void // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complex business logic -): { message: ReactElement; status: 'warning' | 'info' } | null => { +): { message: ReactElement; status: 'warning' | 'destructive' } | null => { if (license === undefined) { return null; } @@ -47,23 +50,28 @@ const getLicenseAlertContentForFeature = ( if (bakedInTrial) { return { message: ( - - This is an enterprise feature. Register for an additional 30 days of enterprise features. - - - - + } variant="destructive"> + + This is an enterprise feature. Register for an additional 30 days of enterprise features. + + + + + ), - status: msToExpiration > WARNING_THRESHOLD_DAYS * MS_IN_DAY ? 'info' : 'warning', + status: msToExpiration > WARNING_THRESHOLD_DAYS * MS_IN_DAY ? 'warning' : 'destructive', }; } return { message: ( - - This is an enterprise feature. - + } + variant={msToExpiration > WARNING_THRESHOLD_DAYS * MS_IN_DAY ? 'warning' : 'destructive'} + > + This is an enterprise feature. + ), - status: msToExpiration > WARNING_THRESHOLD_DAYS * MS_IN_DAY ? 'info' : 'warning', + status: msToExpiration > WARNING_THRESHOLD_DAYS * MS_IN_DAY ? 'warning' : 'destructive', }; } @@ -76,39 +84,43 @@ const getLicenseAlertContentForFeature = ( ) { return { message: ( - - This is an enterprise feature, active until {getPrettyExpirationDate(license)}. - - - - - + } variant="info"> + + This is an enterprise feature, active until {getPrettyExpirationDate(license)}. + + + + + + ), - status: 'info', + status: 'warning', }; } if (msToExpiration > -1 && msToExpiration < 15 * MS_IN_DAY && coreHasEnterpriseFeatures(enterpriseFeaturesUsed)) { return { message: ( - - - Your Redpanda Enterprise trial is expiring in {getPrettyTimeToExpiration(license)}; at that point, your{' '} - - enterprise features - {' '} - will become unavailable. To get a full Redpanda Enterprise license,{' '} - - contact us - - . - - - - - - + } variant="destructive"> + + + Your Redpanda Enterprise trial is expiring in {getPrettyTimeToExpiration(license)}; at that point, your{' '} + + enterprise features + {' '} + will become unavailable. To get a full Redpanda Enterprise license,{' '} + + contact us + + . + + + + + + + ), - status: 'warning', + status: 'destructive', }; } } else { @@ -117,54 +129,62 @@ const getLicenseAlertContentForFeature = ( if (license.type === License_Type.TRIAL) { return { message: ( - - This is an enterprise feature. Your trial is active until {getPrettyExpirationDate(license)} - - - - - + } variant="warning"> + + + This is an enterprise feature. Your trial is active until {getPrettyExpirationDate(license)} + + + + + + + ), - status: 'info', + status: 'warning', }; } return { message: ( - - - This is a Redpanda Enterprise feature. Try it with our{' '} - - Redpanda Enterprise Trial - - . - - + } variant="warning"> + + + This is a Redpanda Enterprise feature. Try it with our{' '} + + Redpanda Enterprise Trial + + . + + + ), - status: 'info', + status: 'warning', }; } if (msToExpiration > 0 && msToExpiration < 15 * MS_IN_DAY && license.type === License_Type.TRIAL) { return { message: ( - - - Your Redpanda Enterprise trial is expiring in {getPrettyTimeToExpiration(license)}; at that point, your{' '} - - enterprise features - {' '} - will become unavailable. To get a full Redpanda Enterprise license,{' '} - - contact us - - . - - - - - - + } variant="warning"> + + + Your Redpanda Enterprise trial is expiring in {getPrettyTimeToExpiration(license)}; at that point, your{' '} + + enterprise features + {' '} + will become unavailable. To get a full Redpanda Enterprise license,{' '} + + contact us + + . + + + + + + + ), - status: 'warning', + status: 'destructive', }; } } @@ -220,16 +240,12 @@ export const FeatureLicenseNotification: FC<{ featureName: 'reassignPartitions' return null; } - const { message, status } = alertContent; + const { message } = alertContent; return ( - - - - {message} - - + <> + {message} setIsRegisterModalOpen(false)} /> - + ); }; diff --git a/frontend/src/components/misc/query-result.tsx b/frontend/src/components/misc/query-result.tsx new file mode 100644 index 0000000000..12fdcc0690 --- /dev/null +++ b/frontend/src/components/misc/query-result.tsx @@ -0,0 +1,48 @@ +/** + * 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 { ReactNode } from 'react'; +import { DefaultSkeleton } from 'utils/tsx-utils'; + +import { Alert, AlertDescription, AlertTitle } from '../redpanda-ui/components/alert'; + +type Props = { + isLoading: boolean; + isError: boolean; + error?: { message?: string } | null; + errorTitle?: string; + children: ReactNode; + skeleton?: ReactNode; +}; + +export const QueryResult = ({ + isLoading, + isError, + error, + errorTitle = 'Failed to load data', + children, + skeleton = DefaultSkeleton, +}: Props) => { + if (isLoading) { + return skeleton; + } + + if (isError) { + return ( + + {errorTitle} + {error?.message ?? 'An unexpected error occurred.'} + + ); + } + + return <>{children}; +}; 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 a6fab8fc9c..4a0fa586ba 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 @@ -42,7 +42,7 @@ import { listACLs } from 'protogen/redpanda/api/dataplane/v1/acl-ACLService_conn import { Scope } from 'protogen/redpanda/api/dataplane/v1/secret_pb'; import { listUsers } from 'protogen/redpanda/api/dataplane/v1/user-UserService_connectquery'; import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; -import { useForm, useWatch } from 'react-hook-form'; +import { type Control, useForm, useWatch } from 'react-hook-form'; import { useCreateSecretMutation } from 'react-query/api/secret'; import { useListUsersQuery } from 'react-query/api/user'; import { LONG_LIVED_CACHE_STALE_TIME } from 'react-query/react-query.utils'; @@ -50,7 +50,6 @@ import { toast } from 'sonner'; import { generatePassword } from 'utils/password'; import { generateServiceAccountName } from 'utils/service-account.utils'; import { formatToastErrorMessageGRPC } from 'utils/toast.utils'; -import { SASL_MECHANISMS } from 'utils/user'; import { useListACLsQuery } from '../../../../react-query/api/acl'; import type { UserStepRef, UserStepSubmissionResult } from '../types/wizard'; @@ -67,12 +66,13 @@ import { checkUserHasConsumerGroupPermissions, checkUserHasTopicReadWritePermissions, getACLOperationName, + SASL_MECHANISM_OPTIONS, useCreateUserWithSecretsMutation, } from '../utils/user'; type AddUserStepProps = { defaultUsername?: string; - defaultSaslMechanism?: (typeof SASL_MECHANISMS)[number]; + defaultSaslMechanism?: 'SCRAM-SHA-256' | 'SCRAM-SHA-512'; hideInternal?: boolean; topicName?: string; defaultConsumerGroup?: string; @@ -132,7 +132,7 @@ export const AddUserStep = forwardRef; + const watchedUsername = useWatch({ control: form.control, name: 'username', @@ -471,7 +473,7 @@ export const AddUserStep = forwardRef ( @@ -548,8 +550,8 @@ export const AddUserStep = forwardRef ACLs {' '} @@ -585,7 +587,7 @@ export const AddUserStep = forwardRef ( @@ -613,7 +615,7 @@ export const AddUserStep = forwardRef (
@@ -636,21 +638,21 @@ export const AddUserStep = forwardRef ( SASL mechanism - field.onChange(v)} value={field.value}> - {SASL_MECHANISMS.map((mechanism) => ( - - {mechanism} + {SASL_MECHANISM_OPTIONS.map((mech) => ( + + {mech.name} ))} @@ -663,7 +665,7 @@ export const AddUserStep = forwardRef ( @@ -725,7 +727,7 @@ export const AddUserStep = forwardRef
( diff --git a/frontend/src/components/pages/rp-connect/onboarding/onboarding-wizard.tsx b/frontend/src/components/pages/rp-connect/onboarding/onboarding-wizard.tsx index 1f8a751e39..674e8494d3 100644 --- a/frontend/src/components/pages/rp-connect/onboarding/onboarding-wizard.tsx +++ b/frontend/src/components/pages/rp-connect/onboarding/onboarding-wizard.tsx @@ -401,7 +401,7 @@ export const ConnectOnboardingWizard = ({ => { redpandaConfig.sasl = [ { - mechanism: userData.saslMechanism || 'SCRAM-SHA-256', + mechanism: userData.saslMechanism ?? 'SCRAM-SHA-256', username: getSecretSyntax(usernameSecretId), password: getSecretSyntax(passwordSecretId), }, @@ -305,7 +305,7 @@ function populateConnectionDefaults( const isMechanismField = spec.name.toLowerCase() === 'mechanism' && parentName?.toLowerCase() === 'sasl'; if (isMechanismField) { const userData = onboardingWizardStore.getUserData(); - return userData?.saslMechanism || 'SCRAM-SHA-256'; + return userData?.saslMechanism ?? 'SCRAM-SHA-256'; } return; diff --git a/frontend/src/components/pages/rp-connect/utils/user.ts b/frontend/src/components/pages/rp-connect/utils/user.ts index 62c8ab2bc6..130cdc6e85 100644 --- a/frontend/src/components/pages/rp-connect/utils/user.ts +++ b/frontend/src/components/pages/rp-connect/utils/user.ts @@ -10,7 +10,6 @@ import { useCreateSecretMutation } from 'react-query/api/secret'; import { useCreateUserMutation } from 'react-query/api/user'; import { toast } from 'sonner'; import { formatToastErrorMessageGRPC } from 'utils/toast.utils'; -import type { SaslMechanism } from 'utils/user'; import { base64ToUInt8Array, encodeBase64 } from 'utils/utils'; import { @@ -23,6 +22,14 @@ import { } from '../../../../protogen/redpanda/api/dataplane/v1/acl_pb'; import { CreateUserRequestSchema, SASLMechanism } from '../../../../protogen/redpanda/api/dataplane/v1/user_pb'; import { convertToScreamingSnakeCase } from '../types/constants'; + +export { getSASLMechanismName, SASL_MECHANISM_OPTIONS, SASL_MECHANISMS, SASLMechanism } from 'utils/user'; + +const saslMechanismToProto: Record = { + 'SCRAM-SHA-256': SASLMechanism.SASL_MECHANISM_SCRAM_SHA_256, + 'SCRAM-SHA-512': SASLMechanism.SASL_MECHANISM_SCRAM_SHA_512, +}; + import type { AddUserFormData, OperationResult } from '../types/wizard'; const createConsumerGroupACLs = (consumerGroupName: string, username: string) => { @@ -322,11 +329,6 @@ const createPasswordSecret = async ( } }; -const saslMechanismToProtoMapping: Record = { - 'SCRAM-SHA-256': SASLMechanism.SASL_MECHANISM_SCRAM_SHA_256, - 'SCRAM-SHA-512': SASLMechanism.SASL_MECHANISM_SCRAM_SHA_512, -}; - const createKafkaUser = async ( userData: AddUserFormData, createUserMutation: ReturnType @@ -336,7 +338,7 @@ const createKafkaUser = async ( user: { name: userData.username, password: userData.password, - mechanism: saslMechanismToProtoMapping[userData.saslMechanism], + mechanism: saslMechanismToProto[userData.saslMechanism] ?? SASLMechanism.SASL_MECHANISM_SCRAM_SHA_256, }, }); 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..429c4d82fc --- /dev/null +++ b/frontend/src/components/pages/security/change-password-dialog.tsx @@ -0,0 +1,235 @@ +/** + * 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 { + SASLMechanism, + UpdateUserRequest_UserSchema, + UpdateUserRequestSchema, +} from 'protogen/redpanda/api/dataplane/v1/user_pb'; +import { useEffect, useState } from 'react'; +import { useUpdateUserMutationWithToast } from 'react-query/api/user'; +import { toast } from 'sonner'; +import { generatePassword, SASL_MECHANISM_OPTIONS, SASL_MECHANISMS } from 'utils/user'; + +type ChangePasswordDialogProps = { + open: boolean; + userName: string; + currentMechanism: SASLMechanism | undefined; + onClose: () => void; +}; + +function resolveInitialMechanism(current: SASLMechanism | undefined): SASLMechanism { + return current !== undefined && SASL_MECHANISMS.includes(current as (typeof SASL_MECHANISMS)[number]) + ? current + : SASLMechanism.SASL_MECHANISM_SCRAM_SHA_512; +} + +export function ChangePasswordDialog({ open, userName, currentMechanism, onClose }: ChangePasswordDialogProps) { + const [newPassword, setNewPassword] = useState(() => generatePassword(24, true)); + const [selectedMechanism, setSelectedMechanism] = useState(() => + resolveInitialMechanism(currentMechanism) + ); + 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(resolveInitialMechanism(currentMechanism)); + setError(null); + setIncludeSpecialChars(true); + setIsSubmitting(false); + }; + + useEffect(() => { + if (!open) { + return; + } + setNewPassword(generatePassword(24, true)); + setSelectedMechanism(resolveInitialMechanism(currentMechanism)); + 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: 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..78bf352988 --- /dev/null +++ b/frontend/src/components/pages/security/create-user-dialog.tsx @@ -0,0 +1,367 @@ +/** + * 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 { useNavigate } from '@tanstack/react-router'; +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 { Text } from 'components/redpanda-ui/components/typography'; +import { Info, RefreshCw, Shield, UserCog } from 'lucide-react'; +import { + CreateUserRequest_UserSchema, + CreateUserRequestSchema, + SASLMechanism, +} from 'protogen/redpanda/api/dataplane/v1/user_pb'; +import { useState } from 'react'; +import { useCreateUserMutation } from 'react-query/api/user'; +import { toast } from 'sonner'; +import { generatePassword, SASL_MECHANISM_OPTIONS } from 'utils/user'; + +const DEFAULT_MECHANISM = SASLMechanism.SASL_MECHANISM_SCRAM_SHA_256; + +const WHITESPACE_REGEX = /\s/; +const USERNAME_ALLOWED_REGEX = /^[a-zA-Z0-9._-]+$/; + +function createGeneratedPassword(includeSpecial: boolean): string { + return generatePassword(24, includeSpecial); +} + +function validateForm(username: string, password: string): string | null { + if (!username.trim()) { + return 'Username is required'; + } + if (WHITESPACE_REGEX.test(username)) { + return 'Username must not contain whitespace'; + } + if (!USERNAME_ALLOWED_REGEX.test(username)) { + return 'Only letters, numbers, dots, hyphens, and underscores are allowed'; + } + if (!password) { + return 'Password is required'; + } + if (password.length < 4) { + return 'Password must be at least 4 characters'; + } + if (password.length > 64) { + return 'Password should not exceed 64 characters'; + } + return null; +} + +type 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(false)); + const [mechanism, setMechanism] = useState(DEFAULT_MECHANISM); + const [error, setError] = useState(null); + const [includeSpecial, setIncludeSpecial] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { mutateAsync: createUserMutation } = useCreateUserMutation(); + const navigate = useNavigate(); + + const resetForm = () => { + setStep('form'); + setUsername(''); + setPassword(createGeneratedPassword(false)); + setMechanism(DEFAULT_MECHANISM); + setError(null); + setIncludeSpecial(false); + setIsSubmitting(false); + }; + + const handleClose = () => { + resetForm(); + onClose(); + }; + + const handleGeneratePassword = () => { + const pwd = createGeneratedPassword(includeSpecial); + setPassword(pwd); + setError(null); + }; + + const handleToggleSpecial = (checked: boolean) => { + setIncludeSpecial(checked); + setPassword(createGeneratedPassword(checked)); + setError(null); + }; + + const handleCreate = async () => { + const validationError = validateForm(username, password); + if (validationError) { + setError(validationError); + return; + } + setError(null); + setIsSubmitting(true); + + try { + await createUserMutation( + create(CreateUserRequestSchema, { + user: create(CreateUserRequest_UserSchema, { + name: username.trim(), + password, + 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" + testId="create-user-name" + 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" + testId="create-user-password" + type="password" + value={password} + /> + + +
+
+ handleToggleSpecial(checked === true)} + testId="special-chars-checkbox" + /> + +
+
+ + {/* 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..4f2f85ec5a --- /dev/null +++ b/frontend/src/components/pages/security/permissions-tab.tsx @@ -0,0 +1,788 @@ +/** + * 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/security/shared/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 { 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 ( +
+ {/* 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/details" + > + + {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..599da9f685 --- /dev/null +++ b/frontend/src/components/pages/security/role-detail-page.tsx @@ -0,0 +1,513 @@ +/** + * 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..f156f355dc --- /dev/null +++ b/frontend/src/components/pages/security/roles-tab.tsx @@ -0,0 +1,336 @@ +/** + * 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 { useRegexFilter } from 'hooks/use-regex-filter'; +import { MoreHorizontal, Pencil, Plus, Search, Shield, Trash2, Users } from 'lucide-react'; +import { parseAsString, useQueryState } from 'nuqs'; +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] = useQueryState('q', parseAsString.withDefault('')); + 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 = useRegexFilter(roles, searchQuery, (role) => role.name); + 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/details', params: { roleName: encodeURIComponent(roleName) } }); + }; + + return ( +
+ {/* Toolbar */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search by name or regex..." + 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..c8999777a1 --- /dev/null +++ b/frontend/src/components/pages/security/security-acl-utils.test.ts @@ -0,0 +1,304 @@ +import type { AclDetail } from 'components/pages/acls/new-acl/acl.model'; +import { + ACL_Operation, + ACL_PermissionType, + ACL_ResourcePatternType, + ACL_ResourceType, + type ListACLsResponse_Resource, +} from 'protogen/redpanda/api/dataplane/v1/acl_pb'; +import { describe, expect, it } from 'vitest'; + +import { + buildPrincipalAutocompleteOptions, + buildResourceOptionsByType, + buildUserAclsMap, + 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']); + }); +}); + +function makeResource( + resourceType: ACL_ResourceType, + resourceName: string, + acls: { principal: string; operation: ACL_Operation; permissionType: ACL_PermissionType }[] +): ListACLsResponse_Resource { + return { resourceType, resourceName, acls } as unknown as ListACLsResponse_Resource; +} + +describe('buildUserAclsMap', () => { + it('returns an empty map for no resources', () => { + expect(buildUserAclsMap([])).toEqual(new Map()); + }); + + it('maps a User: principal to its ACL entry', () => { + const resources = [ + makeResource(ACL_ResourceType.TOPIC, 'my-topic', [ + { principal: 'User:alice', operation: ACL_Operation.READ, permissionType: ACL_PermissionType.ALLOW }, + ]), + ]; + + const map = buildUserAclsMap(resources); + + expect(map.get('alice')).toEqual([ + { resourceType: 'Topic', resourceName: 'my-topic', operation: 'Read', permission: 'Allow' }, + ]); + }); + + it('ignores non-User: principals', () => { + const resources = [ + makeResource(ACL_ResourceType.TOPIC, 'my-topic', [ + { principal: 'RedpandaRole:admin', operation: ACL_Operation.ALL, permissionType: ACL_PermissionType.ALLOW }, + ]), + ]; + + expect(buildUserAclsMap(resources).size).toBe(0); + }); + + it('groups multiple ACL entries under the same user', () => { + const resources = [ + makeResource(ACL_ResourceType.TOPIC, 'topic-a', [ + { principal: 'User:bob', operation: ACL_Operation.READ, permissionType: ACL_PermissionType.ALLOW }, + ]), + makeResource(ACL_ResourceType.GROUP, 'group-a', [ + { principal: 'User:bob', operation: ACL_Operation.READ, permissionType: ACL_PermissionType.DENY }, + ]), + ]; + + const acls = buildUserAclsMap(resources).get('bob'); + expect(acls).toHaveLength(2); + expect(acls?.map((a) => a.resourceType)).toEqual(['Group', 'Topic']); + }); + + it('sorts ACL entries within each user', () => { + const resources = [ + makeResource(ACL_ResourceType.TOPIC, 'topic-10', [ + { principal: 'User:alice', operation: ACL_Operation.WRITE, permissionType: ACL_PermissionType.ALLOW }, + ]), + makeResource(ACL_ResourceType.TOPIC, 'topic-2', [ + { principal: 'User:alice', operation: ACL_Operation.READ, permissionType: ACL_PermissionType.ALLOW }, + ]), + ]; + + const acls = buildUserAclsMap(resources).get('alice'); + expect(acls?.map((a) => a.resourceName)).toEqual(['topic-2', 'topic-10']); + }); + + it('maps enum values to display labels', () => { + const resources = [ + makeResource(ACL_ResourceType.CLUSTER, 'kafka-cluster', [ + { principal: 'User:alice', operation: ACL_Operation.CLUSTER_ACTION, permissionType: ACL_PermissionType.DENY }, + ]), + ]; + + expect(buildUserAclsMap(resources).get('alice')).toEqual([ + { resourceType: 'Cluster', resourceName: 'kafka-cluster', operation: 'ClusterAction', permission: 'Deny' }, + ]); + }); +}); 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..1a43b904d1 --- /dev/null +++ b/frontend/src/components/pages/security/security-acl-utils.ts @@ -0,0 +1,453 @@ +import { + type AclDetail, + getGRPCResourcePatternType, + getResourceNameValue, +} from 'components/pages/security/shared/acl-model'; +import type { ComboboxOption } from 'components/redpanda-ui/components/combobox'; +import { + ACL_Operation, + ACL_PermissionType, + ACL_ResourcePatternType, + ACL_ResourceType, + type ListACLsResponse_Resource, +} from 'protogen/redpanda/api/dataplane/v1/acl_pb'; + +import type { ACLEntry } from './acl-editor'; + +/** + * Numeric-aware, case-insensitive collator used throughout so "topic-10" sorts + * after "topic-2" in every list rendered to the user. + */ +const displayTextCollator = new Intl.Collator(undefined, { + numeric: true, + sensitivity: 'base', + usage: 'sort', +}); + +/** + * Maps form-model resource type strings (used in `AclDetail` rules) to the + * display labels shown in the ACL editor. Kept separate from the protobuf + * enum map because the form model uses its own string identifiers. + */ +const resourceTypeLabels: Record = { + cluster: 'Cluster', + consumerGroup: 'Group', + schemaRegistry: 'SchemaRegistry', + subject: 'Subject', + topic: 'Topic', + transactionalId: 'TransactionalId', +}; + +/** + * Maps form-model operation strings (upper-snake from react-hook-form) to the + * display labels rendered in the ACL editor table. + */ +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 }; + +/** + * Minimal shape required for stable multi-key ACL sorting. Generic so both + * `ACLEntry` (editor) and `UserAcl` (users tab) can share the same comparator. + */ +type SortableAclEntry = Pick & { + host?: string; + principal?: string; + roleName?: string; +}; + +type ResourceOptionSource = { + resourceName?: string; + resourceType?: ACL_ResourceType | string; +}; + +/** Combobox options for ACL resource creation, pre-grouped by resource type. */ +export type ResourceOptionsByType = Partial< + Record<'Cluster' | 'Group' | 'Topic' | 'TransactionalId', ComboboxOption[]> +>; + +/** Flat view-model for a single ACL row displayed in the Users tab. */ +export type UserAcl = { + resourceType: string; + resourceName: string; + operation: string; + permission: string; +}; + +/** + * Compares two display strings using numeric-aware, case-insensitive collation, + * falling back to a raw string tiebreaker when the collator considers them + * equal. The tiebreaker keeps "ALICE" / "Alice" / "alice" in a stable order + * instead of treating them as identical. + * + * @param a - First string to compare. + * @param b - Second string to compare. + * @returns Negative, zero, or positive number suitable for use in `Array.sort`. + */ +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; +} + +/** + * Sorts any collection that has a `name` field using {@link compareDisplayText}. + * + * @param items - Collection to sort. + * @returns New sorted array; the original is not mutated. + */ +export function sortByName(items: readonly T[]): T[] { + return [...items].sort((a, b) => compareDisplayText(a.name, b.name)); +} + +/** + * Sorts any collection that has a `principal` field using {@link compareDisplayText}. + * + * @param items - Collection to sort. + * @returns New sorted array; the original is not mutated. + */ +export function sortByPrincipal(items: readonly T[]): T[] { + return [...items].sort((a, b) => compareDisplayText(a.principal, b.principal)); +} + +/** + * Sorts ACL rows by the tuple (resourceType, resourceName, operation, + * permission, host, roleName, principal) so the table order is deterministic + * regardless of API response ordering. + * + * @param entries - ACL rows to sort. + * @returns New sorted array; the original is not mutated. + */ +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; + }); +} + +/** + * Converts a de-duplicated set of string values into sorted combobox options. + * + * @param values - Iterable of unique string values. + * @returns Sorted array of `{label, value}` pairs. + */ +function toComboboxOptions(values: Iterable): ComboboxOption[] { + return [...values].sort(compareDisplayText).map((value) => ({ + label: value, + value, + })); +} + +/** + * Translates a resource type to its UI display label. + * + * @remarks + * Accepts both the protobuf enum (from API responses) and the form-model + * string (from `AclDetail` rules) so callers don't need two separate helpers. + * Returns `undefined` for types not shown in the ACL editor (e.g. `ANY`). + * + * @param resourceType - Protobuf enum value or form-model string. + * @returns Display label, or `undefined` if the type is not editor-visible. + */ +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; + } +} + +/** + * Builds the sorted list of principal options for the ACL editor combobox. + * + * @remarks + * Merges live principals from existing ACLs, known users, and Redpanda roles + * into a single de-duplicated list so the user can pick from what already + * exists or type a new value. The `User:` prefix entry is included by default + * as a typing prompt. + * + * @returns Sorted, de-duplicated combobox options with `excludePrincipals` removed. + */ +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); +} + +/** + * Groups resource names by their display type for the ACL editor resource + * combobox. Callers pass the raw API resource list; this function filters to + * the four types the editor supports and de-duplicates names within each group. + * + * @param resources - Raw API resource list. + * @returns Options keyed by display type; unsupported types are omitted. + */ +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') ?? []), + }; +} + +/** + * Converts the react-hook-form `AclDetail` tree into the flat `ACLEntry` rows + * consumed by the ACL editor table and the delete/create mutations. The + * form model uses string keys and human-readable operation names; this + * function translates those to the display labels and protobuf pattern types + * expected downstream. + * + * @param details - Form-model ACL details, typically from `useFormContext`. + * @returns Sorted flat array of `ACLEntry` rows, or `[]` if input is absent. + */ +export function flattenAclDetails(details?: AclDetail[]): ACLEntry[] { + if (!Array.isArray(details)) { + return []; + } + return sortAclEntries(details.flatMap(detailToEntries)); +} + +/** @internal Expands a single `AclDetail` into its `ACLEntry` rows. */ +function detailToEntries(detail: AclDetail): ACLEntry[] { + const host = detail.sharedConfig?.host || '*'; + return (detail.rules ?? []).flatMap((rule) => ruleToEntries(rule, host)); +} + +/** @internal Expands a single rule's operation map into `ACLEntry` rows, skipping `not-set` operations. */ +function ruleToEntries(rule: AclDetail['rules'][number], host: string): ACLEntry[] { + return Object.entries(rule.operations ?? {}) + .filter(([, permission]) => permission !== 'not-set') + .map(([operation, permission]) => ({ + 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), + })); +} + +/** + * Translates the protobuf `ACL_ResourceType` enum to a display label. + * + * @remarks + * Separate from {@link getAclResourceTypeLabel} because that function only + * covers the four types the ACL editor supports and returns `undefined` for + * others. This variant handles all enum values for the Users tab summary view. + * + * @param resourceType - Protobuf resource type enum value. + * @returns Human-readable label, or `'Unknown'` for unrecognised values. + */ +function resourceTypeLabel(resourceType: ACL_ResourceType): string { + switch (resourceType) { + case ACL_ResourceType.ANY: + return 'Any'; + case ACL_ResourceType.TOPIC: + return 'Topic'; + case ACL_ResourceType.GROUP: + return 'Group'; + case ACL_ResourceType.CLUSTER: + return 'Cluster'; + case ACL_ResourceType.TRANSACTIONAL_ID: + return 'TransactionalId'; + case ACL_ResourceType.DELEGATION_TOKEN: + return 'DelegationToken'; + case ACL_ResourceType.USER: + // USER resource type represents Redpanda roles in the ACL system. + return 'RedpandaRole'; + default: + return 'Unknown'; + } +} + +/** + * Translates the protobuf `ACL_Operation` enum to a display label for the + * Users tab ACL summary hover card. + * + * @param operation - Protobuf operation enum value. + * @returns Human-readable label, or `'Unknown'` for unrecognised values. + */ +function operationLabel(operation: ACL_Operation): string { + switch (operation) { + case ACL_Operation.ANY: + return 'Any'; + case ACL_Operation.ALL: + return 'All'; + case ACL_Operation.READ: + return 'Read'; + case ACL_Operation.WRITE: + return 'Write'; + case ACL_Operation.CREATE: + return 'Create'; + case ACL_Operation.DELETE: + return 'Delete'; + case ACL_Operation.ALTER: + return 'Alter'; + case ACL_Operation.DESCRIBE: + return 'Describe'; + case ACL_Operation.CLUSTER_ACTION: + return 'ClusterAction'; + case ACL_Operation.DESCRIBE_CONFIGS: + return 'DescribeConfigs'; + case ACL_Operation.ALTER_CONFIGS: + return 'AlterConfigs'; + case ACL_Operation.IDEMPOTENT_WRITE: + return 'IdempotentWrite'; + default: + return 'Unknown'; + } +} + +/** + * Translates the protobuf `ACL_PermissionType` enum to `'Allow'` or `'Deny'`. + * + * @param permissionType - Protobuf permission type enum value. + * @returns `'Deny'` for `DENY`, `'Allow'` for everything else. + */ +function permissionLabel(permissionType: ACL_PermissionType): string { + return permissionType === ACL_PermissionType.DENY ? 'Deny' : 'Allow'; +} + +/** + * Builds a map of username → sorted `UserAcl` entries from the raw API resource list. + * + * @remarks + * Only `User:` principals are included — role and group principals are not + * relevant to the Users tab. The result is consumed by `UsersTab` to render + * the per-user ACL hover card without re-processing the full ACL list on every + * render. + * + * @param resources - Raw `ListACLsResponse` resource list. + * @returns Map keyed by bare username (without the `User:` prefix). + */ +export function buildUserAclsMap(resources: readonly ListACLsResponse_Resource[]): Map { + const map = new Map(); + + for (const resource of resources) { + for (const acl of resource.acls) { + const principal = acl.principal || ''; + if (!principal.startsWith('User:')) { + continue; + } + const userName = principal.substring(5); + const entry: UserAcl = { + resourceType: resourceTypeLabel(resource.resourceType), + resourceName: resource.resourceName, + operation: operationLabel(acl.operation), + permission: permissionLabel(acl.permissionType), + }; + const existing = map.get(userName); + if (existing) { + existing.push(entry); + } else { + map.set(userName, [entry]); + } + } + } + + for (const [userName, acls] of map.entries()) { + map.set(userName, sortAclEntries(acls)); + } + + return map; +} 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..a589579b55 --- /dev/null +++ b/frontend/src/components/pages/security/security-page.tsx @@ -0,0 +1,122 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { useNavigate } from '@tanstack/react-router'; +import { Tabs, TabsContent, TabsContents, TabsList, TabsTrigger } from 'components/redpanda-ui/components/tabs'; +import { Text } from 'components/redpanda-ui/components/typography'; +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; description: string }[] = [ + { + id: 'users', + label: 'Users', + description: + '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.', + }, + { + id: 'roles', + label: 'Roles', + requiresFeature: () => Boolean(Features.rolesApi), + description: + '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).', + }, + { + id: 'permissions', + label: 'Permissions', + description: + '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.', + }, +]; + +interface SecurityPageProps { + tab: SecurityTab; +} + +function TabContentWithDescription({ children, description }: { children: React.ReactNode; description: string }) { + return ( +
+
{children}
+ +
+ ); +} + +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 activeTabData = tabs.find((t) => t.id === activeTab) ?? tabs[0]; + + useEffect(() => { + uiState.pageTitle = 'Security'; + uiState.pageBreadcrumbs = [ + { title: 'Security', linkTo: `/security/${activeTab}` }, + { title: activeTabData.label, linkTo: `/security/${activeTab}` }, + ]; + }, [activeTab, activeTabData.label]); + + const routes: Record = { + users: '/security/users', + roles: '/security/roles', + permissions: '/security/permissions-list', + }; + + const setActiveTab = (securityTab: SecurityTab) => { + navigate({ to: routes[securityTab], 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/shared/create-user-button-props.ts b/frontend/src/components/pages/security/shared/create-user-button-props.ts new file mode 100644 index 0000000000..cfb0938e4f --- /dev/null +++ b/frontend/src/components/pages/security/shared/create-user-button-props.ts @@ -0,0 +1,29 @@ +/** + * 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 + */ + +export 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(' '), + }; +}; diff --git a/frontend/src/components/pages/security/shared/table-skeleton.tsx b/frontend/src/components/pages/security/shared/table-skeleton.tsx new file mode 100644 index 0000000000..510a5cd77e --- /dev/null +++ b/frontend/src/components/pages/security/shared/table-skeleton.tsx @@ -0,0 +1,38 @@ +/** + * 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 { Skeleton } from 'components/redpanda-ui/components/skeleton'; + +/** + * Skeleton that mimics the 3-column user table: + * [name] [role tags ...] [action icon] + */ +export const TableSkeleton = () => ( +
+ {/* Header */} +
+ + + +
+ {/* Rows */} + {Array.from({ length: 4 }).map((_, i) => ( +
+ +
+ + +
+ +
+ ))} +
+); diff --git a/frontend/src/components/pages/security/tabs/acls-tab.tsx b/frontend/src/components/pages/security/tabs/acls-tab.tsx index c92b12d02e..38aaafe837 100644 --- a/frontend/src/components/pages/security/tabs/acls-tab.tsx +++ b/frontend/src/components/pages/security/tabs/acls-tab.tsx @@ -14,6 +14,7 @@ import { DataTable, SearchField } from '@redpanda-data/ui'; import { Link, useNavigate } from '@tanstack/react-router'; import { TrashIcon } from 'components/icons'; import { InfoIcon } from 'lucide-react'; +import { parseAsString } from 'nuqs'; import { ACL_Operation, ACL_PermissionType, @@ -27,6 +28,7 @@ import { useState } from 'react'; import { toast } from 'sonner'; 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 { useDeleteUserMutation, useInvalidateUsersCache, useListUsersQuery } from '../../../../react-query/api/user'; @@ -61,7 +63,11 @@ export const AclsTab: FC = () => { const invalidateUsersCache = useInvalidateUsersCache(); const [aclFailed, setAclFailed] = useState<{ err: unknown } | null>(null); - const [searchQuery, setSearchQuery] = useState(''); + const [searchQuery, setSearchQuery] = useQueryStateWithCallback( + { onUpdate: () => {}, getDefaultValue: () => '' }, + 'q', + parseAsString.withDefault('') + ); const navigate = useNavigate(); @@ -89,7 +95,7 @@ export const AclsTab: FC = () => { principalGroups?.filter((g) => g.principalType === 'User' || g.principalType === 'Group') || []; const groups = filterByName(aclPrincipalGroups, searchQuery, (g) => g.principalName); - if (isError && error) { + if (isError) { return ; } @@ -99,30 +105,29 @@ export const AclsTab: FC = () => { 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"> + } variant="info"> Roles are a more flexible and efficient way to manage user permissions, especially with complex organizational hierarchies or large numbers of users. )} - -
- setAclFailed(null)} /> - +
+ setSearchQuery(x)} + width="300px" + /> +
+
+ setAclFailed(null)} />
{ ({ ...prev, host: record.host })} + search={{ host: record.host }} to="/security/acls/$aclName/details" > @@ -257,6 +265,7 @@ export const AclsTab: FC = () => { }, ]} data={groups} + emptyText={searchQuery ? 'No ACLs match your search' : 'No ACLs yet'} pagination sorting /> diff --git a/frontend/src/components/pages/security/tabs/permissions-list-tab.test.tsx b/frontend/src/components/pages/security/tabs/permissions-list-tab.test.tsx index 294f1e5e17..4d7d8959ba 100644 --- a/frontend/src/components/pages/security/tabs/permissions-list-tab.test.tsx +++ b/frontend/src/components/pages/security/tabs/permissions-list-tab.test.tsx @@ -132,6 +132,7 @@ vi.mock('@tanstack/react-router', async (importOriginal) => { ), useNavigate: () => vi.fn(), + useLocation: () => ({ searchStr: '' }), }; }); @@ -232,6 +233,8 @@ vi.mock('react-query/api/acl', () => ({ useListACLAsPrincipalGroups: () => listACLsData, })); +import { NuqsTestingAdapter } from 'nuqs/adapters/testing'; + import { PermissionsListTab } from './permissions-list-tab'; describe('Permissions List - delete dropdown for different principal types', () => { @@ -242,7 +245,11 @@ describe('Permissions List - delete dropdown for different principal types', () test('Group principal does not show "Delete User" options in dropdown', async () => { const user = userEvent.setup(); - render(); + render( + + + + ); const groupRow = await screen.findByTestId('row-engineering'); const deleteButton = within(groupRow).getByRole('button'); @@ -258,7 +265,11 @@ describe('Permissions List - delete dropdown for different principal types', () const user = userEvent.setup(); // "scram-admin" exists in usersData.users — it's a real SCRAM user - render(); + render( + + + + ); const scramRow = await screen.findByTestId('row-scram-admin'); const deleteButton = within(scramRow).getByRole('button'); @@ -277,7 +288,11 @@ describe('Permissions List - delete dropdown for different principal types', () test('Group principal has "Delete (ACLs only)" available', async () => { const user = userEvent.setup(); - render(); + render( + + + + ); const groupRow = await screen.findByTestId('row-engineering'); const deleteButton = within(groupRow).getByRole('button'); diff --git a/frontend/src/components/pages/security/tabs/permissions-list-tab.tsx b/frontend/src/components/pages/security/tabs/permissions-list-tab.tsx index dbd63c3a92..887e3d3cab 100644 --- a/frontend/src/components/pages/security/tabs/permissions-list-tab.tsx +++ b/frontend/src/components/pages/security/tabs/permissions-list-tab.tsx @@ -12,7 +12,8 @@ import { create } from '@bufbuild/protobuf'; import { DataTable, SearchField } from '@redpanda-data/ui'; import { Link } from '@tanstack/react-router'; -import { TrashIcon } from 'components/icons'; +import { InfoIcon, TrashIcon } from 'components/icons'; +import { parseAsString } from 'nuqs'; import { ACL_Operation, ACL_PermissionType, @@ -25,6 +26,7 @@ import { useState } from 'react'; import { toast } from 'sonner'; import ErrorResult from '../../../../components/misc/error-result'; +import { useQueryStateWithCallback } from '../../../../hooks/use-query-state-with-callback'; import { useDeleteAclMutation } from '../../../../react-query/api/acl'; import { useDeleteUserMutation, useInvalidateUsersCache } from '../../../../react-query/api/user'; import { appGlobal } from '../../../../state/app-global'; @@ -46,29 +48,11 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../ import { type PrincipalEntry, usePrincipalList } from '../hooks/use-principal-list'; import { useSecurityBreadcrumbs } from '../hooks/use-security-breadcrumbs'; import { AlertDeleteFailed } from '../shared/alert-delete-failed'; +import { getCreateUserButtonProps } from '../shared/create-user-button-props'; import { DeleteUserConfirmModal } from '../shared/delete-user-confirm-modal'; import { filterByName } from '../shared/filter-by-name'; import { UserRoleTags } from '../shared/user-role-tags'; -const getCreateUserButtonProps = ( - isAdminApiConfigured: boolean, - featureCreateUser: boolean, - canManageUsers: boolean | undefined -) => { - const hasRBAC = canManageUsers !== undefined; - - return { - disabled: !(isAdminApiConfigured && featureCreateUser) || (hasRBAC && canManageUsers === false), - tooltip: [ - !isAdminApiConfigured && 'The Redpanda Admin API is not configured.', - !featureCreateUser && "Your cluster doesn't support this feature.", - hasRBAC && canManageUsers === false && 'You need RedpandaCapability.MANAGE_REDPANDA_USERS permission.', - ] - .filter(Boolean) - .join(' '), - }; -}; - const PermissionsListActions = ({ entry, canDeleteUser, @@ -141,7 +125,11 @@ const PermissionsListActions = ({ export const PermissionsListTab: FC = () => { useSecurityBreadcrumbs([]); - const [searchQuery, setSearchQuery] = useState(''); + const [searchQuery, setSearchQuery] = useQueryStateWithCallback( + { onUpdate: () => {}, getDefaultValue: () => '' }, + 'q', + parseAsString.withDefault('') + ); const [aclFailed, setAclFailed] = useState<{ err: unknown } | null>(null); const featureCreateUser = useSupportedFeaturesStore((s) => s.createUser); const featureDeleteUser = useSupportedFeaturesStore((s) => s.deleteUser); @@ -215,17 +203,32 @@ export const PermissionsListTab: FC = () => { 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. -

+

+ } variant="info"> + +

+ To grant permissions, use the{' '} + + ACLs + {' '} + or{' '} + + Roles + {' '} + tabs. +

+
+
setSearchQuery(x)} width="300px" /> @@ -259,7 +262,7 @@ export const PermissionsListTab: FC = () => { {entry.name} @@ -290,6 +293,7 @@ export const PermissionsListTab: FC = () => { ]} data={usersFiltered} emptyAction={(() => { + if (searchQuery) return; const { disabled, tooltip } = getCreateUserButtonProps( isAdminApiConfigured, featureCreateUser, @@ -307,12 +311,12 @@ export const PermissionsListTab: FC = () => { Create user - {tooltip && {tooltip}} + {Boolean(tooltip) && {tooltip}} ); })()} - emptyText="No principals yet" + emptyText={searchQuery ? 'No principals match your search' : 'No principals yet'} pagination sorting /> diff --git a/frontend/src/components/pages/security/tabs/roles-tab.test.tsx b/frontend/src/components/pages/security/tabs/roles-tab.test.tsx index 330746d015..c5bba7f8b3 100644 --- a/frontend/src/components/pages/security/tabs/roles-tab.test.tsx +++ b/frontend/src/components/pages/security/tabs/roles-tab.test.tsx @@ -201,6 +201,7 @@ vi.mock('@tanstack/react-router', async (importOriginal) => { ), useNavigate: () => vi.fn(), + useLocation: () => ({ searchStr: '' }), }; }); @@ -303,6 +304,8 @@ vi.mock('react-query/api/security', () => ({ }), })); +import { NuqsTestingAdapter } from 'nuqs/adapters/testing'; + import { RolesTab } from './roles-tab'; describe('RolesTab role navigation', () => { @@ -313,7 +316,11 @@ describe('RolesTab role navigation', () => { test('navigates role edit actions to the encoded update route', async () => { const user = userEvent.setup(); - render(); + render( + + + + ); await user.click(await screen.findByLabelText('Edit role topic reader/qa')); @@ -321,7 +328,11 @@ describe('RolesTab role navigation', () => { }); test('renders role list from useListRolesQuery', async () => { - render(); + render( + + + + ); await expect(screen.findByTestId('role-list-item-topic reader/qa')).resolves.toBeInTheDocument(); }); @@ -329,7 +340,11 @@ describe('RolesTab role navigation', () => { test('delete role calls deleteRoleMutation with correct arguments', async () => { const user = userEvent.setup(); - render(); + render( + + + + ); await user.click(await screen.findByTestId('mock-confirm-delete-topic reader/qa')); diff --git a/frontend/src/components/pages/security/tabs/roles-tab.tsx b/frontend/src/components/pages/security/tabs/roles-tab.tsx index db481761a2..35b16b6d93 100644 --- a/frontend/src/components/pages/security/tabs/roles-tab.tsx +++ b/frontend/src/components/pages/security/tabs/roles-tab.tsx @@ -13,11 +13,12 @@ import { create } from '@bufbuild/protobuf'; import { DataTable, SearchField } from '@redpanda-data/ui'; import { Link } from '@tanstack/react-router'; import { EditIcon, TrashIcon } from 'components/icons'; +import { QueryResult } from 'components/misc/query-result'; +import { TableSkeleton } from 'components/pages/security/shared/table-skeleton'; +import { parseAsString, useQueryState } from 'nuqs'; import { DeleteRoleRequestSchema } from 'protogen/redpanda/api/dataplane/v1/security_pb'; import type { FC } from 'react'; -import { useState } from 'react'; -import ErrorResult from '../../../../components/misc/error-result'; import { useDeleteRoleMutation, useListRolesQuery } from '../../../../react-query/api/security'; import { appGlobal } from '../../../../state/app-global'; import { rolesApi, useApiStoreHook } from '../../../../state/backend-api'; @@ -35,8 +36,8 @@ export const RolesTab: FC = () => { useSecurityBreadcrumbs([]); 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 [searchQuery, setSearchQuery] = useQueryState('q', parseAsString.withDefault('')); + const { data: rolesData, isLoading: rolesIsLoading, isError: rolesIsError, error: rolesError } = useListRolesQuery(); const { mutateAsync: deleteRoleMutation } = useDeleteRoleMutation(); const roles = filterByName(rolesData?.roles ?? [], searchQuery, (r) => r.name); @@ -46,10 +47,6 @@ export const RolesTab: FC = () => { return { name: r.name, members }; }); - if (rolesIsError) { - return ; - } - const createRoleDisabled = userData?.canCreateRoles === false || !featureRolesApi; const createRoleTooltip = [ userData?.canCreateRoles === false && @@ -61,112 +58,122 @@ export const RolesTab: FC = () => { 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). -

+

- -
+
+ setSearchQuery(x)} + width="300px" + /> - {createRoleTooltip && {createRoleTooltip}} + {Boolean(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 ( -
-
+ } + > +
+
+ { + const entry = ctx.row.original; + return ( + - - - - - - } - numberOfPrincipals={entry.members.length} - onConfirm={async () => { - await deleteRoleMutation( - create(DeleteRoleRequestSchema, { roleName: entry.name, deleteAcls: true }) - ); - }} - roleName={entry.name} - /> -
- ); + {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 - /> -
-
+ ]} + data={rolesWithMembers} + emptyText={searchQuery ? 'No roles match your search' : 'No roles yet'} + pagination + sorting + /> +
+
+
); }; diff --git a/frontend/src/components/pages/security/tabs/users-tab.tsx b/frontend/src/components/pages/security/tabs/users-tab.tsx index d5de970702..cf06ae625c 100644 --- a/frontend/src/components/pages/security/tabs/users-tab.tsx +++ b/frontend/src/components/pages/security/tabs/users-tab.tsx @@ -12,18 +12,18 @@ import { DataTable, SearchField } from '@redpanda-data/ui'; import { Link } from '@tanstack/react-router'; import { MoreHorizontalIcon } from 'components/icons'; -import { parseAsString } from 'nuqs'; -import type { FC } from 'react'; -import { useState } from 'react'; +import { QueryResult } from 'components/misc/query-result'; +import { CreateUserDialog } from 'components/pages/security/create-user-dialog'; +import { TableSkeleton } from 'components/pages/security/shared/table-skeleton'; +import { parseAsString, useQueryState } from 'nuqs'; +import { type FC, useState } from 'react'; -import { useQueryStateWithCallback } from '../../../../hooks/use-query-state-with-callback'; import { useGetRedpandaInfoQuery } from '../../../../react-query/api/cluster-status'; import { useDeleteUserMutation, useInvalidateUsersCache, useListUsersQuery } from '../../../../react-query/api/user'; import { appGlobal } from '../../../../state/app-global'; import { rolesApi, useApiStoreHook } from '../../../../state/backend-api'; import { useSupportedFeaturesStore } from '../../../../state/supported-features'; import Section from '../../../misc/section'; -import { Alert, AlertDescription, AlertTitle } from '../../../redpanda-ui/components/alert'; import { Button } from '../../../redpanda-ui/components/button'; import { DropdownMenu, @@ -33,6 +33,7 @@ import { } from '../../../redpanda-ui/components/dropdown-menu'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../../redpanda-ui/components/tooltip'; import { useSecurityBreadcrumbs } from '../hooks/use-security-breadcrumbs'; +import { getCreateUserButtonProps } from '../shared/create-user-button-props'; import { DeleteUserConfirmModal } from '../shared/delete-user-confirm-modal'; import { filterByName } from '../shared/filter-by-name'; import { UserRoleTags } from '../shared/user-role-tags'; @@ -40,25 +41,6 @@ import { ChangePasswordModal, ChangeRolesModal } from '../users/user-edit-modals type PrincipalEntry = { name: string; principalType: 'User' | 'Group'; isScramUser: boolean }; -const getCreateUserButtonProps = ( - isAdminApiConfigured: boolean, - featureCreateUser: boolean, - canManageUsers: boolean | undefined -) => { - const hasRBAC = canManageUsers !== undefined; - - return { - disabled: !(isAdminApiConfigured && featureCreateUser) || (hasRBAC && canManageUsers === false), - tooltip: [ - !isAdminApiConfigured && 'The Redpanda Admin API is not configured.', - !featureCreateUser && "Your cluster doesn't support this feature.", - hasRBAC && canManageUsers === false && 'You need RedpandaCapability.MANAGE_REDPANDA_USERS permission.', - ] - .filter(Boolean) - .join(' '), - }; -}; - export const UsersTab: FC = () => { useSecurityBreadcrumbs([]); const { data: redpandaInfo, isSuccess: isRedpandaInfoSuccess } = useGetRedpandaInfoQuery(); @@ -66,18 +48,10 @@ export const UsersTab: FC = () => { 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 [searchQuery, setSearchQuery] = useQueryState('q', parseAsString.withDefault('')); const { data: usersData, + isLoading, isError, error, } = useListUsersQuery(undefined, { @@ -92,106 +66,113 @@ export const UsersTab: FC = () => { 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 ); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + 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" - /> +

-
+
+ setSearchQuery(x)} + width="300px" + /> - {createTooltip && {createTooltip}} + {Boolean(createTooltip) && {createTooltip}} +
-
- - columns={[ - { - id: 'name', - size: Number.POSITIVE_INFINITY, - header: 'User', - cell: (ctx) => { - const entry = ctx.row.original; - return ( - - {entry.name} - - ); +
+ } + > +
+ + 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 ; + { + 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 ; + { + size: 60, + id: 'menu', + header: '', + cell: (ctx) => { + const entry = ctx.row.original; + return ; + }, }, - }, - ]} - data={usersFiltered} - emptyAction={ - - } - emptyText="No users yet" - pagination - sorting - /> -
+ ]} + data={usersFiltered} + emptyAction={ + searchQuery ? undefined : ( + + ) + } + emptyText={searchQuery ? 'No users match your search' : 'No users yet'} + pagination + sorting + /> +
+
+ + setIsCreateModalOpen(false)} + onNavigateToTab={(tab) => appGlobal.historyPush(tab === 'roles' ? '/security/roles' : '/security/acls')} + open={isCreateModalOpen} + />
); }; 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..96d5b61740 --- /dev/null +++ b/frontend/src/components/pages/security/user-detail-page.tsx @@ -0,0 +1,546 @@ +/** + * 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/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..98281fd577 --- /dev/null +++ b/frontend/src/components/pages/security/users-tab.tsx @@ -0,0 +1,358 @@ +/** + * 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 { useRegexFilter } from 'hooks/use-regex-filter'; +import { Key, MoreHorizontal, Plus, Search, Trash2, UserCog, Users } from 'lucide-react'; +import { parseAsString, useQueryState } from 'nuqs'; +import { SASLMechanism } from 'protogen/redpanda/api/dataplane/v1/user_pb'; +import { useMemo, useState } from 'react'; +import { useListACLsQuery } from 'react-query/api/acl'; +import { useDeleteUserMutation, useListUsersQuery } from 'react-query/api/user'; +import { toast } from 'sonner'; +import { useSupportedFeaturesStore } from 'state/supported-features'; + +import { ChangePasswordDialog } from './change-password-dialog'; +import { CreateUserDialog } from './create-user-dialog'; +import { buildUserAclsMap, sortByName, type UserAcl } from './security-acl-utils'; + +const ACL_HOVER_LIMIT = 8; + +const MECHANISM_LABELS: Partial> = { + [SASLMechanism.SASL_MECHANISM_SCRAM_SHA_256]: 'SCRAM-SHA-256', + [SASLMechanism.SASL_MECHANISM_SCRAM_SHA_512]: 'SCRAM-SHA-512', +}; + +type UsersTabProps = { + onNavigateToTab: (tab: string) => void; +}; + +export function UsersTab({ onNavigateToTab }: UsersTabProps) { + const navigate = useNavigate(); + const [searchQuery, setSearchQuery] = useQueryState('q', parseAsString.withDefault('')); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [passwordDialogUser, setPasswordDialogUser] = useState<{ + name: string; + mechanism: SASLMechanism | undefined; + } | null>(null); + const [deleteUserName, setDeleteUserName] = useState(null); + const { mutateAsync: deleteUserMutation, isPending: isDeletingUser } = useDeleteUserMutation(); + const canCreateUser = useSupportedFeaturesStore((s) => s.createUser); + const canDeleteUser = useSupportedFeaturesStore((s) => s.deleteUser); + + // Fetch data + const { data: usersData } = useListUsersQuery(); + const { data: aclsData } = useListACLsQuery(); + + const users = useMemo(() => sortByName(usersData?.users ?? []), [usersData]); + + const userAclsMap = useMemo(() => buildUserAclsMap(aclsData?.aclResources ?? []), [aclsData]); + + const filteredUsers = useRegexFilter(users, searchQuery, (user) => user.name); + + 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 ( +
+ {/* Toolbar */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search by name or regex..." + type="text" + value={searchQuery} + /> +
+ {Boolean(canCreateUser) && ( + + )} +
+ + {/* Users Table */} +
+ {filteredUsers.length > 0 ? ( + + + + User + Mechanism + ACLs + + Actions + + + + + {filteredUsers.map((user) => { + const mechanismLabel = user?.mechanism ? MECHANISM_LABELS[user.mechanism] : 'Unknown'; + 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: user.mechanism })} + > + + Change password + + navigateToUser(user.name)}> + + View details + + {Boolean(canDeleteUser) && ( + <> + + setDeleteUserName(user.name)} + > + + Delete user + + + )} + + + +
+ ); + })} +
+
+ ) : ( + + + + + + No users found + + {searchQuery + ? 'No users matching the query. Try adjusting your search.' + : 'Get started by creating your first SASL-SCRAM user.'} + + + {!searchQuery && Boolean(canCreateUser) && ( + + )} + + )} +
+ + {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 && ( +
+ +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/pages/security/users/user-create.tsx b/frontend/src/components/pages/security/users/user-create.tsx index dcb19e6b4a..9058459a06 100644 --- a/frontend/src/components/pages/security/users/user-create.tsx +++ b/frontend/src/components/pages/security/users/user-create.tsx @@ -18,13 +18,13 @@ import { useCallback, useState } from 'react'; import { generatePassword } from 'utils/password'; import { useListRolesQuery, useUpdateRoleMembershipMutation } from '../../../../react-query/api/security'; -import { getSASLMechanism, useCreateUserMutation, useListUsersQuery } from '../../../../react-query/api/user'; +import { useCreateUserMutation, useListUsersQuery } from '../../../../react-query/api/user'; import { useSupportedFeaturesStore } from '../../../../state/supported-features'; import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, - SASL_MECHANISMS, - type SaslMechanism, + SASL_MECHANISM_OPTIONS, + SASLMechanism, validatePassword, validateUsername, } from '../../../../utils/user'; @@ -43,7 +43,7 @@ const UserCreatePage = () => { const [formState, setFormState] = useState({ username: '', password: generatePassword(30, false), - mechanism: 'SCRAM-SHA-256' as SaslMechanism, + mechanism: SASLMechanism.SASL_MECHANISM_SCRAM_SHA_256, generateWithSpecialChars: false, selectedRoles: [] as string[], }); @@ -56,7 +56,7 @@ const UserCreatePage = () => { 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 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 })); @@ -76,7 +76,7 @@ const UserCreatePage = () => { user: create(CreateUserRequest_UserSchema, { name: username, password, - mechanism: getSASLMechanism(mechanism), + mechanism, }), }); } catch { @@ -154,8 +154,8 @@ type CreateUserModalProps = { setUsername: (v: string) => void; password: string; setPassword: (v: string) => void; - mechanism: SaslMechanism; - setMechanism: (v: SaslMechanism) => void; + mechanism: SASLMechanism; + setMechanism: (v: SASLMechanism) => void; generateWithSpecialChars: boolean; setGenerateWithSpecialChars: (v: boolean) => void; isCreating: boolean; @@ -263,14 +263,14 @@ const CreateUserModal = ({ state, onCreateUser, onCancel }: CreateUserModalProps SASL mechanism - state.setMechanism(Number(v) as SASLMechanism)} value={String(state.mechanism)}> - {SASL_MECHANISMS.map((m) => ( - - {m} + {SASL_MECHANISM_OPTIONS.map((m) => ( + + {m.name} ))} @@ -306,7 +306,7 @@ const CreateUserModal = ({ state, onCreateUser, onCancel }: CreateUserModalProps type CreateUserConfirmationModalProps = { username: string; password: string; - mechanism: SaslMechanism; + mechanism: SASLMechanism; closeModal: () => void; onCreateAcls: () => void; }; @@ -360,7 +360,9 @@ const CreateUserConfirmationModal = ({ Mechanism
- {mechanism} + + {SASL_MECHANISM_OPTIONS.find((o) => o.id === mechanism)?.name ?? 'SCRAM-SHA-256'} +
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/hooks/use-regex-filter.ts b/frontend/src/hooks/use-regex-filter.ts new file mode 100644 index 0000000000..c3fe1be7f5 --- /dev/null +++ b/frontend/src/hooks/use-regex-filter.ts @@ -0,0 +1,27 @@ +import { useMemo } from 'react'; + +const cache = new Map(); + +/** + * Filters `items` by `searchQuery` using a regex (case-insensitive). + * Falls back to a substring match when `searchQuery` is not a valid regex. + * Returns the original array reference when `searchQuery` is empty. + */ +export function useRegexFilter(items: T[], searchQuery: string, getText: (item: T) => string): T[] { + return useMemo(() => { + if (!searchQuery) { + return items; + } + try { + let re = cache.get(searchQuery); + if (!re) { + re = new RegExp(searchQuery, 'i'); + cache.set(searchQuery, re); + } + return items.filter((item) => re.test(getText(item))); + } catch { + const q = searchQuery.toLowerCase(); + return items.filter((item) => getText(item).toLowerCase().includes(q)); + } + }, [items, searchQuery, getText]); +} diff --git a/frontend/src/react-query/api/acl.tsx b/frontend/src/react-query/api/acl.tsx index baa88eb598..6eb40b3c05 100644 --- a/frontend/src/react-query/api/acl.tsx +++ b/frontend/src/react-query/api/acl.tsx @@ -168,7 +168,7 @@ export const useLegacyListACLsQuery = ( }); const allRetrievedACLs = - legacyListACLsResult.data?.aclResources.map((aclResource) => + legacyListACLsResult.data?.aclResources?.map((aclResource) => create(ListACLsResponse_ResourceSchema, { resourceType: getACLResourceTypeLegacy(aclResource.resourceType), resourceName: aclResource.resourceName, @@ -329,6 +329,7 @@ export const useLegacyCreateACLMutation = () => { const queryClient = useQueryClient(); return useTanstackMutation({ + retry: false, mutationFn: async (request: CreateACLRequest) => { const legacyRequestBody = { resourceType: getACLResourceType(request.resourceType), @@ -362,6 +363,13 @@ export const useLegacyCreateACLMutation = () => { }), exact: false, }); + await queryClient.invalidateQueries({ + queryKey: createConnectQueryKey({ + schema: ACLService.method.listACLs, + cardinality: 'finite', + }), + exact: false, + }); return data; }, @@ -373,6 +381,13 @@ export const useLegacyCreateACLMutation = () => { }), exact: false, }); + await queryClient.invalidateQueries({ + queryKey: createConnectQueryKey({ + schema: ACLService.method.listACLs, + cardinality: 'finite', + }), + exact: false, + }); }, onError: (error) => { const connectError = ConnectError.from(error); @@ -392,12 +407,21 @@ export const useCreateACLMutation = () => { const queryClient = useQueryClient(); return useMutation(createACL, { + retry: false, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: createConnectQueryKey({ schema: ACLService.method.listACLs, cardinality: 'infinite', }), + exact: false, + }); + await queryClient.invalidateQueries({ + queryKey: createConnectQueryKey({ + schema: ACLService.method.listACLs, + cardinality: 'finite', + }), + exact: false, }); }, onError: (error) => @@ -457,6 +481,14 @@ const useInvalidateAclsList = () => { schema: ACLService.method.listACLs, cardinality: 'finite', }), + exact: false, + }); + await queryClient.invalidateQueries({ + queryKey: createConnectQueryKey({ + schema: ACLService.method.listACLs, + cardinality: 'infinite', + }), + exact: false, }); }; @@ -470,6 +502,7 @@ export const useDeleteAclMutation = ( ) => { const { invalid } = useInvalidateAclsList(); return useMutation(deleteACLs, { + retry: false, onSettled: async (_, error) => { if (!error) { await invalid(); diff --git a/frontend/src/react-query/api/security.tsx b/frontend/src/react-query/api/security.tsx index 17421e9464..86c5f632eb 100644 --- a/frontend/src/react-query/api/security.tsx +++ b/frontend/src/react-query/api/security.tsx @@ -3,6 +3,7 @@ import type { GenMessage } from '@bufbuild/protobuf/codegenv1'; import { createConnectQueryKey, useMutation, useQuery } from '@connectrpc/connect-query'; import type { InfiniteData } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query'; +import { ACLService } from 'protogen/redpanda/api/dataplane/v1/acl_pb'; import { type GetRoleRequest, GetRoleRequestSchema, @@ -29,6 +30,53 @@ import { useInfiniteQueryWithAllPages } from 'react-query/use-infinite-query-wit import { toast } from 'sonner'; import { formatToastErrorMessageGRPC } from 'utils/toast.utils'; +const invalidateRolesQueries = async (queryClient: ReturnType) => { + await queryClient.invalidateQueries({ + queryKey: createConnectQueryKey({ + schema: SecurityService.method.listRoles, + cardinality: 'infinite', + }), + exact: false, + }); +}; + +const invalidateRoleMembersQueries = async (queryClient: ReturnType) => { + await queryClient.invalidateQueries({ + queryKey: createConnectQueryKey({ + schema: SecurityService.method.listRoleMembers, + cardinality: 'infinite', + }), + exact: false, + }); +}; + +const invalidateRoleDetailQueries = async (queryClient: ReturnType) => { + await queryClient.invalidateQueries({ + queryKey: createConnectQueryKey({ + schema: SecurityService.method.getRole, + cardinality: 'finite', + }), + exact: false, + }); +}; + +const invalidateAclQueries = async (queryClient: ReturnType) => { + await queryClient.invalidateQueries({ + queryKey: createConnectQueryKey({ + schema: ACLService.method.listACLs, + cardinality: 'finite', + }), + exact: false, + }); + await queryClient.invalidateQueries({ + queryKey: createConnectQueryKey({ + schema: ACLService.method.listACLs, + cardinality: 'infinite', + }), + exact: false, + }); +}; + export const useListRolesQuery = ( input?: MessageInit, options?: QueryOptions, ListRolesResponse> @@ -85,14 +133,9 @@ export const useCreateRoleMutation = () => { const queryClient = useQueryClient(); return useMutation(createRole, { + retry: false, onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: createConnectQueryKey({ - schema: SecurityService.method.listRoles, - cardinality: 'infinite', - }), - exact: false, - }); + await invalidateRolesQueries(queryClient); }, onError: (error) => toast.error( @@ -120,14 +163,12 @@ export const useDeleteRoleMutation = () => { const queryClient = useQueryClient(); return useMutation(deleteRole, { + retry: false, onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: createConnectQueryKey({ - schema: SecurityService.method.listRoles, - cardinality: 'infinite', - }), - exact: false, - }); + await invalidateRolesQueries(queryClient); + await invalidateRoleMembersQueries(queryClient); + await invalidateRoleDetailQueries(queryClient); + await invalidateAclQueries(queryClient); }, onError: (error) => toast.error( @@ -152,6 +193,7 @@ export const useUpdateRoleMembershipMutation = () => { } as const; return useMutation(updateRoleMembership, { + retry: false, onMutate: async (variables) => { // Cancel in-flight fetches so they don't overwrite the optimistic data await queryClient.cancelQueries(listRoleMembersQueryFilter); @@ -182,17 +224,12 @@ export const useUpdateRoleMembershipMutation = () => { }, onSuccess: async () => { - // Only invalidate the roles list (to refresh member counts). + // Invalidate the roles list (to refresh member counts) and role detail. // The listRoleMembers cache already reflects the correct state via the // optimistic update in onMutate — invalidating it here would trigger a // refetch that may return stale data before the backend catches up. - await queryClient.invalidateQueries({ - queryKey: createConnectQueryKey({ - schema: SecurityService.method.listRoles, - cardinality: 'infinite', - }), - exact: false, - }); + await invalidateRolesQueries(queryClient); + await invalidateRoleDetailQueries(queryClient); }, onError: (error, _variables, context) => { diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index cbbdb8c792..0faca0ce95 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -60,6 +60,7 @@ import { Route as AgentsIdIndexRouteImport } from './routes/agents/$id/index'; import { Route as TopicsTopicNameProduceRecordRouteImport } from './routes/topics/$topicName/produce-record'; import { Route as ShadowlinksNameEditRouteImport } from './routes/shadowlinks/$name/edit'; import { Route as SecurityUsersCreateRouteImport } from './routes/security/users/create'; +import { Route as SecurityUsersUserNameRouteImport } from './routes/security/users/$userName'; import { Route as SecurityRolesCreateRouteImport } from './routes/security/roles/create'; import { Route as SecurityAclsCreateRouteImport } from './routes/security/acls/create'; import { Route as SecretsIdEditRouteImport } from './routes/secrets/$id/edit'; @@ -70,7 +71,6 @@ import { Route as DebugBundleProgressJobIdRouteImport } from './routes/debug-bun import { Route as ConnectClustersClusterNameCreateConnectorRouteImport } from './routes/connect-clusters/$clusterName/create-connector'; import { Route as ConnectClustersClusterNameConnectorRouteImport } from './routes/connect-clusters/$clusterName/$connector'; import { Route as SchemaRegistrySubjectsSubjectNameIndexRouteImport } from './routes/schema-registry/subjects/$subjectName/index'; -import { Route as SecurityUsersUserNameDetailsRouteImport } from './routes/security/users/$userName/details'; import { Route as SecurityRolesRoleNameUpdateRouteImport } from './routes/security/roles/$roleName/update'; import { Route as SecurityRolesRoleNameEditRouteImport } from './routes/security/roles/$roleName/edit'; import { Route as SecurityRolesRoleNameDetailsRouteImport } from './routes/security/roles/$roleName/details'; @@ -347,6 +347,11 @@ const SecurityUsersCreateRoute = SecurityUsersCreateRouteImport.update({ path: '/users/create', getParentRoute: () => SecurityRoute, } as any); +const SecurityUsersUserNameRoute = SecurityUsersUserNameRouteImport.update({ + id: '/users/$userName', + path: '/users/$userName', + getParentRoute: () => SecurityRoute, +} as any); const SecurityRolesCreateRoute = SecurityRolesCreateRouteImport.update({ id: '/roles/create', path: '/roles/create', @@ -401,12 +406,6 @@ const SchemaRegistrySubjectsSubjectNameIndexRoute = path: '/schema-registry/subjects/$subjectName/', getParentRoute: () => rootRouteImport, } as any); -const SecurityUsersUserNameDetailsRoute = - SecurityUsersUserNameDetailsRouteImport.update({ - id: '/users/$userName/details', - path: '/users/$userName/details', - getParentRoute: () => SecurityRoute, - } as any); const SecurityRolesRoleNameUpdateRoute = SecurityRolesRoleNameUpdateRouteImport.update({ id: '/roles/$roleName/update', @@ -540,6 +539,7 @@ export interface FileRoutesByFullPath { '/secrets/$id/edit': typeof SecretsIdEditRoute; '/security/acls/create': typeof SecurityAclsCreateRoute; '/security/roles/create': typeof SecurityRolesCreateRoute; + '/security/users/$userName': typeof SecurityUsersUserNameRoute; '/security/users/create': typeof SecurityUsersCreateRoute; '/shadowlinks/$name/edit': typeof ShadowlinksNameEditRoute; '/topics/$topicName/produce-record': typeof TopicsTopicNameProduceRecordRoute; @@ -567,7 +567,6 @@ export interface FileRoutesByFullPath { '/security/roles/$roleName/details': typeof SecurityRolesRoleNameDetailsRoute; '/security/roles/$roleName/edit': typeof SecurityRolesRoleNameEditRoute; '/security/roles/$roleName/update': typeof SecurityRolesRoleNameUpdateRoute; - '/security/users/$userName/details': typeof SecurityUsersUserNameDetailsRoute; '/schema-registry/subjects/$subjectName/': typeof SchemaRegistrySubjectsSubjectNameIndexRoute; } export interface FileRoutesByTo { @@ -617,6 +616,7 @@ export interface FileRoutesByTo { '/secrets/$id/edit': typeof SecretsIdEditRoute; '/security/acls/create': typeof SecurityAclsCreateRoute; '/security/roles/create': typeof SecurityRolesCreateRoute; + '/security/users/$userName': typeof SecurityUsersUserNameRoute; '/security/users/create': typeof SecurityUsersCreateRoute; '/shadowlinks/$name/edit': typeof ShadowlinksNameEditRoute; '/topics/$topicName/produce-record': typeof TopicsTopicNameProduceRecordRoute; @@ -644,7 +644,6 @@ export interface FileRoutesByTo { '/security/roles/$roleName/details': typeof SecurityRolesRoleNameDetailsRoute; '/security/roles/$roleName/edit': typeof SecurityRolesRoleNameEditRoute; '/security/roles/$roleName/update': typeof SecurityRolesRoleNameUpdateRoute; - '/security/users/$userName/details': typeof SecurityUsersUserNameDetailsRoute; '/schema-registry/subjects/$subjectName': typeof SchemaRegistrySubjectsSubjectNameIndexRoute; } export interface FileRoutesById { @@ -696,6 +695,7 @@ export interface FileRoutesById { '/secrets/$id/edit': typeof SecretsIdEditRoute; '/security/acls/create': typeof SecurityAclsCreateRoute; '/security/roles/create': typeof SecurityRolesCreateRoute; + '/security/users/$userName': typeof SecurityUsersUserNameRoute; '/security/users/create': typeof SecurityUsersCreateRoute; '/shadowlinks/$name/edit': typeof ShadowlinksNameEditRoute; '/topics/$topicName/produce-record': typeof TopicsTopicNameProduceRecordRoute; @@ -723,7 +723,6 @@ export interface FileRoutesById { '/security/roles/$roleName/details': typeof SecurityRolesRoleNameDetailsRoute; '/security/roles/$roleName/edit': typeof SecurityRolesRoleNameEditRoute; '/security/roles/$roleName/update': typeof SecurityRolesRoleNameUpdateRoute; - '/security/users/$userName/details': typeof SecurityUsersUserNameDetailsRoute; '/schema-registry/subjects/$subjectName/': typeof SchemaRegistrySubjectsSubjectNameIndexRoute; } export interface FileRouteTypes { @@ -776,6 +775,7 @@ export interface FileRouteTypes { | '/secrets/$id/edit' | '/security/acls/create' | '/security/roles/create' + | '/security/users/$userName' | '/security/users/create' | '/shadowlinks/$name/edit' | '/topics/$topicName/produce-record' @@ -803,7 +803,6 @@ export interface FileRouteTypes { | '/security/roles/$roleName/details' | '/security/roles/$roleName/edit' | '/security/roles/$roleName/update' - | '/security/users/$userName/details' | '/schema-registry/subjects/$subjectName/'; fileRoutesByTo: FileRoutesByTo; to: @@ -853,6 +852,7 @@ export interface FileRouteTypes { | '/secrets/$id/edit' | '/security/acls/create' | '/security/roles/create' + | '/security/users/$userName' | '/security/users/create' | '/shadowlinks/$name/edit' | '/topics/$topicName/produce-record' @@ -880,7 +880,6 @@ export interface FileRouteTypes { | '/security/roles/$roleName/details' | '/security/roles/$roleName/edit' | '/security/roles/$roleName/update' - | '/security/users/$userName/details' | '/schema-registry/subjects/$subjectName'; id: | '__root__' @@ -931,6 +930,7 @@ export interface FileRouteTypes { | '/secrets/$id/edit' | '/security/acls/create' | '/security/roles/create' + | '/security/users/$userName' | '/security/users/create' | '/shadowlinks/$name/edit' | '/topics/$topicName/produce-record' @@ -958,7 +958,6 @@ export interface FileRouteTypes { | '/security/roles/$roleName/details' | '/security/roles/$roleName/edit' | '/security/roles/$roleName/update' - | '/security/users/$userName/details' | '/schema-registry/subjects/$subjectName/'; fileRoutesById: FileRoutesById; } @@ -1386,6 +1385,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SecurityUsersCreateRouteImport; parentRoute: typeof SecurityRoute; }; + '/security/users/$userName': { + id: '/security/users/$userName'; + path: '/users/$userName'; + fullPath: '/security/users/$userName'; + preLoaderRoute: typeof SecurityUsersUserNameRouteImport; + parentRoute: typeof SecurityRoute; + }; '/security/roles/create': { id: '/security/roles/create'; path: '/roles/create'; @@ -1456,13 +1462,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SchemaRegistrySubjectsSubjectNameIndexRouteImport; parentRoute: typeof rootRouteImport; }; - '/security/users/$userName/details': { - id: '/security/users/$userName/details'; - path: '/users/$userName/details'; - fullPath: '/security/users/$userName/details'; - preLoaderRoute: typeof SecurityUsersUserNameDetailsRouteImport; - parentRoute: typeof SecurityRoute; - }; '/security/roles/$roleName/update': { id: '/security/roles/$roleName/update'; path: '/roles/$roleName/update'; @@ -1568,6 +1567,7 @@ interface SecurityRouteChildren { SecurityIndexRoute: typeof SecurityIndexRoute; SecurityAclsCreateRoute: typeof SecurityAclsCreateRoute; SecurityRolesCreateRoute: typeof SecurityRolesCreateRoute; + SecurityUsersUserNameRoute: typeof SecurityUsersUserNameRoute; SecurityUsersCreateRoute: typeof SecurityUsersCreateRoute; SecurityAclsIndexRoute: typeof SecurityAclsIndexRoute; SecurityPermissionsListIndexRoute: typeof SecurityPermissionsListIndexRoute; @@ -1578,13 +1578,13 @@ interface SecurityRouteChildren { SecurityRolesRoleNameDetailsRoute: typeof SecurityRolesRoleNameDetailsRoute; SecurityRolesRoleNameEditRoute: typeof SecurityRolesRoleNameEditRoute; SecurityRolesRoleNameUpdateRoute: typeof SecurityRolesRoleNameUpdateRoute; - SecurityUsersUserNameDetailsRoute: typeof SecurityUsersUserNameDetailsRoute; } const SecurityRouteChildren: SecurityRouteChildren = { SecurityIndexRoute: SecurityIndexRoute, SecurityAclsCreateRoute: SecurityAclsCreateRoute, SecurityRolesCreateRoute: SecurityRolesCreateRoute, + SecurityUsersUserNameRoute: SecurityUsersUserNameRoute, SecurityUsersCreateRoute: SecurityUsersCreateRoute, SecurityAclsIndexRoute: SecurityAclsIndexRoute, SecurityPermissionsListIndexRoute: SecurityPermissionsListIndexRoute, @@ -1595,7 +1595,6 @@ const SecurityRouteChildren: SecurityRouteChildren = { SecurityRolesRoleNameDetailsRoute: SecurityRolesRoleNameDetailsRoute, SecurityRolesRoleNameEditRoute: SecurityRolesRoleNameEditRoute, SecurityRolesRoleNameUpdateRoute: SecurityRolesRoleNameUpdateRoute, - SecurityUsersUserNameDetailsRoute: SecurityUsersUserNameDetailsRoute, }; const SecurityRouteWithChildren = SecurityRoute._addFileChildren( diff --git a/frontend/src/routes/security/acls/index.tsx b/frontend/src/routes/security/acls/index.tsx index c00192588b..c0ff2e013d 100644 --- a/frontend/src/routes/security/acls/index.tsx +++ b/frontend/src/routes/security/acls/index.tsx @@ -15,7 +15,7 @@ import { AclsTab } from '../../../components/pages/security/tabs/acls-tab'; export const Route = createFileRoute('/security/acls/')({ staticData: { - title: 'Security', + title: 'Role Details', }, component: AclsTab, }); diff --git a/frontend/src/routes/security/users/$userName/details.tsx b/frontend/src/routes/security/users/$userName.tsx similarity index 65% rename from frontend/src/routes/security/users/$userName/details.tsx rename to frontend/src/routes/security/users/$userName.tsx index 7770f9fa67..3eb199f36d 100644 --- a/frontend/src/routes/security/users/$userName/details.tsx +++ b/frontend/src/routes/security/users/$userName.tsx @@ -10,17 +10,16 @@ */ import { createFileRoute, useParams } from '@tanstack/react-router'; +import { UserDetailPage } from 'components/pages/security/user-detail-page'; -import UserDetailsPage from '../../../../components/pages/security/users/user-details'; - -export const Route = createFileRoute('/security/users/$userName/details')({ +export const Route = createFileRoute('/security/users/$userName')({ staticData: { title: 'User Details', }, - component: UserDetailsWrapper, + component: UserDetailWrapper, }); -function UserDetailsWrapper() { - const { userName } = useParams({ from: '/security/users/$userName/details' }); - return ; +function UserDetailWrapper() { + const { userName } = useParams({ from: '/security/users/$userName' }); + return ; } diff --git a/frontend/src/utils/user.ts b/frontend/src/utils/user.ts index 8cba520363..bb6c5da317 100644 --- a/frontend/src/utils/user.ts +++ b/frontend/src/utils/user.ts @@ -9,13 +9,36 @@ * by the Apache License, Version 2.0 */ +import { SASLMechanism } from 'protogen/redpanda/api/dataplane/v1/user_pb'; + /** * Shared user utilities for SASL mechanisms and validation * Used by both UserCreate and add-user-step */ -export const SASL_MECHANISMS = ['SCRAM-SHA-256', 'SCRAM-SHA-512'] as const; -export type SaslMechanism = (typeof SASL_MECHANISMS)[number]; +export { SASLMechanism }; + +export const SASL_MECHANISMS = [ + SASLMechanism.SASL_MECHANISM_SCRAM_SHA_256, + SASLMechanism.SASL_MECHANISM_SCRAM_SHA_512, +] as const; + +export const SASL_MECHANISM_OPTIONS: ReadonlyArray<{ id: SASLMechanism; name: string; description: string }> = [ + { + id: SASLMechanism.SASL_MECHANISM_SCRAM_SHA_256, + name: 'SCRAM-SHA-256', + description: 'Salted Challenge Response with SHA-256', + }, + { + id: SASLMechanism.SASL_MECHANISM_SCRAM_SHA_512, + name: 'SCRAM-SHA-512', + description: 'Salted Challenge Response with SHA-512 (recommended)', + }, +]; + +export function getSASLMechanismName(mechanism: SASLMechanism): string { + return SASL_MECHANISM_OPTIONS.find((o) => o.id === mechanism)?.name ?? 'SCRAM-SHA-256'; +} export const USERNAME_REGEX = /^[a-zA-Z0-9._@-]+$/; export const USERNAME_ERROR_MESSAGE = @@ -31,3 +54,19 @@ export function validateUsername(username: string): boolean { export function validatePassword(password: string): boolean { return Boolean(password) && password.length >= PASSWORD_MIN_LENGTH && password.length <= PASSWORD_MAX_LENGTH; } + +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; +} diff --git a/frontend/tests/mocks/sonner.ts b/frontend/tests/mocks/sonner.ts new file mode 100644 index 0000000000..de5c75ea44 --- /dev/null +++ b/frontend/tests/mocks/sonner.ts @@ -0,0 +1,11 @@ +// Stub for sonner in Node unit tests +// sonner inserts CSS at module init time which requires DOM APIs +export const toast = Object.assign(() => {}, { + success: () => {}, + error: () => {}, + info: () => {}, + warning: () => {}, + loading: () => {}, + dismiss: () => {}, +}); +export default toast; diff --git a/frontend/tests/shared/global-setup.mjs b/frontend/tests/shared/global-setup.mjs index f73668700e..0f555ef35c 100644 --- a/frontend/tests/shared/global-setup.mjs +++ b/frontend/tests/shared/global-setup.mjs @@ -88,7 +88,7 @@ async function startRedpandaContainer(network, state, ports) { '--kafka-addr', 'internal://0.0.0.0:9092,external://0.0.0.0:19092', '--advertise-kafka-addr', - 'internal://redpanda:9092,external://localhost:19092', + `internal://redpanda:9092,external://localhost:${ports.redpandaKafka}`, '--pandaproxy-addr', 'internal://0.0.0.0:8082,external://0.0.0.0:18082', '--advertise-pandaproxy-addr', @@ -405,15 +405,37 @@ export async function buildBackendImage(isEnterprise) { const isWorkspaceBuild = isEnterprise && existsSync(join(backendDir, 'go.work')); const workspaceDir = join(backendDir, '.e2e-workspace'); + let originalGoWork = null; if (isWorkspaceBuild) { console.log('Workspace build detected (go.work found)'); - const goWorkContent = readFileSync(join(backendDir, 'go.work'), 'utf-8'); + originalGoWork = readFileSync(join(backendDir, 'go.work'), 'utf-8'); + + // Detect and recover from a corrupted go.work left by a previous interrupted run. + // A corrupted go.work references .e2e-workspace/ paths that no longer exist on disk. + // In that case, use go.ci.work (the canonical workspace template) as the base instead. + const hasStaleWorkspacePaths = originalGoWork.includes('.e2e-workspace/'); + if (hasStaleWorkspacePaths) { + const ciWorkPath = join(backendDir, 'go.ci.work'); + if (existsSync(ciWorkPath)) { + console.warn( + ' go.work contains stale .e2e-workspace/ paths (leftover from previous run). Resetting from go.ci.work...' + ); + originalGoWork = readFileSync(ciWorkPath, 'utf-8'); + writeFileSync(join(backendDir, 'go.work'), originalGoWork); + console.log(' ✓ Reset go.work from go.ci.work'); + } else { + throw new Error( + 'go.work contains stale .e2e-workspace/ references but go.ci.work was not found to recover from. ' + + 'Please restore go.work manually.' + ); + } + } // Parse workspace module paths (skip "." which is the backend itself) const useRegex = /^\s+(\S+)\s*$/gm; const rewrittenPaths = []; let match; - while ((match = useRegex.exec(goWorkContent)) !== null) { + while ((match = useRegex.exec(originalGoWork)) !== null) { const modulePath = match[1]; if (modulePath === '.' || modulePath === 'use' || modulePath === '(' || modulePath === ')') continue; @@ -435,7 +457,7 @@ export async function buildBackendImage(isEnterprise) { // Rewrite go.work to use local paths if (rewrittenPaths.length > 0) { - let newGoWork = goWorkContent; + let newGoWork = originalGoWork; for (const { original, local } of rewrittenPaths) { newGoWork = newGoWork.replace(original, local); } @@ -460,6 +482,11 @@ export async function buildBackendImage(isEnterprise) { await execAsync(`rm -f "${tempDockerfile}"`).catch(() => {}); if (isWorkspaceBuild) { await execAsync(`rm -rf "${workspaceDir}"`).catch(() => {}); + // Restore original go.work so subsequent runs don't reference the deleted workspace dir + if (originalGoWork !== null) { + writeFileSync(join(backendDir, 'go.work'), originalGoWork); + console.log(' ✓ Restored original go.work'); + } } } @@ -716,7 +743,7 @@ async function startDestinationRedpandaContainer(network, state, ports) { '--kafka-addr', 'internal://0.0.0.0:9092,external://0.0.0.0:19093', '--advertise-kafka-addr', - 'internal://dest-cluster:9092,external://localhost:19093', + `internal://dest-cluster:9092,external://localhost:${ports.destRedpandaKafka}`, '--pandaproxy-addr', 'internal://0.0.0.0:8082,external://0.0.0.0:18092', '--advertise-pandaproxy-addr', diff --git a/frontend/tests/test-variant-console-enterprise/debug-bundle/debug-bundle.spec.ts b/frontend/tests/test-variant-console-enterprise/debug-bundle/debug-bundle.spec.ts index 8c58004d09..6bc1fe5bf1 100644 --- a/frontend/tests/test-variant-console-enterprise/debug-bundle/debug-bundle.spec.ts +++ b/frontend/tests/test-variant-console-enterprise/debug-bundle/debug-bundle.spec.ts @@ -110,15 +110,15 @@ test.describe('Debug Bundle - Generation Progress', () => { test.describe('Debug Bundle - Download and Deletion', () => { test('should display download link when bundle is ready', async ({ page }) => { const debugBundlePage = new DebugBundlePage(page); - await debugBundlePage.goto(); + // Use skipCleanup to avoid resetting form state — we just want to check current state + await debugBundlePage.goto({ skipCleanup: true }); - // Check if there's a download link available - const downloadLink = page.getByRole('link', { name: /download/i }); - const hasDownload = await downloadLink.isVisible({ timeout: 2000 }).catch(() => false); + // The download mechanism is a button (not an anchor), with text matching the filename + const downloadButton = page.getByRole('button', { name: /debug-bundle\.zip/i }); + const hasDownload = await downloadButton.isVisible({ timeout: 2000 }).catch(() => false); if (hasDownload) { - await expect(downloadLink).toBeVisible(); - await expect(downloadLink).toHaveAttribute('href', /\/api\/debug_bundle\/files\//); + await expect(downloadButton).toBeVisible(); } else { test.skip(); } diff --git a/frontend/tests/test-variant-console-enterprise/license.spec.ts b/frontend/tests/test-variant-console-enterprise/license.spec.ts index 3dca298d5b..8ffc529308 100644 --- a/frontend/tests/test-variant-console-enterprise/license.spec.ts +++ b/frontend/tests/test-variant-console-enterprise/license.spec.ts @@ -7,8 +7,11 @@ test.describe('Licenses', () => { }); const licensingEl = page.locator('[data-testid="overview-license-name"]'); - // Assert that at least one element is visible and contains the text - await expect(licensingEl.filter({ hasText: 'Console Enterprise' }).first()).toBeVisible(); + // When multiple licenses of the same type exist (e.g., both a Redpanda Core and a Console + // trial license), the source prefix is omitted and only the type is shown (e.g., "Trial"). + // When only one license of a type exists, the source is included (e.g., "Console Enterprise"). + // Accept any enterprise-grade license label regardless of how many licenses are present. + await expect(licensingEl.filter({ hasText: /Enterprise|Trial/ }).first()).toBeVisible(); }); test('should be able to upload new license', async ({ page }) => { diff --git a/frontend/tests/test-variant-console-enterprise/users.spec.ts b/frontend/tests/test-variant-console-enterprise/users.spec.ts index bb09b51d9d..e0c5635375 100644 --- a/frontend/tests/test-variant-console-enterprise/users.spec.ts +++ b/frontend/tests/test-variant-console-enterprise/users.spec.ts @@ -29,19 +29,13 @@ test.describe('Users', () => { await page.goto('/security/users/', { waitUntil: 'domcontentloaded', }); - await page.getByPlaceholder('Filter by name').fill(`user-${r}-regexp-[1,2]`); + await page.getByTestId('search-field-input').getByRole('textbox').fill(`user-${r}-regexp-[1,2]`); // Wait for nuqs to push the filter into the URL (TanStack Router navigate is async) await page.waitForURL(/[?&]q=/); - await expect( - page.getByTestId('data-table-cell').locator(`a[href='/security/users/${userName1}/details']`) - ).toHaveCount(1); - await expect( - page.getByTestId('data-table-cell').locator(`a[href='/security/users/${userName2}/details']`) - ).toHaveCount(1); - await expect( - page.getByTestId('data-table-cell').locator(`a[href='/security/users/${userName3}/details']`) - ).toHaveCount(0); + await expect(page.getByRole('link', { name: userName1, exact: true })).toBeVisible(); + await expect(page.getByRole('link', { name: userName2, exact: true })).toBeVisible(); + await expect(page.getByRole('link', { name: userName3, exact: true })).not.toBeVisible(); await securityPage.deleteUser(userName1); await securityPage.deleteUser(userName2); diff --git a/frontend/tests/test-variant-console/acls/permissions-list.spec.ts b/frontend/tests/test-variant-console/acls/permissions-list.spec.ts index ad28d7849f..a020efaa45 100644 --- a/frontend/tests/test-variant-console/acls/permissions-list.spec.ts +++ b/frontend/tests/test-variant-console/acls/permissions-list.spec.ts @@ -29,8 +29,8 @@ async function createScramUser(page: Page, username: string) { await expect(page.getByTestId('create-user-button')).toBeEnabled({ timeout: 10_000 }); await page.getByTestId('create-user-button').click(); await page.getByTestId('create-user-name').fill(username); - await page.getByRole('button', { name: 'Create' }).click(); - await expect(page.getByRole('heading', { name: 'User created successfully' })).toBeVisible(); + await page.getByRole('button', { name: 'Create', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'User Created' })).toBeVisible(); await page.getByRole('button', { name: 'Done' }).click(); await expect(page).toHaveURL('/security/users'); } diff --git a/frontend/tests/test-variant-console/acls/user-management.spec.ts b/frontend/tests/test-variant-console/acls/user-management.spec.ts index 924b47c5b7..9f88f0c5d3 100644 --- a/frontend/tests/test-variant-console/acls/user-management.spec.ts +++ b/frontend/tests/test-variant-console/acls/user-management.spec.ts @@ -19,7 +19,7 @@ test.describe('ACL User Management', () => { test('should create a new user with special characters in password', async ({ page }) => { await test.step('1. Click Create user button to open user creation dialog', async () => { await page.getByTestId('create-user-button').click(); - await expect(page).toHaveURL('/security/users/create'); + await expect(page.getByRole('dialog')).toBeVisible(); }); const timestamp = Date.now(); @@ -42,7 +42,7 @@ test.describe('ACL User Management', () => { await test.step('5. Verify success message', async () => { await expect(page.getByTestId('user-created-successfully')).toBeVisible(); - await expect(page.getByText(username)).toBeVisible(); + await expect(page.getByRole('dialog').getByText(username)).toBeVisible(); }); await test.step('6. Return to users list', async () => { @@ -61,7 +61,7 @@ test.describe('ACL User Management', () => { await test.step('1. Create a new user', async () => { await page.getByTestId('create-user-button').click(); - await expect(page).toHaveURL('/security/users/create'); + await expect(page.getByRole('dialog')).toBeVisible(); await page.getByTestId('create-user-name').fill(username); await page.getByTestId('create-user-submit').click(); await expect(page.getByTestId('user-created-successfully')).toBeVisible(); @@ -246,24 +246,21 @@ test.describe('ACL User Management', () => { }); await test.step('3. Verify user detail page loads', async () => { - await expect(page).toHaveURL(`/security/users/${username}/details`); - await expect(page.getByRole('heading', { name: `User: ${username}`, exact: true })).toBeVisible(); + await expect(page).toHaveURL(`/security/users/${username}`); + await expect(page.getByRole('heading', { name: username, exact: true }).first()).toBeVisible(); }); await test.step('4. Verify user information section', async () => { - await expect(page.getByText('User information')).toBeVisible(); - await expect(page.getByText('Username')).toBeVisible(); await expect(page.getByText(username, { exact: true }).first()).toBeVisible(); - await expect(page.getByText('Passwords cannot be viewed')).toBeVisible(); }); await test.step('5. Verify sections are visible', async () => { - await expect(page.getByRole('heading', { name: 'Roles' })).toBeVisible(); - await expect(page.getByRole('heading', { name: /ACLs/ })).toBeVisible(); + await expect(page.getByText('Roles').first()).toBeVisible(); + await expect(page.getByText('ACLs').first()).toBeVisible(); }); await test.step('6. Navigate back using breadcrumb', async () => { - await page.getByRole('link', { name: 'Users' }).click(); + await page.getByRole('link', { name: 'Users' }).first().click(); await expect(page).toHaveURL('/security/users'); }); @@ -290,21 +287,21 @@ test.describe('ACL User Management', () => { }); await test.step('3. Verify URL and heading', async () => { - await expect(page).toHaveURL(`/security/users/${username}/details`); - await expect(page.getByRole('heading', { name: `User: ${username}`, exact: true })).toBeVisible(); + await expect(page).toHaveURL(`/security/users/${username}`); + await expect(page.getByRole('heading', { name: username, exact: true }).first()).toBeVisible(); }); - await test.step('4. Verify User information section shows correct username', async () => { + await test.step('4. Verify correct username is shown', async () => { await expect(page.getByText('test-user-123', { exact: false })).not.toBeVisible(); - await expect(page.getByText('User information')).toBeVisible(); + await expect(page.getByText(username, { exact: true }).first()).toBeVisible(); }); - await test.step('5. Verify Delete user button is available', async () => { - await expect(page.getByRole('button', { name: 'Delete user' })).toBeVisible(); + await test.step('5. Verify Delete User button is available', async () => { + await expect(page.getByRole('button', { name: 'Delete User' })).toBeVisible(); }); await test.step('6. Navigate back to list using breadcrumb', async () => { - await page.getByRole('link', { name: 'Users' }).click(); + await page.getByRole('link', { name: 'Users' }).first().click(); await expect(page).toHaveURL('/security/users'); }); }); @@ -380,17 +377,16 @@ test.describe('ACL User Management', () => { await test.step('3. Navigate to user detail page', async () => { await page.getByRole('link', { name: username, exact: true }).click(); - await expect(page).toHaveURL(`/security/users/${username}/details`); - await expect(page.getByRole('heading', { name: `User: ${username}`, exact: true })).toBeVisible(); + await expect(page).toHaveURL(`/security/users/${username}`); + await expect(page.getByRole('heading', { name: username, exact: true }).first()).toBeVisible(); }); - await test.step('4. Click Delete user button', async () => { - await page.getByRole('button', { name: 'Delete user' }).click(); + await test.step('4. Click Delete User button', async () => { + await page.getByRole('button', { name: 'Delete User' }).click(); }); await test.step('5. Confirm deletion', async () => { - await page.getByTestId('txt-confirmation-delete').fill(username); - await page.getByRole('button', { name: 'Delete' }).click(); + await page.getByRole('button', { name: 'Delete User' }).last().click(); }); await test.step('6. Verify redirect to users list', async () => { diff --git a/frontend/tests/test-variant-console/utils/security-page.ts b/frontend/tests/test-variant-console/utils/security-page.ts index 5469fd8b62..290676e48c 100644 --- a/frontend/tests/test-variant-console/utils/security-page.ts +++ b/frontend/tests/test-variant-console/utils/security-page.ts @@ -16,7 +16,7 @@ export class SecurityPage { } async goToUserDetails(username: string) { - await this.page.goto(`/security/users/${username}/details`); + await this.page.goto(`/security/users/${username}`); } async goToCreateUser() { @@ -38,19 +38,18 @@ export class SecurityPage { } async submitUserCreation() { - await this.page.getByRole('button').getByText('Create').click(); + await this.page.getByTestId('create-user-submit').click(); } /** * User deletion operations */ async clickDeleteButton() { - await this.page.getByRole('button').getByText('Delete').click(); + await this.page.getByRole('button', { name: 'Delete User' }).click(); } - async confirmUserDeletion(username: string) { - await this.page.getByPlaceholder(`Type "${username}" to confirm`).fill(username); - await this.page.getByTestId('test-delete-item').click(); + async confirmUserDeletion(_username: string) { + await this.page.getByRole('button', { name: 'Delete User' }).last().click(); } /** @@ -65,7 +64,7 @@ export class SecurityPage { return await test.step('Create user', async () => { await this.goToUsersList(); await this.clickCreateUserButton(); - await this.page.waitForURL('/security/users/create'); + await this.page.getByRole('dialog').waitFor({ state: 'visible' }); await this.fillUsername(username); await this.submitUserCreation(); await this.page.getByTestId('user-created-successfully').waitFor({ state: 'visible' }); diff --git a/frontend/vitest.config.unit.mts b/frontend/vitest.config.unit.mts index 39bfe81052..56bde8c57d 100644 --- a/frontend/vitest.config.unit.mts +++ b/frontend/vitest.config.unit.mts @@ -33,6 +33,8 @@ export default defineConfig(({ mode }) => { '@monaco-editor/react': new URL('./tests/mocks/monaco-editor-react.ts', import.meta.url).pathname, // @redpanda-data/ui has CSS that can't be parsed in Node '@redpanda-data/ui': new URL('./tests/mocks/redpanda-ui.ts', import.meta.url).pathname, + // sonner inserts CSS via document.getElementsByTagName at module init + sonner: new URL('./tests/mocks/sonner.ts', import.meta.url).pathname, }, }, };