diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/settings/members.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/settings/members.tsx index 74e75babc62..2fdebeef19c 100644 --- a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/settings/members.tsx +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/settings/members.tsx @@ -1,9 +1,10 @@ import { createFileRoute } from '@tanstack/react-router' +import { SettingsMembers } from '@qovery/domains/organizations/feature' export const Route = createFileRoute('/_authenticated/organization/$organizationId/settings/members')({ component: RouteComponent, }) function RouteComponent() { - return
Hello "/_authenticated/organization/$organizationId/settings/members"!
+ return } diff --git a/libs/domains/organizations/feature/src/index.ts b/libs/domains/organizations/feature/src/index.ts index 8a6505183f8..3aaec5710d9 100644 --- a/libs/domains/organizations/feature/src/index.ts +++ b/libs/domains/organizations/feature/src/index.ts @@ -98,3 +98,4 @@ export * from './lib/settings-webhook/settings-webhook' export * from './lib/settings-api-token/settings-api-token' export * from './lib/settings-danger-zone/settings-danger-zone' export * from './lib/settings-billing-details/settings-billing-details' +export * from './lib/settings-members/settings-members' diff --git a/libs/domains/organizations/feature/src/lib/hooks/use-available-roles/use-available-roles.ts b/libs/domains/organizations/feature/src/lib/hooks/use-available-roles/use-available-roles.ts index 94bc5b84a43..314408f8590 100644 --- a/libs/domains/organizations/feature/src/lib/hooks/use-available-roles/use-available-roles.ts +++ b/libs/domains/organizations/feature/src/lib/hooks/use-available-roles/use-available-roles.ts @@ -3,9 +3,10 @@ import { queries } from '@qovery/state/util-queries' export interface UseAvailableRolesProps { organizationId: string + suspense?: boolean } -export function useAvailableRoles({ organizationId }: UseAvailableRolesProps) { +export function useAvailableRoles({ organizationId, suspense = false }: UseAvailableRolesProps) { return useQuery({ ...queries.organizations.availableRoles({ organizationId }), select(data) { @@ -14,6 +15,7 @@ export function useAvailableRoles({ organizationId }: UseAvailableRolesProps) { } return data.sort((a, b) => (a.name && b.name ? a.name.localeCompare(b.name) : 0)) }, + suspense, }) } diff --git a/libs/domains/organizations/feature/src/lib/hooks/use-invite-members/use-invite-members.ts b/libs/domains/organizations/feature/src/lib/hooks/use-invite-members/use-invite-members.ts index 8e46c5ad120..5d4842bac17 100644 --- a/libs/domains/organizations/feature/src/lib/hooks/use-invite-members/use-invite-members.ts +++ b/libs/domains/organizations/feature/src/lib/hooks/use-invite-members/use-invite-members.ts @@ -3,9 +3,10 @@ import { queries } from '@qovery/state/util-queries' export interface UseInviteMembersProps { organizationId: string + suspense?: boolean } -export function useInviteMembers({ organizationId }: UseInviteMembersProps) { +export function useInviteMembers({ organizationId, suspense = false }: UseInviteMembersProps) { return useQuery({ ...queries.organizations.inviteMembers({ organizationId }), select(data) { @@ -14,6 +15,7 @@ export function useInviteMembers({ organizationId }: UseInviteMembersProps) { } return data.sort((a, b) => a.email.localeCompare(b.email)) }, + suspense, }) } diff --git a/libs/domains/organizations/feature/src/lib/hooks/use-members/use-members.ts b/libs/domains/organizations/feature/src/lib/hooks/use-members/use-members.ts index 807fe377365..a8fc4805b28 100644 --- a/libs/domains/organizations/feature/src/lib/hooks/use-members/use-members.ts +++ b/libs/domains/organizations/feature/src/lib/hooks/use-members/use-members.ts @@ -3,14 +3,16 @@ import { queries } from '@qovery/state/util-queries' export interface UseMembersProps { organizationId: string + suspense?: boolean } -export function useMembers({ organizationId }: UseMembersProps) { +export function useMembers({ organizationId, suspense = false }: UseMembersProps) { return useQuery({ ...queries.organizations.members({ organizationId }), meta: { notifyOnError: true, }, + suspense, }) } diff --git a/libs/domains/organizations/feature/src/lib/settings-members/create-modal/create-modal.spec.tsx b/libs/domains/organizations/feature/src/lib/settings-members/create-modal/create-modal.spec.tsx new file mode 100644 index 00000000000..52508d0456c --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/settings-members/create-modal/create-modal.spec.tsx @@ -0,0 +1,63 @@ +import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests' +import * as useCreateInviteMemberHook from '../../hooks/use-create-invite-member/use-create-invite-member' +import CreateModal, { type CreateModalProps } from './create-modal' + +const useCreateInviteMemberMock = jest.spyOn(useCreateInviteMemberHook, 'useCreateInviteMember') as jest.Mock + +describe('CreateModal', () => { + const createInviteMemberMock = jest.fn() + + const props: CreateModalProps = { + availableRoles: [ + { + id: 'role-admin', + name: 'Admin', + }, + { + id: 'role-viewer', + name: 'Viewer', + }, + ], + onClose: jest.fn(), + organizationId: 'org-1', + } + + beforeEach(() => { + jest.clearAllMocks() + useCreateInviteMemberMock.mockReturnValue({ + mutateAsync: createInviteMemberMock, + isLoading: false, + }) + }) + + it('should render successfully', () => { + const { baseElement } = renderWithProviders() + expect(baseElement).toBeTruthy() + }) + + it('should submit the form', async () => { + createInviteMemberMock.mockResolvedValueOnce(undefined) + + const { userEvent } = renderWithProviders() + + await userEvent.type(screen.getByTestId('input-email'), 'test@qovery.com') + const submitButton = screen.getByTestId('submit-button') + + await waitFor(() => { + expect(submitButton).toBeEnabled() + }) + + await userEvent.click(submitButton) + + await waitFor(() => { + expect(createInviteMemberMock).toHaveBeenCalledWith({ + organizationId: 'org-1', + inviteMemberRequest: { + email: 'test@qovery.com', + role_id: 'role-admin', + }, + }) + expect(props.onClose).toHaveBeenCalled() + }) + }) +}) diff --git a/libs/pages/settings/src/lib/ui/page-organization-members/create-modal/create-modal.tsx b/libs/domains/organizations/feature/src/lib/settings-members/create-modal/create-modal.tsx similarity index 57% rename from libs/pages/settings/src/lib/ui/page-organization-members/create-modal/create-modal.tsx rename to libs/domains/organizations/feature/src/lib/settings-members/create-modal/create-modal.tsx index 7ff410c73a1..f3a13093e1e 100644 --- a/libs/pages/settings/src/lib/ui/page-organization-members/create-modal/create-modal.tsx +++ b/libs/domains/organizations/feature/src/lib/settings-members/create-modal/create-modal.tsx @@ -1,28 +1,54 @@ -import { type OrganizationAvailableRole } from 'qovery-typescript-axios' -import { Controller, useFormContext } from 'react-hook-form' +import { type InviteMemberRequest, type OrganizationAvailableRole } from 'qovery-typescript-axios' +import { Controller, FormProvider, useForm, useFormContext } from 'react-hook-form' import { InputSelect, InputText, ModalCrud } from '@qovery/shared/ui' import { upperCaseFirstLetter } from '@qovery/shared/util-js' +import { useCreateInviteMember } from '../../hooks/use-create-invite-member/use-create-invite-member' export interface CreateModalProps { - availableRoles: OrganizationAvailableRole[] - onSubmit: () => void onClose: () => void - loading?: boolean + availableRoles: OrganizationAvailableRole[] + organizationId?: string } export function CreateModal(props: CreateModalProps) { - const { availableRoles } = props - const { control } = useFormContext() + const { organizationId = '', availableRoles, onClose } = props + const { mutateAsync: createInviteMember, isLoading: isLoadingInviteMember } = useCreateInviteMember() + + type CreateInviteMemberForm = { + email: string + role_id: string + } + + const methods = useForm({ + mode: 'onChange', + defaultValues: { + email: '', + role_id: availableRoles[0]?.id ?? '', + }, + }) + const { control } = methods + + const onSubmit = methods.handleSubmit(async (data) => { + try { + await createInviteMember({ + organizationId, + inviteMemberRequest: data as InviteMemberRequest, + }) + onClose() + } catch (error) { + console.error(error) + } + }) return ( - -
+ + ( ({ label: upperCaseFirstLetter(availableRole.name), @@ -69,8 +95,8 @@ export function CreateModal(props: CreateModalProps) { /> )} /> -
-
+ + ) } diff --git a/libs/domains/organizations/feature/src/lib/settings-members/row-member/row-member.spec.tsx b/libs/domains/organizations/feature/src/lib/settings-members/row-member/row-member.spec.tsx new file mode 100644 index 00000000000..8a3d940444b --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/settings-members/row-member/row-member.spec.tsx @@ -0,0 +1,140 @@ +import { inviteMembersMock, membersMock } from '@qovery/shared/factories' +import { TablePrimitives } from '@qovery/shared/ui' +import { dateMediumLocalFormat } from '@qovery/shared/util-dates' +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import RowMember, { type RowMemberProps } from './row-member' + +const mockOpenModalConfirmation = jest.fn() + +jest.mock('@qovery/shared/ui', () => { + const actual = jest.requireActual('@qovery/shared/ui') + return { + ...actual, + useModalConfirmation: () => ({ + openModalConfirmation: mockOpenModalConfirmation, + }), + } +}) + +const { Table } = TablePrimitives + +const availableRoles = [ + { id: 'role-owner', name: 'Owner' }, + { id: 'role-admin', name: 'Admin' }, +] + +const columnSizes = [35, 22, 21, 21] + +const baseMember = { + ...membersMock(1, 'Admin', 'member-1')[0], + role_id: 'role-admin', + role_name: 'Admin', +} + +const baseInviteMember = { + ...inviteMembersMock(1)[0], + id: 'invite-1', + email: 'invite@qovery.com', + role_id: 'role-admin', + role_name: 'Admin', +} + +const renderRowMember = (overrideProps?: Partial) => { + const props: RowMemberProps = { + member: baseMember, + editMemberRole: jest.fn(), + deleteMember: jest.fn(), + transferOwnership: jest.fn(), + columnSizes, + availableRoles, + ...overrideProps, + } + + return { + props, + ...renderWithProviders( + + + + + + ), + } +} + +describe('RowMember', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should render successfully', () => { + const { baseElement } = renderRowMember() + expect(baseElement).toBeTruthy() + }) + + it('should have disabled input for owner', () => { + const ownerMember = { + ...membersMock(1, 'Owner', 'member-owner')[0], + role_id: 'role-owner', + role_name: 'Owner', + } + + renderRowMember({ member: ownerMember }) + + expect(screen.getByTestId('input')).toBeDisabled() + }) + + it('should have disabled input while loading', () => { + renderRowMember({ loadingUpdateRole: true }) + + expect(screen.getByTestId('input')).toBeDisabled() + }) + + it('should show last activity and created date', () => { + renderRowMember() + + const dateLastActivity = screen.getByTestId('last-activity') + const dateCreatedAt = screen.getByTestId('created-at') + + expect(dateLastActivity).toHaveTextContent(/ago/i) + expect(dateCreatedAt).toHaveTextContent(dateMediumLocalFormat(baseMember.created_at)) + }) + + it('should call editMemberRole when selecting a new role', async () => { + const { props, userEvent } = renderRowMember() + + await userEvent.click(screen.getByRole('button', { name: /member role/i })) + await userEvent.click(screen.getByRole('menuitem', { name: 'Owner' })) + + expect(props.editMemberRole).toHaveBeenCalledWith(baseMember.id, 'role-owner') + }) + + it('should call transferOwnership when user is owner', async () => { + const member = { ...baseMember } + const transferOwnership = jest.fn() + + const { userEvent } = renderRowMember({ member, transferOwnership, userIsOwner: true }) + + await userEvent.click(screen.getByRole('button', { name: /member actions/i })) + await userEvent.click(screen.getByText('Transfer ownership')) + + expect(transferOwnership).toHaveBeenCalledWith(member) + }) + + it('should call resendInvite for pending members', async () => { + const resendInvite = jest.fn() + + const { userEvent } = renderRowMember({ + member: baseInviteMember, + resendInvite, + }) + + await userEvent.click(screen.getByRole('button', { name: /invite actions/i })) + await userEvent.click(screen.getByText('Resend invite')) + + expect(resendInvite).toHaveBeenCalledWith(baseInviteMember.id, { + email: baseInviteMember.email, + role_id: baseInviteMember.role_id, + }) + }) +}) diff --git a/libs/domains/organizations/feature/src/lib/settings-members/row-member/row-member.tsx b/libs/domains/organizations/feature/src/lib/settings-members/row-member/row-member.tsx new file mode 100644 index 00000000000..4b1623446b3 --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/settings-members/row-member/row-member.tsx @@ -0,0 +1,289 @@ +import { + type InviteMember, + type InviteMemberRequest, + type Member, + type OrganizationAvailableRole, +} from 'qovery-typescript-axios' +import { useState } from 'react' +import { IconEnum, MemberRoleEnum } from '@qovery/shared/enums' +import { + Avatar, + Button, + DropdownMenu, + Icon, + Indicator, + TablePrimitives, + ToastEnum, + Tooltip, + toast, + useModalConfirmation, +} from '@qovery/shared/ui' +import { dateMediumLocalFormat, dateUTCString, timeAgo } from '@qovery/shared/util-dates' +import { useCopyToClipboard } from '@qovery/shared/util-hooks' +import { upperCaseFirstLetter } from '@qovery/shared/util-js' + +export interface RowMemberProps { + member: Member | InviteMember + columnSizes: number[] + transferOwnership?: (user: Member) => void + editMemberRole?: (userId: string, roleId: string) => void + deleteMember?: (userId: string) => void + deleteInviteMember?: (inviteId: string) => void + resendInvite?: (inviteId: string, data: InviteMemberRequest) => void + availableRoles?: OrganizationAvailableRole[] + loadingUpdateRole?: boolean + userIsOwner?: boolean +} + +const { Table } = TablePrimitives + +const getProviderIcon = (id: string): IconEnum | undefined => { + if (id.toUpperCase().includes('GITHUB')) { + return IconEnum.GITHUB + } else if (id.toUpperCase().includes('GITLAB')) { + return IconEnum.GITLAB + } else if (id.toUpperCase().includes('BITBUCKET')) { + return IconEnum.BITBUCKET + } else if (id.toUpperCase().includes('GOOGLE')) { + return IconEnum.GOOGLE + } else if (id.toUpperCase().includes('WINDOWSLIVE')) { + return IconEnum.MICROSOFT + } else { + return undefined + } +} + +export function RowMember(props: RowMemberProps) { + const { + member, + availableRoles, + editMemberRole, + loadingUpdateRole, + columnSizes, + deleteMember, + deleteInviteMember, + transferOwnership, + resendInvite, + userIsOwner, + } = props + + const [, copyToClipboard] = useCopyToClipboard() + const { openModalConfirmation } = useModalConfirmation() + + const name = (member as Member).name?.split(' ') || (member as InviteMember).email.split(' ') + + const isOwner = member.role_name?.toUpperCase() === MemberRoleEnum.OWNER + + const canEditRole = !isOwner && Boolean((member as Member).last_activity_at) + + const roleOptions = + availableRoles?.map((role) => ({ + label: upperCaseFirstLetter(role.name), + value: role.id || role.name || '', + })) ?? [] + const selectedRoleValue = + availableRoles?.find((role) => role.name?.toUpperCase() === member.role_name?.toUpperCase())?.id ?? + member.role_id ?? + '' + const [roleOverrideId, setRoleOverrideId] = useState(null) + const displayedRoleValue = roleOverrideId && roleOverrideId !== selectedRoleValue ? roleOverrideId : selectedRoleValue + const selectedRoleLabel = roleOptions.find((role) => role.value === displayedRoleValue)?.label ?? 'Select role' + + const handleRoleChange = (roleId: string | undefined) => { + if (!roleId) return + setRoleOverrideId(roleId) + editMemberRole?.(member.id, roleId) + } + + return ( + + +
+
+ {name && ( + + + + ) + } + > + + {name[0]?.charAt(0).toUpperCase()} + {name[1]?.charAt(0).toUpperCase()} + + } + /> + + )} +
+

{(member as Member).name}

+ {member.email} +
+
+ {!isOwner && ( + <> + {(member as Member).last_activity_at ? ( + + + + + + {userIsOwner ? ( + <> + } + onSelect={() => transferOwnership?.(member as Member)} + > + Transfer ownership + + + + ) : null} + } + onSelect={() => { + openModalConfirmation({ + title: 'Confirm to remove this member', + confirmationMethod: 'action', + name: (member as Member).name, + action: () => deleteMember?.(member.id), + }) + }} + > + Delete member + + + + ) : ( + + + + + + } + onSelect={() => { + resendInvite?.(member.id, { email: member.email, role_id: member.role_id }) + }} + > + Resend invite + + } + onSelect={() => { + copyToClipboard((member as InviteMember).invitation_link) + toast(ToastEnum.SUCCESS, 'Copied to your clipboard!') + }} + > + Copy invitation link + + + } + onSelect={() => { + openModalConfirmation({ + title: 'Confirm to remove this invite', + confirmationMethod: 'action', + name: (member as InviteMember).email, + action: () => deleteInviteMember?.(member.id), + }) + }} + > + Revoke invite + + + + )} + + )} +
+
+ +
+ + + + + + {roleOptions.map((role) => ( + handleRoleChange(role.value)} + > + {role.label} + + ))} + + +
+
+ + {(member as Member).last_activity_at ? ( + + {timeAgo(new Date((member as Member).last_activity_at || ''))} ago + + ) : ( + {upperCaseFirstLetter((member as InviteMember).invitation_status)} + )} + + + + {dateMediumLocalFormat(member.created_at)} + + +
+ ) +} + +export default RowMember diff --git a/libs/domains/organizations/feature/src/lib/settings-members/settings-members.spec.tsx b/libs/domains/organizations/feature/src/lib/settings-members/settings-members.spec.tsx new file mode 100644 index 00000000000..f9527279413 --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/settings-members/settings-members.spec.tsx @@ -0,0 +1,206 @@ +import { useAuth0 } from '@auth0/auth0-react' +import { type ReactNode } from 'react' +import { inviteMembersMock, membersMock } from '@qovery/shared/factories' +import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests' +import * as useAvailableRolesHook from '../hooks/use-available-roles/use-available-roles' +import * as useCreateInviteMemberHook from '../hooks/use-create-invite-member/use-create-invite-member' +import * as useDeleteInviteMemberHook from '../hooks/use-delete-invite-member/use-delete-invite-member' +import * as useDeleteMemberHook from '../hooks/use-delete-member/use-delete-member' +import * as useEditMemberRoleHook from '../hooks/use-edit-member-role/use-edit-member-role' +import * as useInviteMembersHook from '../hooks/use-invite-members/use-invite-members' +import * as useMembersHook from '../hooks/use-members/use-members' +import * as useTransferOwnershipMemberRoleHook from '../hooks/use-transfer-ownership-member-role/use-transfer-ownership-member-role' +import CreateModal from './create-modal/create-modal' +import { SettingsMembers } from './settings-members' + +const mockOpenModal = jest.fn() +const mockCloseModal = jest.fn() +const mockOpenModalConfirmation = jest.fn() + +jest.mock('@qovery/shared/ui', () => { + const actual = jest.requireActual('@qovery/shared/ui') + return { + ...actual, + useModal: () => ({ + openModal: mockOpenModal, + closeModal: mockCloseModal, + }), + useModalConfirmation: () => ({ + openModalConfirmation: mockOpenModalConfirmation, + }), + } +}) + +const mockUser = { sub: 'owner-id' } + +jest.mock('@auth0/auth0-react', () => ({ + ...jest.requireActual('@auth0/auth0-react'), + Auth0Provider: ({ children }: { children: ReactNode }) => children, + useAuth0: jest.fn(), +})) + +jest.mock('@tanstack/react-router', () => ({ + ...jest.requireActual('@tanstack/react-router'), + useParams: () => ({ organizationId: 'org-1' }), +})) + +const useMembersMock = jest.spyOn(useMembersHook, 'useMembers') as jest.Mock +const useInviteMembersMock = jest.spyOn(useInviteMembersHook, 'useInviteMembers') as jest.Mock +const useAvailableRolesMock = jest.spyOn(useAvailableRolesHook, 'useAvailableRoles') as jest.Mock +const useEditMemberRoleMock = jest.spyOn(useEditMemberRoleHook, 'useEditMemberRole') as jest.Mock +const useDeleteMemberMock = jest.spyOn(useDeleteMemberHook, 'useDeleteMember') as jest.Mock +const useDeleteInviteMemberMock = jest.spyOn(useDeleteInviteMemberHook, 'useDeleteInviteMember') as jest.Mock +const useTransferOwnershipMemberRoleMock = jest.spyOn( + useTransferOwnershipMemberRoleHook, + 'useTransferOwnershipMemberRole' +) as jest.Mock +const useCreateInviteMemberMock = jest.spyOn(useCreateInviteMemberHook, 'useCreateInviteMember') as jest.Mock + +const availableRoles = [ + { id: 'role-owner', name: 'Owner' }, + { id: 'role-admin', name: 'Admin' }, +] + +const ownerMember = { + ...membersMock(1, 'Owner', 'owner-id')[0], + role_id: 'role-owner', + role_name: 'Owner', +} + +const adminMember = { + ...membersMock(1, 'Admin', 'admin-id')[0], + role_id: 'role-admin', + role_name: 'Admin', +} + +const inviteMember = { + ...inviteMembersMock(1)[0], + id: 'invite-1', + email: 'invite@qovery.com', + role_id: 'role-admin', + role_name: 'Admin', +} + +describe('SettingsMembers', () => { + const editMemberRoleMock = jest.fn() + const deleteMemberMock = jest.fn() + const deleteInviteMemberMock = jest.fn() + const transferOwnershipMemberRoleMock = jest.fn() + const createInviteMemberMock = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + + const useAuth0Mock = useAuth0 as jest.MockedFunction + useAuth0Mock.mockReturnValue({ user: mockUser } as ReturnType) + + useMembersMock.mockReturnValue({ + data: [ownerMember, adminMember], + isSuccess: true, + error: undefined, + }) + useInviteMembersMock.mockReturnValue({ data: [inviteMember] }) + useAvailableRolesMock.mockReturnValue({ data: availableRoles }) + useEditMemberRoleMock.mockReturnValue({ mutateAsync: editMemberRoleMock }) + useDeleteMemberMock.mockReturnValue({ mutateAsync: deleteMemberMock }) + useDeleteInviteMemberMock.mockReturnValue({ mutateAsync: deleteInviteMemberMock }) + useTransferOwnershipMemberRoleMock.mockReturnValue({ mutateAsync: transferOwnershipMemberRoleMock }) + useCreateInviteMemberMock.mockReturnValue({ mutateAsync: createInviteMemberMock }) + }) + + it('should render successfully', () => { + const { baseElement } = renderWithProviders() + expect(baseElement).toBeTruthy() + }) + + it('should open create modal when clicking add member', async () => { + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByRole('button', { name: /add member/i })) + + expect(mockOpenModal).toHaveBeenCalled() + + const [{ content, options }] = mockOpenModal.mock.calls[0] + expect(options).toEqual(expect.objectContaining({ fakeModal: true })) + expect(content.type).toBe(CreateModal) + }) + + it('should update member role when selecting a new role', async () => { + const { userEvent } = renderWithProviders() + + const roleButtons = screen.getAllByRole('button', { name: /member role/i }) + const editableButton = roleButtons.find((button) => !button.hasAttribute('disabled')) + + if (!editableButton) { + throw new Error('No editable role button found') + } + + await userEvent.click(editableButton) + await userEvent.click(screen.getByRole('menuitem', { name: 'Owner' })) + + await waitFor(() => { + expect(editMemberRoleMock).toHaveBeenCalledWith({ + organizationId: 'org-1', + memberRoleUpdateRequest: { + user_id: adminMember.id, + role_id: 'role-owner', + }, + }) + }) + }) + + it('should open confirmation and transfer ownership', async () => { + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByRole('button', { name: /member actions/i })) + await userEvent.click(screen.getByText('Transfer ownership')) + + expect(mockOpenModalConfirmation).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Confirm ownership transfer', + name: adminMember.name, + }) + ) + + const [{ action }] = mockOpenModalConfirmation.mock.calls[0] + await action() + + expect(transferOwnershipMemberRoleMock).toHaveBeenCalledWith({ + organizationId: 'org-1', + userId: adminMember.id, + }) + }) + + it('should resend invite for pending members', async () => { + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByRole('button', { name: /invite actions/i })) + await userEvent.click(screen.getByText('Resend invite')) + + await waitFor(() => { + expect(deleteInviteMemberMock).toHaveBeenCalledWith({ + organizationId: 'org-1', + inviteId: inviteMember.id, + }) + expect(createInviteMemberMock).toHaveBeenCalledWith({ + organizationId: 'org-1', + inviteMemberRequest: { + email: inviteMember.email, + role_id: inviteMember.role_id, + }, + }) + }) + }) + + it('should display permission error callout when access is forbidden', () => { + useMembersMock.mockReturnValue({ + data: [], + isSuccess: false, + error: { response: { status: 403 } }, + }) + + renderWithProviders() + + expect(screen.getByText('Permission denied')).toBeInTheDocument() + }) +}) diff --git a/libs/domains/organizations/feature/src/lib/settings-members/settings-members.tsx b/libs/domains/organizations/feature/src/lib/settings-members/settings-members.tsx new file mode 100644 index 00000000000..9c22a127843 --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/settings-members/settings-members.tsx @@ -0,0 +1,510 @@ +import { useAuth0 } from '@auth0/auth0-react' +import { useParams } from '@tanstack/react-router' +import { + type SortingState, + createColumnHelper, + flexRender, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table' +import { + EnvironmentModeEnum, + type InviteMember, + type InviteMemberRequest, + type Member, + type OrganizationAvailableRole, +} from 'qovery-typescript-axios' +import { Suspense, useEffect, useMemo, useRef, useState } from 'react' +import { match } from 'ts-pattern' +import { SettingsHeading } from '@qovery/shared/console-shared' +import { MemberRoleEnum } from '@qovery/shared/enums' +import { + Button, + Callout, + Icon, + Section, + Skeleton as SkeletonPrimitive, + TableFilter, + TablePrimitives, + useModal, + useModalConfirmation, +} from '@qovery/shared/ui' +import { useDocumentTitle } from '@qovery/shared/util-hooks' +import { pluralize } from '@qovery/shared/util-js' +import { NODE_ENV } from '@qovery/shared/util-node-env' +import { type SerializedError } from '@qovery/shared/utils' +import { useAvailableRoles } from '../hooks/use-available-roles/use-available-roles' +import { useCreateInviteMember } from '../hooks/use-create-invite-member/use-create-invite-member' +import { useDeleteInviteMember } from '../hooks/use-delete-invite-member/use-delete-invite-member' +import { useDeleteMember } from '../hooks/use-delete-member/use-delete-member' +import { useEditMemberRole } from '../hooks/use-edit-member-role/use-edit-member-role' +import { useInviteMembers } from '../hooks/use-invite-members/use-invite-members' +import { useMembers } from '../hooks/use-members/use-members' +import { useTransferOwnershipMemberRole } from '../hooks/use-transfer-ownership-member-role/use-transfer-ownership-member-role' +import CreateModal from './create-modal/create-modal' +import RowMember from './row-member/row-member' + +const { Table } = TablePrimitives + +const Skeleton = () => { + const columnSizes = ['35%', '22%', '21%', '21%'] + + return ( + + + + {columnSizes.map((columnSize, index) => ( + + + + ))} + + + + {Array.from({ length: 10 }).map((_, rowIndex) => ( + + +
+
+ +
+ + +
+
+ +
+
+ + + + + + + + + +
+ ))} +
+
+ ) +} + +interface MembersTableProps { + editMemberRole: (userId: string, roleId: string) => void + deleteMember: (userId: string) => void + deleteInviteMember: (inviteId: string) => void + resendInvite: (inviteId: string, data: InviteMemberRequest) => void + transferOwnership: (user: Member) => void + loadingUpdateRole: { userId: string; loading: boolean } + members?: Member[] + inviteMembers?: InviteMember[] + availableRoles?: OrganizationAvailableRole[] + userId?: string +} + +interface MembersTableWithDataProps extends Omit { + organizationId: string +} + +const MembersTable = (props: MembersTableProps) => { + const { + members = [], + inviteMembers = [], + deleteInviteMember, + availableRoles, + editMemberRole, + loadingUpdateRole, + deleteMember, + transferOwnership, + userId, + resendInvite, + } = props + + const columnSizes = [35, 22, 21, 21] + const membersCountLabel = `${pluralize(members.length, 'Member', 'Members')} (${members.length})` + + const memberColumnHelper = createColumnHelper() + const inviteColumnHelper = createColumnHelper() + + const memberColumns = useMemo( + () => [ + memberColumnHelper.display({ + id: 'member', + header: 'Member', + }), + memberColumnHelper.accessor('role_name', { + header: 'Roles', + enableColumnFilter: true, + enableSorting: false, + filterFn: 'arrIncludesSome', + }), + memberColumnHelper.accessor( + (member) => (member.last_activity_at ? new Date(member.last_activity_at).getTime() : 0), + { + id: 'last_activity_at', + header: 'Last activity', + enableColumnFilter: false, + enableSorting: true, + } + ), + memberColumnHelper.accessor((member) => new Date(member.created_at).getTime(), { + id: 'created_at', + header: 'Member since', + enableColumnFilter: false, + enableSorting: true, + }), + ], + [] + ) + + const inviteColumns = useMemo( + () => [ + inviteColumnHelper.display({ + id: 'invite_member', + header: 'Pending members', + }), + inviteColumnHelper.display({ + id: 'invite_roles', + header: 'Roles', + }), + inviteColumnHelper.display({ + id: 'invite_status', + header: 'Status', + }), + inviteColumnHelper.accessor((member) => new Date(member.created_at).getTime(), { + id: 'created_at', + header: 'Sent since', + enableColumnFilter: false, + enableSorting: true, + }), + ], + [] + ) + + const [memberSorting, setMemberSorting] = useState([]) + const [inviteSorting, setInviteSorting] = useState([]) + + const membersTable = useReactTable({ + data: members, + columns: memberColumns, + state: { + sorting: memberSorting, + }, + onSortingChange: setMemberSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + // https://github.com/TanStack/table/discussions/3192#discussioncomment-6458134 + defaultColumn: { + minSize: 0, + size: Number.MAX_SAFE_INTEGER, + maxSize: Number.MAX_SAFE_INTEGER, + }, + }) + + const inviteTable = useReactTable({ + data: inviteMembers, + columns: inviteColumns, + state: { + sorting: inviteSorting, + }, + onSortingChange: setInviteSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + // https://github.com/TanStack/table/discussions/3192#discussioncomment-6458134 + defaultColumn: { + minSize: 0, + size: Number.MAX_SAFE_INTEGER, + maxSize: Number.MAX_SAFE_INTEGER, + }, + }) + + const userIsOwner = members.find((member) => member.id === userId) + + return ( + <> + + + {membersTable.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header, index) => ( + + {header.isPlaceholder ? null : index === 0 ? ( + membersCountLabel + ) : header.column.getCanFilter() ? ( + + ) : header.column.getCanSort() ? ( + + ) : ( + flexRender(header.column.columnDef.header, header.getContext()) + )} + + ))} + + ))} + + + {membersTable.getRowModel().rows.map((row) => ( + + ))} + + + {inviteMembers.length > 0 && ( + + + {inviteTable.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header, index) => ( + + {header.isPlaceholder ? null : header.column.getCanSort() ? ( + + ) : ( + flexRender(header.column.columnDef.header, header.getContext()) + )} + + ))} + + ))} + + + {inviteTable.getRowModel().rows.map((row) => ( + + ))} + + + )} + + ) +} + +const MembersTableWithData = ({ + organizationId, + editMemberRole, + deleteMember, + deleteInviteMember, + resendInvite, + transferOwnership, + loadingUpdateRole, + userId, +}: MembersTableWithDataProps) => { + const { data: members = [] } = useMembers({ organizationId, suspense: true }) + const { data: inviteMembers = [] } = useInviteMembers({ organizationId, suspense: true }) + const { data: availableRoles = [] } = useAvailableRoles({ organizationId, suspense: true }) + + return ( + + ) +} + +export function SettingsMembers() { + useDocumentTitle('Members - Organization settings') + const { organizationId = '' } = useParams({ strict: false }) + const hasPreviousRequestFailed = useRef(false) + + const { error: membersError, isSuccess: isSuccessMembers } = useMembers({ organizationId }) + const { data: availableRoles = [] } = useAvailableRoles({ organizationId }) + + const hasPermissionError = (membersError as SerializedError)?.response?.status === 403 + const shouldShowPermissionError = hasPermissionError || hasPreviousRequestFailed.current + const { mutateAsync: editMemberRole } = useEditMemberRole() + const { mutateAsync: deleteMember } = useDeleteMember() + const { mutateAsync: deleteInviteMember } = useDeleteInviteMember() + const { mutateAsync: transferOwnershipMemberRole } = useTransferOwnershipMemberRole() + const { mutateAsync: createInviteMember } = useCreateInviteMember() + + const { user } = useAuth0() + + const { openModal, closeModal } = useModal() + const [loadingUpdateRole, setLoadingUpdateRole] = useState({ userId: '', loading: false }) + + const { openModalConfirmation } = useModalConfirmation() + + const onClickEditMemberRole = async (userId: string, roleId: string) => { + const data = { user_id: userId, role_id: roleId } + setLoadingUpdateRole({ userId, loading: true }) + + try { + await editMemberRole({ organizationId, memberRoleUpdateRequest: data }) + setLoadingUpdateRole({ userId, loading: false }) + } catch (error) { + console.error(error) + } + } + + const onClickDeleteMember = async (userId: string) => { + try { + await deleteMember({ organizationId, userId }) + } catch (error) { + console.error(error) + } + } + + const onClickRevokeMemberInvite = async (inviteId: string) => { + try { + await deleteInviteMember({ organizationId, inviteId }) + } catch (error) { + console.error(error) + } + } + + const onClickTransferOwnership = (user: Member) => { + openModalConfirmation({ + title: 'Confirm ownership transfer', + description: 'Confirm by entering the member name', + name: user?.name, + mode: NODE_ENV === 'production' ? EnvironmentModeEnum.PRODUCTION : EnvironmentModeEnum.DEVELOPMENT, + action: async () => { + try { + await transferOwnershipMemberRole({ organizationId, userId: user.id }) + } catch (error) { + console.error(error) + } + }, + }) + } + + const onClickResendInvite = async (inviteId: string, data: InviteMemberRequest) => { + try { + await deleteInviteMember({ organizationId, inviteId }) + await createInviteMember({ organizationId, inviteMemberRequest: data }) + } catch (error) { + console.error(error) + } + } + + const onAddMember = () => { + openModal({ + content: , + options: { + fakeModal: true, + }, + }) + } + + useEffect(() => { + if (isSuccessMembers) { + hasPreviousRequestFailed.current = false + } + + if (hasPermissionError) { + hasPreviousRequestFailed.current = true + } + }, [hasPermissionError, isSuccessMembers]) + + return ( +
+
+
+ + + +
+ {shouldShowPermissionError && ( + + + + + + Permission denied + + You do not have the permission to view other members. Please contact your organization administrator. + + + + )} +
+ }> + + +
+
+
+ ) +} diff --git a/libs/domains/service-helm/feature/src/lib/values-override-arguments-setting/__snapshots__/values-override-arguments-setting.spec.tsx.snap b/libs/domains/service-helm/feature/src/lib/values-override-arguments-setting/__snapshots__/values-override-arguments-setting.spec.tsx.snap index aaad23466c4..71ff8582b09 100644 --- a/libs/domains/service-helm/feature/src/lib/values-override-arguments-setting/__snapshots__/values-override-arguments-setting.spec.tsx.snap +++ b/libs/domains/service-helm/feature/src/lib/values-override-arguments-setting/__snapshots__/values-override-arguments-setting.spec.tsx.snap @@ -88,7 +88,7 @@ exports[`ValuesOverrideArgumentsSetting should match snapshot 1`] = ` class=" group relative flex items-center gap-4" >