diff --git a/frontend/.gitignore b/frontend/.gitignore index ba1513a94c..3b291f90b3 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -151,7 +151,7 @@ dist # Playwright /test-results/ -/playwright-report/ +**/playwright-report/ /blob-report/ /playwright/.cache/ /playwright/.auth/ diff --git a/frontend/src/components/pages/acls/user-create.tsx b/frontend/src/components/pages/acls/user-create.tsx index 272c26b095..fe5719d8b1 100644 --- a/frontend/src/components/pages/acls/user-create.tsx +++ b/frontend/src/components/pages/acls/user-create.tsx @@ -24,29 +24,24 @@ import { IconButton, Input, isMultiValue, - isSingleValue, PasswordInput, redpandaTheme, redpandaToastOptions, Select, - Tag, - TagCloseButton, - TagLabel, Text, Tooltip, } from '@redpanda-data/ui'; import { Link } from '@tanstack/react-router'; import { RotateCwIcon } from 'components/icons'; -import { makeObservable, observable } from 'mobx'; -import { observer } from 'mobx-react'; -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useListRolesQuery } from '../../../react-query/api/security'; -import { invalidateUsersCache } from '../../../react-query/api/user'; +import { invalidateUsersCache, useLegacyListUsersQuery } from '../../../react-query/api/user'; import { appGlobal } from '../../../state/app-global'; import { api, rolesApi } from '../../../state/backend-api'; -import { AclRequestDefault, type CreateUserRequest } from '../../../state/rest-interfaces'; +import { AclRequestDefault } from '../../../state/rest-interfaces'; import { Features } from '../../../state/supported-features'; +import { uiState } from '../../../state/ui-state'; import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, @@ -57,7 +52,6 @@ import { } from '../../../utils/user'; import PageContent from '../../misc/page-content'; import { SingleSelect } from '../../misc/select'; -import { PageComponent, type PageInitHelper } from '../page'; const { ToastContainer, toast } = createStandaloneToast({ theme: redpandaTheme, @@ -68,105 +62,64 @@ const { ToastContainer, toast } = createStandaloneToast({ }, }); -export type CreateUserModalState = CreateUserRequest & { - generateWithSpecialChars: boolean; - step: 'CREATE_USER' | 'CREATE_USER_CONFIRMATION'; - isCreating: boolean; - isValidUsername: boolean; - isValidPassword: boolean; - selectedRoles: string[]; -}; - -@observer -class UserCreatePage extends PageComponent { - @observable username = ''; - @observable password: string = generatePassword(30, false); - @observable mechanism: SaslMechanism = 'SCRAM-SHA-256'; - - @observable isValidUsername = false; - @observable isValidPassword = false; - - @observable generateWithSpecialChars = false; - @observable step: 'CREATE_USER' | 'CREATE_USER_CONFIRMATION' = 'CREATE_USER'; - @observable isCreating = false; - - @observable selectedRoles: string[] = []; - - constructor(p: Readonly<{ matchedPath: string }>) { - super(p); - makeObservable(this); - this.onCreateUser = this.onCreateUser.bind(this); - } - - initPage(p: PageInitHelper): void { - p.title = 'Create user'; - p.addBreadcrumb('Access Control', '/security'); - p.addBreadcrumb('Create user', '/security/users/create'); +const UserCreatePage = () => { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(() => generatePassword(30, false)); + const [mechanism, setMechanism] = useState('SCRAM-SHA-256'); + 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([]); - // biome-ignore lint/suspicious/noConsole: error logging - this.refreshData(true).catch(console.error); - // biome-ignore lint/suspicious/noConsole: error logging - appGlobal.onRefresh = () => this.refreshData(true).catch(console.error); - } + const { data: usersData } = useLegacyListUsersQuery(); + const users = usersData?.users?.map((u) => u.name) ?? []; - async refreshData(force: boolean) { - if (api.userData !== null && api.userData !== undefined && !api.userData.canListAcls) { - return; - } + const isValidUsername = validateUsername(username); + const isValidPassword = validatePassword(password); - await Promise.allSettled([api.refreshAcls(AclRequestDefault, force), api.refreshServiceAccounts()]); - } + 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' }); - render() { - // if (api.userData != null && !api.userData.canListAcls) return PermissionDenied; - // if (api.ACLs?.aclResources === undefined) return DefaultSkeleton; - // if (!api.serviceAccounts || !api.serviceAccounts.users) return DefaultSkeleton; - - this.isValidUsername = validateUsername(this.username); - this.isValidPassword = validatePassword(this.password); - - const onCancel = () => appGlobal.historyPush('/security/users'); - - return ( - <> - - - - - {this.step === 'CREATE_USER' ? ( - - ) : ( - - )} - - - - ); - } + 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 + }); + }, []); - async onCreateUser(): Promise { + const onCreateUser = useCallback(async (): Promise => { try { - this.isCreating = true; + setIsCreating(true); await api.createServiceAccount({ - username: this.username, - password: this.password, - mechanism: this.mechanism, + username, + password, + mechanism, }); - // Refresh user list and invalidate React Query cache if (api.userData !== null && api.userData !== undefined && !api.userData.canListAcls) { return false; } await Promise.allSettled([api.refreshAcls(AclRequestDefault, true), invalidateUsersCache()]); - // Add the user to the selected roles const roleAddPromises: Promise[] = []; - for (const r of this.selectedRoles) { - roleAddPromises.push(rolesApi.updateRoleMembership(r, [this.username], [], false)); + for (const r of selectedRoles) { + roleAddPromises.push(rolesApi.updateRoleMembership(r, [username], [], false)); } await Promise.allSettled(roleAddPromises); - this.step = 'CREATE_USER_CONFIRMATION'; + setStep('CREATE_USER_CONFIRMATION'); } catch (err) { toast({ status: 'error', @@ -176,264 +129,272 @@ class UserCreatePage extends PageComponent { description: String(err), }); } finally { - this.isCreating = false; + setIsCreating(false); } 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, + }; -export default UserCreatePage; + return ( + <> + -const CreateUserModal = observer( - (p: { state: CreateUserModalState; onCreateUser: () => Promise; onCancel: () => void }) => { - const state = p.state; + + + {step === 'CREATE_USER' ? ( + + ) : ( + + )} + + + + ); +}; - const isValidUsername = validateUsername(state.username); - const users = api.serviceAccounts?.users ?? []; - const userAlreadyExists = users.includes(state.username); - const isValidPassword = validatePassword(state.password); +export default UserCreatePage; - const errorText = useMemo(() => { - if (!isValidUsername) { - return 'The username contains invalid characters. Use only letters, numbers, dots, underscores, at symbols, and hyphens.'; - } +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; +}; - if (userAlreadyExists) { - return 'User already exists'; - } - }, [isValidUsername, userAlreadyExists]); +const CreateUserModal = ({ state, onCreateUser, onCancel }: CreateUserModalProps) => { + const userAlreadyExists = state.users.includes(state.username); - return ( - - - 0} - label="Username" - showRequiredIndicator - > - { - state.username = v.target.value; - }} - placeholder="Username" - spellCheck={false} - value={state.username} - width="100%" - /> - + const errorText = useMemo(() => { + if (!state.isValidUsername) { + return 'The username contains invalid characters. Use only letters, numbers, dots, underscores, at symbols, and hyphens.'; + } - - - - { - state.password = e.target.value; - }} - value={state.password} - /> + if (userAlreadyExists) { + return 'User already exists'; + } + }, [state.isValidUsername, userAlreadyExists]); - - } - onClick={() => { - state.password = generatePassword(30, state.generateWithSpecialChars); - }} - variant="ghost" - /> - - - - - - + + 0} + label="Username" + showRequiredIndicator + > + { + state.setUsername(v.target.value); + }} + placeholder="Username" + spellCheck={false} + value={state.username} + width="100%" + /> + + + + + + { - state.generateWithSpecialChars = e.target.checked; - state.password = generatePassword(30, e.target.checked); + state.setPassword(e.target.value); }} - > - Generate with special characters - + value={state.password} + /> + + + } + onClick={() => { + state.setPassword(generatePassword(30, state.generateWithSpecialChars)); + }} + variant="ghost" + /> + + + + - - - - + { - state.mechanism = e; + state.setGenerateWithSpecialChars(e.target.checked); + state.setPassword(generatePassword(30, e.target.checked)); }} - options={SASL_MECHANISMS.map((mechanism) => ({ - value: mechanism, - label: mechanism, - }))} - value={state.mechanism} - /> - - - {Boolean(Features.rolesApi) && ( - - - - )} - - - - - - - - ); - } -); - -const CreateUserConfirmationModal = observer((p: { state: CreateUserModalState; closeModal: () => void }) => { - return ( - <> - - - {/* */} - User created successfully - - - - - - You will not be able to view this password again. Make sure that it is copied and saved. - - - - - Username - - - - - {p.state.username} - - - - - - - - - - Password - - - - - - - - + Generate with special characters + - - - - Mechanism - - - - {p.state.mechanism} - - - + + + + + onChange={(e) => { + state.setMechanism(e); + }} + options={SASL_MECHANISMS.map((mechanism) => ({ + value: mechanism, + label: mechanism, + }))} + value={state.mechanism} + /> + + + {Boolean(Features.rolesApi) && ( + + + + )} + - + - + ); -}); +}; -export const RoleSelector = observer((p: { state: string[] }) => { - // Make sure we have up to date role info - useEffect(() => { - rolesApi.refreshRoles(); - rolesApi.refreshRoleMembers(); - }, []); - const [searchValue, setSearchValue] = useState(''); +type CreateUserConfirmationModalProps = { + username: string; + password: string; + mechanism: SaslMechanism; + closeModal: () => void; +}; - const state = p.state; +const CreateUserConfirmationModal = ({ + username, + password, + mechanism, + closeModal, +}: CreateUserConfirmationModalProps) => ( + <> + + User created successfully + + + + + You will not be able to view this password again. Make sure that it is copied and saved. + + + + + Username + + + + + {username} + - const availableRoles = (rolesApi.roles ?? []).filter((r) => !state.includes(r)).map((r) => ({ value: r })); + + + + + - return ( - - - - inputValue={searchValue} - isMulti={false} - noOptionsMessage={() => 'No roles found'} - onChange={(val, meta) => { - // biome-ignore lint/suspicious/noConsole: debug logging - console.log('onChange', { metaAction: meta.action, val }); - if (val && isSingleValue(val) && val.value) { - state.push(val.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={undefined} - /> + + Password + + + - - {state.map((role) => ( - - {role} - state.remove(role)} /> - - ))} - + + + + + + + + Mechanism + + + + {mechanism} + + + + + + + - ); -}); + +); -// use instead of RoleSelector whn not using mobx export const StateRoleSelector = ({ roles, setRoles }: { roles: string[]; setRoles: (roles: string[]) => void }) => { const [searchValue, setSearchValue] = useState(''); const { diff --git a/frontend/src/components/pages/acls/user-details.tsx b/frontend/src/components/pages/acls/user-details.tsx index ac24ee2e60..c9fcbed863 100644 --- a/frontend/src/components/pages/acls/user-details.tsx +++ b/frontend/src/components/pages/acls/user-details.tsx @@ -14,191 +14,163 @@ import { UserAclsCard } from 'components/pages/roles/user-acls-card'; import { UserInformationCard } from 'components/pages/roles/user-information-card'; import { UserRolesCard } from 'components/pages/roles/user-roles-card'; import { Button } from 'components/redpanda-ui/components/button'; -import { makeObservable, observable } from 'mobx'; -import { observer } from 'mobx-react'; import type { UpdateRoleMembershipResponse } from 'protogen/redpanda/api/console/v1alpha1/security_pb'; +import { useEffect, useState } from 'react'; import { DeleteUserConfirmModal } from './delete-user-confirm-modal'; import type { AclPrincipalGroup } from './models'; import { ChangePasswordModal, ChangeRolesModal } from './user-edit-modals'; import { useGetAclsByPrincipal } from '../../../react-query/api/acl'; -import { invalidateUsersCache } from '../../../react-query/api/user'; +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 { DefaultSkeleton } from '../../../utils/tsx-utils'; import PageContent from '../../misc/page-content'; -import { PageComponent, type PageInitHelper, type PageProps } from '../page'; -@observer -class UserDetailsPage extends PageComponent<{ userName: string }> { - @observable username = ''; +type UserDetailsPageProps = { + userName: string; +}; - @observable isValidUsername = false; - @observable isValidPassword = false; +const UserDetailsPage = ({ userName }: UserDetailsPageProps) => { + const [isChangePasswordModalOpen, setIsChangePasswordModalOpen] = useState(false); + const [isChangeRolesModalOpen, setIsChangeRolesModalOpen] = useState(false); - @observable generateWithSpecialChars = false; - @observable step: 'CREATE_USER' | 'CREATE_USER_CONFIRMATION' = 'CREATE_USER'; - @observable isCreating = false; + const { data: usersData, isLoading: isUsersLoading } = useLegacyListUsersQuery(); + const users = usersData?.users?.map((u) => u.name) ?? []; - @observable selectedRoles: string[] = []; + useEffect(() => { + uiState.pageTitle = 'User details'; + uiState.pageBreadcrumbs = []; + uiState.pageBreadcrumbs.push({ title: 'Access Control', linkTo: '/security' }); + uiState.pageBreadcrumbs.push({ title: 'Users', linkTo: '/security/users' }); + uiState.pageBreadcrumbs.push({ title: userName, linkTo: `/security/users/${userName}` }); - @observable isChangePasswordModalOpen = false; - @observable isChangeRolesModalOpen = false; - - constructor(p: Readonly>) { - super(p); - makeObservable(this); - } - - initPage(p: PageInitHelper): void { - p.title = 'Create user'; - p.addBreadcrumb('Access Control', '/security'); - p.addBreadcrumb('Users', '/security/users'); - p.addBreadcrumb(this.props.userName, '/security/users/'); + const refreshData = async () => { + if (api.userData !== null && api.userData !== undefined && !api.userData.canListAcls) { + return; + } + await Promise.allSettled([ + api.refreshAcls(AclRequestDefault, true), + api.refreshServiceAccounts(), + rolesApi.refreshRoles(), + ]); + await rolesApi.refreshRoleMembers(); + }; // biome-ignore lint/suspicious/noConsole: error logging for unhandled promise rejections - this.refreshData(true).catch(console.error); - // biome-ignore lint/suspicious/noConsole: error logging for unhandled promise rejections - appGlobal.onRefresh = () => this.refreshData(true).catch(console.error); + refreshData().catch(console.error); + appGlobal.onRefresh = () => + // biome-ignore lint/suspicious/noConsole: error logging for unhandled promise rejections + refreshData().catch(console.error); + }, [userName]); + + if (isUsersLoading) { + return DefaultSkeleton; } - async refreshData(force: boolean) { - if (api.userData !== null && api.userData !== undefined && !api.userData.canListAcls) { - return; - } - - await Promise.allSettled([ - api.refreshAcls(AclRequestDefault, force), - api.refreshServiceAccounts(), - rolesApi.refreshRoles(), - ]); - - await rolesApi.refreshRoleMembers(); - } + const isServiceAccount = users.includes(userName); - render() { - if (!api.serviceAccounts?.users) { - return DefaultSkeleton; - } - const userName = this.props.userName; - - const isServiceAccount = api.serviceAccounts.users.includes(userName); - - return ( - -
- { - this.isChangePasswordModalOpen = true; - } - : undefined - } - username={userName} - /> - { - this.isChangeRolesModalOpen = true; - } - : undefined - } - userName={userName} - /> -
- {Boolean(isServiceAccount) && ( - - Delete user - + return ( + +
+ { + setIsChangePasswordModalOpen(true); } - onConfirm={async () => { - await api.deleteServiceAccount(userName); - - // Remove user from all its roles - const promises: Promise[] = []; - for (const [roleName, members] of rolesApi.roleMembers) { - if (members.any((m) => m.name === userName)) { - // is this user part of this role? - // then remove it - promises.push(rolesApi.updateRoleMembership(roleName, [], [userName])); - } + : undefined + } + username={userName} + /> + { + setIsChangeRolesModalOpen(true); + } + : undefined + } + userName={userName} + /> +
+ {Boolean(isServiceAccount) && ( + + Delete user + + } + onConfirm={async () => { + await api.deleteServiceAccount(userName); + + // Remove user from all its roles + const promises: Promise[] = []; + for (const [roleName, members] of rolesApi.roleMembers) { + if (members.any((m) => m.name === userName)) { + promises.push(rolesApi.updateRoleMembership(roleName, [], [userName])); } - await Promise.allSettled(promises); - await invalidateUsersCache(); - await rolesApi.refreshRoleMembers(); - appGlobal.historyPush('/security/users/'); - }} - userName={userName} - /> - )} -
- - {/*Modals*/} - {Boolean(api.isAdminApiConfigured) && ( - { - this.isChangePasswordModalOpen = value; - }} - userName={userName} - /> - )} - - {Boolean(Features.rolesApi) && ( - { - this.isChangeRolesModalOpen = value; + } + await Promise.allSettled(promises); + await invalidateUsersCache(); + await rolesApi.refreshRoleMembers(); + appGlobal.historyPush('/security/users/'); }} userName={userName} /> )}
-
- ); - } -} -export default UserDetailsPage; + {Boolean(api.isAdminApiConfigured) && ( + + )} -const UserPermissionDetailsContent = observer((p: { userName: string; onChangeRoles?: () => void }) => { - // Get all roles and ACLs matching this user - const roles: { - principalType: string; - principalName: string; - }[] = []; + {Boolean(Features.rolesApi) && ( + + )} +
+ + ); +}; - if (Features.rolesApi) { - for (const [roleName, members] of rolesApi.roleMembers) { - if (!members.any((m) => m.name === p.userName)) { - continue; // this role doesn't contain our user - } - roles.push({ - principalType: 'RedpandaRole', - principalName: roleName, - }); - } - } +export default UserDetailsPage; - const { data: acls } = useGetAclsByPrincipal(`User:${p.userName}`); +const UserPermissionDetailsContent = ({ + userName, + onChangeRoles, +}: { + userName: string; + onChangeRoles?: () => void; +}) => { + const { data: rolesData } = useListRolesQuery({ filter: { principal: userName } }); + const { data: acls } = useGetAclsByPrincipal(`User:${userName}`); + + const roles = Features.rolesApi + ? (rolesData?.roles ?? []).map((r) => ({ + principalType: 'RedpandaRole', + principalName: r.name, + })) + : []; return (
- +
); -}); +}; // TODO: remove this component when we update RoleDetails // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complexity 70, refactor later -export const AclPrincipalGroupPermissionsTable = observer((p: { group: AclPrincipalGroup }) => { +export const AclPrincipalGroupPermissionsTable = ({ group }: { group: AclPrincipalGroup }) => { const entries: { type: string; selector: string; @@ -209,7 +181,6 @@ export const AclPrincipalGroupPermissionsTable = observer((p: { group: AclPrinci }[] = []; // Convert all entries of the group into a table row - const group = p.group; for (const topicAcl of group.topicAcls) { const allow: string[] = []; const deny: string[] = []; @@ -383,4 +354,4 @@ export const AclPrincipalGroupPermissionsTable = observer((p: { group: AclPrinci data={entries} /> ); -}); +}; diff --git a/frontend/src/routes/security/users/$userName/details.tsx b/frontend/src/routes/security/users/$userName/details.tsx index 6c09a04d3e..ebdffafe5c 100644 --- a/frontend/src/routes/security/users/$userName/details.tsx +++ b/frontend/src/routes/security/users/$userName/details.tsx @@ -22,5 +22,5 @@ export const Route = createFileRoute('/security/users/$userName/details')({ function UserDetailsWrapper() { const { userName } = useParams({ from: '/security/users/$userName/details' }); - return ; + return ; } diff --git a/frontend/src/routes/security/users/create.tsx b/frontend/src/routes/security/users/create.tsx index a400be50fd..ba2ea288b3 100644 --- a/frontend/src/routes/security/users/create.tsx +++ b/frontend/src/routes/security/users/create.tsx @@ -21,5 +21,5 @@ export const Route = createFileRoute('/security/users/create')({ }); function UserCreateWrapper() { - return ; + return ; } 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 50e923d15b..53759f65f4 100644 --- a/frontend/tests/test-variant-console/acls/user-management.spec.ts +++ b/frontend/tests/test-variant-console/acls/user-management.spec.ts @@ -13,7 +13,7 @@ test.describe('ACL User Management', () => { // Wait for API initialization to complete and button to be enabled // This handles timing differences between local and CI environments - await expect(page.getByTestId('create-user-button')).toBeEnabled({ timeout: 10000 }); + await expect(page.getByTestId('create-user-button')).toBeEnabled({ timeout: 10_000 }); }); test('should create a new user with special characters in password', async ({ page }) => { @@ -367,7 +367,7 @@ test.describe('ACL User Management', () => { }); await test.step('6. Verify redirect to users list', async () => { - await page.waitForURL('/security/users', { timeout: 10000 }); + await page.waitForURL('/security/users', { timeout: 10_000 }); }); }); });