diff --git a/frontend/src/components/pages/acls/user-create-form-schema.ts b/frontend/src/components/pages/acls/user-create-form-schema.ts new file mode 100644 index 000000000..b06c23e54 --- /dev/null +++ b/frontend/src/components/pages/acls/user-create-form-schema.ts @@ -0,0 +1,46 @@ +/** + * 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 { CreateUserRequest_UserSchema } from 'protogen/redpanda/api/dataplane/v1/user_pb'; +import { protoToZodSchema } from 'utils/proto-constraints'; +import { SASL_MECHANISMS, USERNAME_ERROR_MESSAGE, USERNAME_REGEX } from 'utils/user'; +import { z } from 'zod'; + +/** + * Base schema derived from proto CreateUserRequest.User constraints: + * - name: string, min_len=1, max_len=128 + * - password: string, min_len=3, max_len=128 + * - mechanism: enum (numeric) + */ +const protoSchema = protoToZodSchema(CreateUserRequest_UserSchema); + +export const createUserFormSchema = (existingUsers: string[]) => + z.object({ + // Proto provides min(1) + max(128), we add regex and uniqueness check + username: (protoSchema.shape.name as z.ZodString) + .regex(USERNAME_REGEX, USERNAME_ERROR_MESSAGE) + .refine((val) => !existingUsers.includes(val), 'User already exists'), + // Proto provides min(3) + max(128) + password: protoSchema.shape.password as z.ZodString, + // Keep as string enum for form UX (proto uses numeric enum) + mechanism: z.enum(SASL_MECHANISMS), + // Not in proto — frontend-only field for role assignment + roles: z.array(z.string()).default([]), + }); + +export type UserCreateFormValues = z.infer>; + +export const initialValues: UserCreateFormValues = { + username: '', + password: '', + mechanism: 'SCRAM-SHA-256', + roles: [], +}; diff --git a/frontend/src/components/pages/acls/user-create.test.tsx b/frontend/src/components/pages/acls/user-create.test.tsx new file mode 100644 index 000000000..c25455933 --- /dev/null +++ b/frontend/src/components/pages/acls/user-create.test.tsx @@ -0,0 +1,233 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import userEvent from '@testing-library/user-event'; +import { fireEvent, renderWithFileRoutes, screen, waitFor } from 'test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +// Mock hooks +vi.mock('react-query/api/user', () => ({ + useLegacyListUsersQuery: vi.fn(), + useCreateUserMutation: vi.fn(), + getSASLMechanism: vi.fn(() => 1), +})); + +vi.mock('react-query/api/security', () => ({ + useListRolesQuery: vi.fn(), + useUpdateRoleMembershipMutation: vi.fn(), +})); + +vi.mock('state/supported-features', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Features: { rolesApi: false }, + }; +}); + +vi.mock('state/ui-state', () => ({ + uiState: { pageTitle: '', pageBreadcrumbs: [] }, +})); + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +// Mock Radix tooltip — avoids context mismatch between radix-ui and @radix-ui/react-tooltip +vi.mock('components/redpanda-ui/components/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: () => null, + TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +import React from 'react'; +import { useListRolesQuery, useUpdateRoleMembershipMutation } from 'react-query/api/security'; +import { useCreateUserMutation, useLegacyListUsersQuery } from 'react-query/api/user'; + +import UserCreatePage from './user-create'; + +// jsdom polyfills +global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +}; +Element.prototype.scrollIntoView = vi.fn(); + +const renderPage = () => renderWithFileRoutes(); + +/** Find the password inside the password Field wrapper. */ +const getPasswordInput = () => { + const field = screen.getByTestId('create-user-password'); + const input = field.querySelector('input'); + if (!input) { + throw new Error('Password input not found'); + } + return input; +}; + +describe('UserCreatePage', () => { + const mockCreateUserAsync = vi.fn().mockResolvedValue({}); + + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(useLegacyListUsersQuery).mockReturnValue({ + data: { users: [{ name: 'existing-user' }] }, + isFetching: false, + error: null, + } as any); + + vi.mocked(useCreateUserMutation).mockReturnValue({ + mutateAsync: mockCreateUserAsync, + isPending: false, + } as any); + + vi.mocked(useUpdateRoleMembershipMutation).mockReturnValue({ + mutateAsync: vi.fn().mockResolvedValue({}), + isPending: false, + } as any); + + vi.mocked(useListRolesQuery).mockReturnValue({ + data: { roles: [] }, + } as any); + }); + + describe('Username validation', () => { + test('shows error when username exceeds 128 characters', async () => { + renderPage(); + const input = await screen.findByTestId('create-user-name'); + + fireEvent.change(input, { target: { value: 'a'.repeat(129) } }); + + await waitFor(() => { + expect(screen.getByText('Must not exceed 128 characters')).toBeInTheDocument(); + }); + }); + + test('accepts username at exactly 128 characters without max-length error', async () => { + renderPage(); + const input = await screen.findByTestId('create-user-name'); + + fireEvent.change(input, { target: { value: 'a'.repeat(128) } }); + + await waitFor(() => { + expect(screen.queryByText('Must not exceed 128 characters')).not.toBeInTheDocument(); + }); + }); + + test('shows error for invalid characters (spaces)', async () => { + const user = userEvent.setup(); + renderPage(); + const input = await screen.findByTestId('create-user-name'); + + await user.type(input, 'user name'); + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent(/Must not contain any whitespace/); + }); + }); + + test('shows error for duplicate username', async () => { + const user = userEvent.setup(); + renderPage(); + const input = await screen.findByTestId('create-user-name'); + + await user.type(input, 'existing-user'); + + await waitFor(() => { + expect(screen.getByText('User already exists')).toBeInTheDocument(); + }); + }); + }); + + describe('Password validation', () => { + test('shows error when password is shorter than 3 characters', async () => { + const user = userEvent.setup(); + renderPage(); + + await screen.findByTestId('create-user-name'); + const passwordInput = getPasswordInput(); + + // Clear the auto-generated password and type a short one + await user.clear(passwordInput); + await user.type(passwordInput, 'ab'); + + await waitFor(() => { + expect(screen.getByText('Must be at least 3 characters')).toBeInTheDocument(); + }); + }); + + test('shows error when password exceeds 128 characters', async () => { + renderPage(); + + await screen.findByTestId('create-user-name'); + const passwordInput = getPasswordInput(); + + fireEvent.change(passwordInput, { target: { value: 'a'.repeat(129) } }); + + await waitFor(() => { + expect(screen.getByText('Must not exceed 128 characters')).toBeInTheDocument(); + }); + }); + }); + + describe('Form submission', () => { + test('submit button is disabled when username is empty', async () => { + renderPage(); + + await screen.findByTestId('create-user-name'); + + expect(screen.getByTestId('create-user-submit')).toBeDisabled(); + }); + + test('creates user with valid form data', async () => { + const user = userEvent.setup(); + renderPage(); + + const usernameInput = await screen.findByTestId('create-user-name'); + await user.type(usernameInput, 'testuser'); + + const submitButton = screen.getByTestId('create-user-submit'); + await waitFor(() => { + expect(submitButton).toBeEnabled(); + }); + + await user.click(submitButton); + + await waitFor(() => { + expect(mockCreateUserAsync).toHaveBeenCalledTimes(1); + }); + }); + + test('shows confirmation page after successful creation', async () => { + const user = userEvent.setup(); + renderPage(); + + const usernameInput = await screen.findByTestId('create-user-name'); + await user.type(usernameInput, 'newuser'); + + const submitButton = screen.getByTestId('create-user-submit'); + await waitFor(() => { + expect(submitButton).toBeEnabled(); + }); + + await user.click(submitButton); + + await waitFor(() => { + expect(screen.getByText('User created successfully')).toBeInTheDocument(); + }); + + expect(screen.getByText('newuser')).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/pages/acls/user-create.tsx b/frontend/src/components/pages/acls/user-create.tsx index fe5719d8b..c89111522 100644 --- a/frontend/src/components/pages/acls/user-create.tsx +++ b/frontend/src/components/pages/acls/user-create.tsx @@ -9,455 +9,364 @@ * by the Apache License, Version 2.0 */ +import { create } from '@bufbuild/protobuf'; +import { ConnectError } from '@connectrpc/connect'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Link, useNavigate } from '@tanstack/react-router'; +import { Loader2, RefreshCcw } from 'lucide-react'; +import { UpdateRoleMembershipRequestSchema } from 'protogen/redpanda/api/dataplane/v1/security_pb'; +import { CreateUserRequestSchema } from 'protogen/redpanda/api/dataplane/v1/user_pb'; +import { useEffect, useMemo, useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { useListRolesQuery, useUpdateRoleMembershipMutation } from 'react-query/api/security'; +import { getSASLMechanism, useCreateUserMutation, useLegacyListUsersQuery } from 'react-query/api/user'; +import { toast } from 'sonner'; +import { Features } from 'state/supported-features'; +import { uiState } from 'state/ui-state'; +import { formatToastErrorMessageGRPC } from 'utils/toast.utils'; import { - Alert, - AlertIcon, - Box, - Button, - Checkbox, - CopyButton, - createStandaloneToast, - Flex, - FormField, - Grid, - Heading, - IconButton, - Input, - isMultiValue, - PasswordInput, - redpandaTheme, - redpandaToastOptions, - Select, - Text, - Tooltip, -} from '@redpanda-data/ui'; -import { Link } from '@tanstack/react-router'; -import { RotateCwIcon } from 'components/icons'; -import { useCallback, useEffect, useMemo, useState } from 'react'; - -import { useListRolesQuery } from '../../../react-query/api/security'; -import { invalidateUsersCache, useLegacyListUsersQuery } from '../../../react-query/api/user'; -import { appGlobal } from '../../../state/app-global'; -import { api, rolesApi } from '../../../state/backend-api'; -import { AclRequestDefault } from '../../../state/rest-interfaces'; -import { Features } from '../../../state/supported-features'; -import { uiState } from '../../../state/ui-state'; -import { + generatePassword, PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, SASL_MECHANISMS, type SaslMechanism, - validatePassword, - validateUsername, -} from '../../../utils/user'; -import PageContent from '../../misc/page-content'; -import { SingleSelect } from '../../misc/select'; + USERNAME_MAX_LENGTH, +} from 'utils/user'; -const { ToastContainer, toast } = createStandaloneToast({ - theme: redpandaTheme, - defaultOptions: { - ...redpandaToastOptions.defaultOptions, - isClosable: false, - duration: 2000, - }, -}); +import { createUserFormSchema, initialValues, type UserCreateFormValues } from './user-create-form-schema'; +import PageContent from '../../misc/page-content'; +import { Button } from '../../redpanda-ui/components/button'; +import { Checkbox } from '../../redpanda-ui/components/checkbox'; +import { CopyButton } from '../../redpanda-ui/components/copy-button'; +import { Field, FieldDescription, FieldError, FieldLabel } from '../../redpanda-ui/components/field'; +import { Input } from '../../redpanda-ui/components/input'; +import { Label } from '../../redpanda-ui/components/label'; +import { + MultiSelect, + MultiSelectContent, + MultiSelectEmpty, + MultiSelectItem, + MultiSelectList, + MultiSelectTrigger, + MultiSelectValue, +} from '../../redpanda-ui/components/multi-select'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../redpanda-ui/components/select'; +import { Tooltip, TooltipContent, TooltipTrigger } from '../../redpanda-ui/components/tooltip'; +import { Heading, Text } from '../../redpanda-ui/components/typography'; + +type SubmittedData = { + username: string; + password: string; + mechanism: SaslMechanism; +}; const UserCreatePage = () => { - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(() => generatePassword(30, false)); - const [mechanism, setMechanism] = useState('SCRAM-SHA-256'); + const navigate = useNavigate(); + const [submittedData, setSubmittedData] = useState(null); const [generateWithSpecialChars, setGenerateWithSpecialChars] = useState(false); - const [step, setStep] = useState<'CREATE_USER' | 'CREATE_USER_CONFIRMATION'>('CREATE_USER'); - const [isCreating, setIsCreating] = useState(false); - const [selectedRoles, setSelectedRoles] = useState([]); const { data: usersData } = useLegacyListUsersQuery(); - const users = usersData?.users?.map((u) => u.name) ?? []; + const existingUsers = useMemo(() => usersData?.users?.map((u) => u.name) ?? [], [usersData]); - const isValidUsername = validateUsername(username); - const isValidPassword = validatePassword(password); + const { mutateAsync: createUser, isPending: isCreating } = useCreateUserMutation(); + const { mutateAsync: updateRoleMembership } = useUpdateRoleMembershipMutation(); + + const form = useForm({ + resolver: zodResolver(createUserFormSchema(existingUsers)), + defaultValues: { + ...initialValues, + password: generatePassword(30, false), + }, + mode: 'onChange', + }); useEffect(() => { uiState.pageTitle = 'Create user'; uiState.pageBreadcrumbs = []; uiState.pageBreadcrumbs.push({ title: 'Access Control', linkTo: '/security' }); uiState.pageBreadcrumbs.push({ title: 'Create user', linkTo: '/security/users/create' }); - - const refreshData = async () => { - if (api.userData !== null && api.userData !== undefined && !api.userData.canListAcls) { - return; - } - await Promise.allSettled([api.refreshAcls(AclRequestDefault, true), api.refreshServiceAccounts()]); - }; - - refreshData().catch(() => { - // Silently ignore refresh errors - }); - appGlobal.onRefresh = () => - refreshData().catch(() => { - // Silently ignore refresh errors - }); }, []); - const onCreateUser = useCallback(async (): Promise => { + const onSubmit = async (values: UserCreateFormValues) => { try { - setIsCreating(true); - await api.createServiceAccount({ - username, - password, - mechanism, + const request = create(CreateUserRequestSchema, { + user: { + name: values.username, + password: values.password, + mechanism: getSASLMechanism(values.mechanism), + }, }); + await createUser(request); + + // Assign roles + const rolePromises = values.roles.map((roleName) => { + const membership = create(UpdateRoleMembershipRequestSchema, { + roleName, + add: [{ principal: values.username }], + }); + return updateRoleMembership(membership); + }); + await Promise.allSettled(rolePromises); - if (api.userData !== null && api.userData !== undefined && !api.userData.canListAcls) { - return false; - } - await Promise.allSettled([api.refreshAcls(AclRequestDefault, true), invalidateUsersCache()]); - - const roleAddPromises: Promise[] = []; - for (const r of selectedRoles) { - roleAddPromises.push(rolesApi.updateRoleMembership(r, [username], [], false)); - } - await Promise.allSettled(roleAddPromises); - - setStep('CREATE_USER_CONFIRMATION'); - } catch (err) { - toast({ - status: 'error', - duration: null, - isClosable: true, - title: 'Failed to create user', - description: String(err), + setSubmittedData({ + username: values.username, + password: values.password, + mechanism: values.mechanism, }); - } finally { - setIsCreating(false); + } catch (error) { + const connectError = ConnectError.from(error); + toast.error(formatToastErrorMessageGRPC({ error: connectError, action: 'create', entity: 'user' })); } - return true; - }, [username, password, mechanism, selectedRoles]); - - const onCancel = () => appGlobal.historyPush('/security/users'); - - const state = { - username, - setUsername, - password, - setPassword, - mechanism, - setMechanism, - generateWithSpecialChars, - setGenerateWithSpecialChars, - isCreating, - isValidUsername, - isValidPassword, - selectedRoles, - setSelectedRoles, - users, }; - return ( - <> - + const onCancel = () => navigate({ to: '/security/$tab', params: { tab: 'users' } }); + if (submittedData) { + return ( - - {step === 'CREATE_USER' ? ( - - ) : ( - - )} - + - - ); -}; - -export default UserCreatePage; - -type CreateUserModalProps = { - state: { - username: string; - setUsername: (v: string) => void; - password: string; - setPassword: (v: string) => void; - mechanism: SaslMechanism; - setMechanism: (v: SaslMechanism) => void; - generateWithSpecialChars: boolean; - setGenerateWithSpecialChars: (v: boolean) => void; - isCreating: boolean; - isValidUsername: boolean; - isValidPassword: boolean; - selectedRoles: string[]; - setSelectedRoles: (v: string[]) => void; - users: string[]; - }; - onCreateUser: () => Promise; - onCancel: () => void; -}; - -const CreateUserModal = ({ state, onCreateUser, onCancel }: CreateUserModalProps) => { - const userAlreadyExists = state.users.includes(state.username); - - const errorText = useMemo(() => { - if (!state.isValidUsername) { - return 'The username contains invalid characters. Use only letters, numbers, dots, underscores, at symbols, and hyphens.'; - } - - if (userAlreadyExists) { - return 'User already exists'; - } - }, [state.isValidUsername, userAlreadyExists]); + ); + } return ( - - - 0} - label="Username" - showRequiredIndicator - > - { - state.setUsername(v.target.value); - }} - placeholder="Username" - spellCheck={false} - value={state.username} - width="100%" - /> - - - - - - { - state.setPassword(e.target.value); - }} - value={state.password} + +
+ ( + + Username + + + Must not contain any whitespace. Dots, hyphens and underscores may be used. Maximum{' '} + {USERNAME_MAX_LENGTH} characters. + + {Boolean(fieldState.invalid) && } + + )} + /> - - } - onClick={() => { - state.setPassword(generatePassword(30, state.generateWithSpecialChars)); - }} - variant="ghost" - /> - - - - - - { - state.setGenerateWithSpecialChars(e.target.checked); - state.setPassword(generatePassword(30, e.target.checked)); - }} - > - Generate with special characters - - - + ( + + Password +
+
+ + + + + + Generate new random password + + + + + + Copy password + +
+
+ { + const useSpecial = checked === true; + setGenerateWithSpecialChars(useSpecial); + form.setValue('password', generatePassword(30, useSpecial), { shouldValidate: true }); + }} + /> + +
+
+ + Must be at least {PASSWORD_MIN_LENGTH} characters and should not exceed {PASSWORD_MAX_LENGTH}{' '} + characters. + + {Boolean(fieldState.invalid) && } +
+ )} + /> - - - onChange={(e) => { - state.setMechanism(e); - }} - options={SASL_MECHANISMS.map((mechanism) => ({ - value: mechanism, - label: mechanism, - }))} - value={state.mechanism} - /> - + ( + + SASL mechanism + + {Boolean(fieldState.invalid) && } + + )} + /> {Boolean(Features.rolesApi) && ( - - - + ( + + Assign roles + + + Assign roles to this user. This is optional and can be changed later. + + + )} + /> )} - - - - - - +
+ + +
+ +
); }; -type CreateUserConfirmationModalProps = { +export default UserCreatePage; + +type CreateUserConfirmationProps = { username: string; password: string; mechanism: SaslMechanism; - closeModal: () => void; + onDone: () => void; }; -const CreateUserConfirmationModal = ({ - username, - password, - mechanism, - closeModal, -}: CreateUserConfirmationModalProps) => ( - <> - - User created successfully - +const CreateUserConfirmation = ({ username, password, mechanism, onDone }: CreateUserConfirmationProps) => ( +
+ User created successfully - - - You will not be able to view this password again. Make sure that it is copied and saved. - +
+ You will not be able to view this password again. Make sure that it is copied and saved. +
- - +
+ Username - - - - - {username} - - - - - - - - - + +
+ {username} + + + + + Copy username + +
+ + Password - - - - - - - - - - - - + +
+ + + + + + Copy password + +
+ + Mechanism - - - - {mechanism} - - - - - - -
+ +
+ + - - +
+
); +/** + * Role selector using MultiSelect. + * Exported for use in user-edit-modals.tsx. + */ export const StateRoleSelector = ({ roles, setRoles }: { roles: string[]; setRoles: (roles: string[]) => void }) => { - const [searchValue, setSearchValue] = useState(''); const { data: { roles: allRoles }, } = useListRolesQuery(); - const availableRoles = (allRoles ?? []) - .filter((r: { name: string }) => !roles.includes(r.name)) - .map((r: { name: string }) => ({ value: r.name })); + + const availableOptions = useMemo( + () => (allRoles ?? []).map((r: { name: string }) => ({ value: r.name, label: r.name })), + [allRoles] + ); return ( - - - - inputValue={searchValue} - isMulti={true} - noOptionsMessage={() => 'No roles found'} - onChange={(val) => { - if (val && isMultiValue(val)) { - setRoles([...val.map((selectedRole) => selectedRole.value)]); - setSearchValue(''); - } - }} - onInputChange={setSearchValue} - options={availableRoles} - // TODO: Selecting an entry triggers onChange properly. - // But there is no way to prevent the component from showing no value as intended - // Seems to be a bug with the component. - // On 'undefined' it should handle selection on its own (this works properly) - // On 'null' the component should NOT show any selection after a selection has been made (does not work!) - // The override doesn't work either (isOptionSelected={()=>false}) - placeholder="Select roles..." - value={roles.map((r) => ({ value: r }))} - /> - - +
+ + + + + + + {availableOptions.map((option) => ( + + {option.label} + + ))} + + No roles found + + +
); }; - -export function generatePassword(length: number, allowSpecialChars: boolean): string { - if (length <= 0) { - return ''; - } - - const lowercase = 'abcdefghijklmnopqrstuvwxyz'; - const uppercase = lowercase.toUpperCase(); - const numbers = '0123456789'; - const special = '.,&_+|[]/-()'; - - let alphabet = lowercase + uppercase + numbers; - if (allowSpecialChars) { - alphabet += special; - } - - const randomValues = new Uint32Array(length); - crypto.getRandomValues(randomValues); - - let result = ''; - for (const n of randomValues) { - const index = n % alphabet.length; - const sym = alphabet[index]; - - result += sym; - } - - return result; -} diff --git a/frontend/src/components/pages/acls/user-edit-modals.tsx b/frontend/src/components/pages/acls/user-edit-modals.tsx index 9ec96a0b0..27106565f 100644 --- a/frontend/src/components/pages/acls/user-edit-modals.tsx +++ b/frontend/src/components/pages/acls/user-edit-modals.tsx @@ -25,12 +25,13 @@ import { import { SASLMechanism, UpdateUserRequestSchema } from 'protogen/redpanda/api/dataplane/v1/user_pb'; import { useEffect, useState } from 'react'; -import { generatePassword, StateRoleSelector } from './user-create'; +import { StateRoleSelector } from './user-create'; import { useListRolesQuery, useUpdateRoleMembershipMutation } from '../../../react-query/api/security'; import { useUpdateUserMutationWithToast } from '../../../react-query/api/user'; import { rolesApi } from '../../../state/backend-api'; import { Features } from '../../../state/supported-features'; import { formatToastErrorMessageGRPC, showToast } from '../../../utils/toast.utils'; +import { generatePassword } from '../../../utils/user'; import { SingleSelect } from '../../misc/select'; type ChangePasswordModalProps = { 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 721f93466..65f4efe25 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 @@ -4,7 +4,6 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useQueryClient } from '@tanstack/react-query'; import { Link as TanStackRouterLink } from '@tanstack/react-router'; import { FEATURE_FLAGS } from 'components/constants'; -import { generatePassword } from 'components/pages/acls/user-create'; import { Alert, AlertDescription, AlertTitle } from 'components/redpanda-ui/components/alert'; import { Button } from 'components/redpanda-ui/components/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from 'components/redpanda-ui/components/card'; @@ -52,7 +51,7 @@ import { LONG_LIVED_CACHE_STALE_TIME } from 'react-query/react-query.utils'; import { toast } from 'sonner'; import { generateServiceAccountName } from 'utils/service-account.utils'; import { formatToastErrorMessageGRPC } from 'utils/toast.utils'; -import { SASL_MECHANISMS } from 'utils/user'; +import { generatePassword, SASL_MECHANISMS } from 'utils/user'; import { useListACLsQuery } from '../../../../react-query/api/acl'; import type { UserStepRef, UserStepSubmissionResult } from '../types/wizard'; diff --git a/frontend/src/components/redpanda-ui/components/copy-button.tsx b/frontend/src/components/redpanda-ui/components/copy-button.tsx index 4df45ecde..808d9388c 100644 --- a/frontend/src/components/redpanda-ui/components/copy-button.tsx +++ b/frontend/src/components/redpanda-ui/components/copy-button.tsx @@ -18,7 +18,7 @@ const buttonVariants = cva( 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', secondary: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90', - ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', + ghost: 'hover:bg-accent dark:hover:bg-accent/50 text-action-primary', }, size: { md: 'h-9 px-4 py-2 has-[>svg]:px-3', diff --git a/frontend/src/components/redpanda-ui/components/input.tsx b/frontend/src/components/redpanda-ui/components/input.tsx index 41b91bc86..074d3e7f0 100644 --- a/frontend/src/components/redpanda-ui/components/input.tsx +++ b/frontend/src/components/redpanda-ui/components/input.tsx @@ -216,7 +216,7 @@ const Input = React.forwardRef( } ); -const inputEndClassNames = 'absolute top-1/2 -translate-y-1/2 z-10 pointer-events-none right-2'; +const inputEndClassNames = 'absolute top-1/2 -translate-y-1/2 z-10 pointer-events-none right-2 flex items-center justify-center'; const InputContext = createContext<{ setStartWidth: (width: number) => void; diff --git a/frontend/src/utils/proto-constraints.test.ts b/frontend/src/utils/proto-constraints.test.ts new file mode 100644 index 000000000..82c1d5e9e --- /dev/null +++ b/frontend/src/utils/proto-constraints.test.ts @@ -0,0 +1,156 @@ +/** + * 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 { CreateUserRequest_UserSchema } from 'protogen/redpanda/api/dataplane/v1/user_pb'; +import { describe, expect, it } from 'vitest'; + +import { + getFieldConstraints, + getMessageConstraints, + getStringFieldConstraints, + protoToZodSchema, +} from './proto-constraints'; + +describe('proto-constraints', () => { + describe('getFieldConstraints', () => { + it('extracts string constraints from CreateUserRequest.User.name', () => { + const nameField = CreateUserRequest_UserSchema.fields.find((f) => f.localName === 'name'); + expect(nameField).toBeDefined(); + + const constraints = getFieldConstraints(nameField!); + expect(constraints).toEqual({ + type: 'string', + required: true, + minLen: 1, + maxLen: 128, + }); + }); + + it('extracts string constraints from CreateUserRequest.User.password', () => { + const passwordField = CreateUserRequest_UserSchema.fields.find((f) => f.localName === 'password'); + expect(passwordField).toBeDefined(); + + const constraints = getFieldConstraints(passwordField!); + expect(constraints).toEqual({ + type: 'string', + required: true, + minLen: 3, + maxLen: 128, + }); + }); + + it('extracts enum constraints from CreateUserRequest.User.mechanism', () => { + const mechanismField = CreateUserRequest_UserSchema.fields.find((f) => f.localName === 'mechanism'); + expect(mechanismField).toBeDefined(); + + const constraints = getFieldConstraints(mechanismField!); + expect(constraints).toEqual({ + type: 'enum', + required: true, + definedOnly: true, + notIn: [0], + }); + }); + }); + + describe('getMessageConstraints', () => { + it('returns all field constraints for CreateUserRequest.User', () => { + const constraints = getMessageConstraints(CreateUserRequest_UserSchema); + + expect(constraints.name).toEqual({ + type: 'string', + required: true, + minLen: 1, + maxLen: 128, + }); + expect(constraints.password).toEqual({ + type: 'string', + required: true, + minLen: 3, + maxLen: 128, + }); + expect(constraints.mechanism).toBeDefined(); + expect(constraints.mechanism.type).toBe('enum'); + }); + }); + + describe('getStringFieldConstraints', () => { + it('returns string constraints for a named field', () => { + const constraints = getStringFieldConstraints(CreateUserRequest_UserSchema, 'name'); + expect(constraints).not.toBeNull(); + expect(constraints?.maxLen).toBe(128); + expect(constraints?.minLen).toBe(1); + }); + + it('returns null for non-string fields', () => { + const constraints = getStringFieldConstraints(CreateUserRequest_UserSchema, 'mechanism'); + expect(constraints).toBeNull(); + }); + + it('returns null for non-existent fields', () => { + const constraints = getStringFieldConstraints(CreateUserRequest_UserSchema, 'nonExistent'); + expect(constraints).toBeNull(); + }); + }); + + describe('protoToZodSchema', () => { + it('generates a Zod schema from CreateUserRequest.User', () => { + const schema = protoToZodSchema(CreateUserRequest_UserSchema); + + // Valid data should parse + const valid = schema.safeParse({ name: 'testuser', password: 'abc', mechanism: 1 }); + expect(valid.success).toBe(true); + }); + + it('rejects empty name (minLen=1)', () => { + const schema = protoToZodSchema(CreateUserRequest_UserSchema); + const result = schema.safeParse({ name: '', password: 'abc', mechanism: 1 }); + expect(result.success).toBe(false); + }); + + it('rejects name exceeding 128 characters', () => { + const schema = protoToZodSchema(CreateUserRequest_UserSchema); + const result = schema.safeParse({ name: 'a'.repeat(129), password: 'abc', mechanism: 1 }); + expect(result.success).toBe(false); + }); + + it('accepts name at exactly 128 characters', () => { + const schema = protoToZodSchema(CreateUserRequest_UserSchema); + const result = schema.safeParse({ name: 'a'.repeat(128), password: 'abc', mechanism: 1 }); + expect(result.success).toBe(true); + }); + + it('rejects password shorter than 3 characters', () => { + const schema = protoToZodSchema(CreateUserRequest_UserSchema); + const result = schema.safeParse({ name: 'test', password: 'ab', mechanism: 1 }); + expect(result.success).toBe(false); + }); + + it('rejects password exceeding 128 characters', () => { + const schema = protoToZodSchema(CreateUserRequest_UserSchema); + const result = schema.safeParse({ name: 'test', password: 'a'.repeat(129), mechanism: 1 }); + expect(result.success).toBe(false); + }); + + it('exposes individual field schemas via .shape', () => { + const schema = protoToZodSchema(CreateUserRequest_UserSchema); + + // The name field schema should be accessible + const nameSchema = schema.shape.name; + expect(nameSchema).toBeDefined(); + + // And extendable with custom rules + const extended = (nameSchema as import('zod').ZodString).regex(/^[a-z]+$/, 'Only lowercase'); + const result = extended.safeParse('UPPER'); + expect(result.success).toBe(false); + }); + }); +}); diff --git a/frontend/src/utils/proto-constraints.ts b/frontend/src/utils/proto-constraints.ts new file mode 100644 index 000000000..77a25397e --- /dev/null +++ b/frontend/src/utils/proto-constraints.ts @@ -0,0 +1,383 @@ +/** + * 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 type { DescField, DescMessage } from '@bufbuild/protobuf'; +import { getOption, hasOption } from '@bufbuild/protobuf'; +import type { EnumRules, Int32Rules, RepeatedRules, StringRules } from 'protogen/buf/validate/validate_pb'; +import { field as fieldExt } from 'protogen/buf/validate/validate_pb'; +import { z } from 'zod'; + +// ── Constraint types ────────────────────────────────────────────────── + +export type StringConstraints = { + type: 'string'; + required: boolean; + minLen?: number; + maxLen?: number; + pattern?: string; + const?: string; + email?: boolean; + uri?: boolean; + hostname?: boolean; + uuid?: boolean; +}; + +export type NumberConstraints = { + type: 'number'; + required: boolean; + gt?: number; + gte?: number; + lt?: number; + lte?: number; + in?: number[]; + notIn?: number[]; +}; + +export type EnumConstraints = { + type: 'enum'; + required: boolean; + definedOnly: boolean; + in?: number[]; + notIn?: number[]; +}; + +export type RepeatedConstraints = { + type: 'repeated'; + required: boolean; + minItems?: number; + maxItems?: number; + unique: boolean; +}; + +export type MessageConstraints = { + type: 'message'; + required: boolean; +}; + +export type BoolConstraints = { + type: 'bool'; + required: boolean; +}; + +export type FieldConstraint = + | StringConstraints + | NumberConstraints + | EnumConstraints + | RepeatedConstraints + | MessageConstraints + | BoolConstraints; + +// ── Constraint extraction ───────────────────────────────────────────── + +function extractStringConstraints(rules: StringRules, required: boolean): StringConstraints { + const constraints: StringConstraints = { type: 'string', required }; + + if (rules.minLen > 0n) { + constraints.minLen = Number(rules.minLen); + } + if (rules.maxLen > 0n) { + constraints.maxLen = Number(rules.maxLen); + } + if (rules.pattern) { + constraints.pattern = rules.pattern; + } + if (rules.const) { + constraints.const = rules.const; + } + + switch (rules.wellKnown.case) { + case 'email': { + constraints.email = true; + break; + } + case 'uri': { + constraints.uri = true; + break; + } + case 'hostname': { + constraints.hostname = true; + break; + } + case 'uuid': { + constraints.uuid = true; + break; + } + default: + break; + } + + return constraints; +} + +function extractInt32Constraints(rules: Int32Rules, required: boolean): NumberConstraints { + const constraints: NumberConstraints = { type: 'number', required }; + + if (rules.greaterThan.case === 'gt') { + constraints.gt = rules.greaterThan.value; + } + if (rules.greaterThan.case === 'gte') { + constraints.gte = rules.greaterThan.value; + } + if (rules.lessThan.case === 'lt') { + constraints.lt = rules.lessThan.value; + } + if (rules.lessThan.case === 'lte') { + constraints.lte = rules.lessThan.value; + } + if (rules.in.length > 0) { + constraints.in = [...rules.in]; + } + if (rules.notIn.length > 0) { + constraints.notIn = [...rules.notIn]; + } + + return constraints; +} + +function extractEnumConstraints(rules: EnumRules, required: boolean): EnumConstraints { + const constraints: EnumConstraints = { + type: 'enum', + required, + definedOnly: rules.definedOnly, + }; + + if (rules.in.length > 0) { + constraints.in = [...rules.in]; + } + if (rules.notIn.length > 0) { + constraints.notIn = [...rules.notIn]; + } + + return constraints; +} + +function extractRepeatedConstraints(rules: RepeatedRules, required: boolean): RepeatedConstraints { + const constraints: RepeatedConstraints = { + type: 'repeated', + required, + unique: rules.unique, + }; + + if (rules.minItems > 0n) { + constraints.minItems = Number(rules.minItems); + } + if (rules.maxItems > 0n) { + constraints.maxItems = Number(rules.maxItems); + } + + return constraints; +} + +/** Extract type-specific constraints from a FieldRules object. */ +function extractTypedConstraints( + rules: { type: { case: string | undefined; value?: unknown } }, + required: boolean +): FieldConstraint | null { + switch (rules.type.case) { + case 'string': + return extractStringConstraints(rules.type.value as StringRules, required); + case 'int32': + case 'uint32': + case 'sint32': + return extractInt32Constraints(rules.type.value as Int32Rules, required); + case 'enum': + return extractEnumConstraints(rules.type.value as EnumRules, required); + case 'repeated': + return extractRepeatedConstraints(rules.type.value as RepeatedRules, required); + default: + return null; + } +} + +/** Infer a constraint type from the proto field descriptor kind. */ +function inferConstraintFromFieldKind(fieldDesc: DescField, required: boolean): FieldConstraint | null { + if (fieldDesc.fieldKind === 'message') { + return { type: 'message', required }; + } + if (fieldDesc.fieldKind === 'enum') { + return { type: 'enum', required, definedOnly: false }; + } + if (fieldDesc.fieldKind === 'list') { + return { type: 'repeated', required, unique: false }; + } + if (fieldDesc.fieldKind === 'scalar') { + switch (fieldDesc.scalar) { + case 9: // STRING + return { type: 'string', required }; + case 5: // INT32 + case 13: // UINT32 + case 17: // SINT32 + return { type: 'number', required }; + case 8: // BOOL + return { type: 'bool', required }; + default: + return null; + } + } + return null; +} + +/** + * Extract validation constraints from a single proto field descriptor. + * Returns null if the field has no buf.validate annotations. + */ +export function getFieldConstraints(fieldDesc: DescField): FieldConstraint | null { + if (!hasOption(fieldDesc, fieldExt)) { + return null; + } + + const rules = getOption(fieldDesc, fieldExt); + const required = rules.required; + + // Try to extract type-specific constraints first + const typed = extractTypedConstraints(rules, required); + if (typed) { + return typed; + } + + // Fall back to inferring from the field descriptor kind + return inferConstraintFromFieldKind(fieldDesc, required); +} + +/** + * Extract all field constraints from a proto message descriptor. + */ +export function getMessageConstraints(schema: DescMessage): Record { + const result: Record = {}; + + for (const fieldDesc of schema.fields) { + const constraint = getFieldConstraints(fieldDesc); + if (constraint) { + result[fieldDesc.localName] = constraint; + } + } + + return result; +} + +// ── Zod schema generation ───────────────────────────────────────────── + +function stringConstraintsToZod(c: StringConstraints): z.ZodString { + let schema = z.string(); + + if (c.required && !c.minLen) { + schema = schema.min(1, 'This field is required'); + } + if (c.minLen !== undefined) { + schema = schema.min(c.minLen, `Must be at least ${c.minLen} characters`); + } + if (c.maxLen !== undefined) { + schema = schema.max(c.maxLen, `Must not exceed ${c.maxLen} characters`); + } + if (c.pattern) { + schema = schema.regex(new RegExp(c.pattern), 'Invalid format'); + } + if (c.email) { + schema = schema.email('Must be a valid email'); + } + if (c.uri) { + schema = schema.url('Must be a valid URL'); + } + if (c.uuid) { + schema = schema.uuid('Must be a valid UUID'); + } + + return schema; +} + +function numberConstraintsToZod(c: NumberConstraints): z.ZodNumber { + let schema = z.number(); + + if (c.gte !== undefined) { + schema = schema.min(c.gte); + } + if (c.gt !== undefined) { + schema = schema.gt(c.gt); + } + if (c.lte !== undefined) { + schema = schema.max(c.lte); + } + if (c.lt !== undefined) { + schema = schema.lt(c.lt); + } + + return schema; +} + +function repeatedConstraintsToZod(c: RepeatedConstraints): z.ZodArray { + let schema = z.array(z.unknown()); + + if (c.minItems !== undefined) { + schema = schema.min(c.minItems); + } + if (c.maxItems !== undefined) { + schema = schema.max(c.maxItems); + } + + return schema; +} + +function constraintToZod(constraint: FieldConstraint): z.ZodTypeAny { + switch (constraint.type) { + case 'string': + return stringConstraintsToZod(constraint); + case 'number': + return numberConstraintsToZod(constraint); + case 'enum': + return z.number(); + case 'repeated': + return repeatedConstraintsToZod(constraint); + case 'bool': + return z.boolean(); + case 'message': + return z.unknown().optional(); + default: + return z.unknown().optional(); + } +} + +/** + * Convert a proto message descriptor into a Zod object schema by reading + * buf.validate annotations from each field. + * + * Use `.shape.fieldName` to access individual field schemas and extend + * them with custom rules like `.regex()` or `.refine()`. + * + * Enum fields are z.number() since proto enums are numeric. Override + * in your form schema if you use string representations. + */ +export function protoToZodSchema(schema: DescMessage): z.ZodObject> { + const shape: Record = {}; + + for (const fieldDesc of schema.fields) { + const constraint = getFieldConstraints(fieldDesc); + shape[fieldDesc.localName] = constraint ? constraintToZod(constraint) : z.unknown().optional(); + } + + return z.object(shape); +} + +/** + * Get the string constraints for a specific field of a proto message. + * Useful for extracting individual values like maxLen for display. + */ +export function getStringFieldConstraints(schema: DescMessage, fieldName: string): StringConstraints | null { + const fieldDesc = schema.fields.find((f) => f.localName === fieldName); + if (!fieldDesc) { + return null; + } + + const constraint = getFieldConstraints(fieldDesc); + if (constraint?.type === 'string') { + return constraint; + } + return null; +} diff --git a/frontend/src/utils/user.ts b/frontend/src/utils/user.ts index 8cba52036..c92c677ce 100644 --- a/frontend/src/utils/user.ts +++ b/frontend/src/utils/user.ts @@ -14,20 +14,57 @@ * Used by both UserCreate and add-user-step */ +import { CreateUserRequest_UserSchema } from 'protogen/redpanda/api/dataplane/v1/user_pb'; +import { getStringFieldConstraints } from 'utils/proto-constraints'; + export const SASL_MECHANISMS = ['SCRAM-SHA-256', 'SCRAM-SHA-512'] as const; export type SaslMechanism = (typeof SASL_MECHANISMS)[number]; export const USERNAME_REGEX = /^[a-zA-Z0-9._@-]+$/; export const USERNAME_ERROR_MESSAGE = - 'Must not contain any whitespace. Must be alphanumeric and can contain underscores, periods, and hyphens.'; + 'Must not contain any whitespace. Must be alphanumeric and can contain underscores, periods, at symbols, and hyphens.'; + +/** Derived at runtime from proto CreateUserRequest.User field constraints. */ +const nameConstraints = getStringFieldConstraints(CreateUserRequest_UserSchema, 'name'); +const passwordConstraints = getStringFieldConstraints(CreateUserRequest_UserSchema, 'password'); -export const PASSWORD_MIN_LENGTH = 4; -export const PASSWORD_MAX_LENGTH = 64; +export const USERNAME_MAX_LENGTH = nameConstraints?.maxLen ?? 128; +export const PASSWORD_MIN_LENGTH = passwordConstraints?.minLen ?? 3; +export const PASSWORD_MAX_LENGTH = passwordConstraints?.maxLen ?? 128; export function validateUsername(username: string): boolean { - return USERNAME_REGEX.test(username); + return username.length > 0 && username.length <= USERNAME_MAX_LENGTH && USERNAME_REGEX.test(username); } export function validatePassword(password: string): boolean { return Boolean(password) && password.length >= PASSWORD_MIN_LENGTH && password.length <= PASSWORD_MAX_LENGTH; } + +export function generatePassword(length: number, allowSpecialChars: boolean): string { + if (length <= 0) { + return ''; + } + + const lowercase = 'abcdefghijklmnopqrstuvwxyz'; + const uppercase = lowercase.toUpperCase(); + const numbers = '0123456789'; + const special = '.,&_+|[]/-()'; + + let alphabet = lowercase + uppercase + numbers; + if (allowSpecialChars) { + alphabet += special; + } + + const randomValues = new Uint32Array(length); + crypto.getRandomValues(randomValues); + + let result = ''; + for (const n of randomValues) { + const index = n % alphabet.length; + const sym = alphabet[index]; + + result += sym; + } + + return result; +}