diff --git a/chaoscenter/authentication/api/handlers/rest/project_handler.go b/chaoscenter/authentication/api/handlers/rest/project_handler.go index 31d5478a277..490f86b25e1 100644 --- a/chaoscenter/authentication/api/handlers/rest/project_handler.go +++ b/chaoscenter/authentication/api/handlers/rest/project_handler.go @@ -325,6 +325,9 @@ func SendInvitation(service services.ApplicationService) gin.HandlerFunc { newMember := &entities.Member{ UserID: user.ID, Role: *member.Role, + Username: user.Username, + Name: user.Name, + Email: user.Email, Invitation: entities.PendingInvitation, JoinedAt: time.Now().Unix(), } @@ -499,7 +502,7 @@ func RemoveInvitation(service services.ApplicationService) gin.HandlerFunc { case entities.DeclinedInvitation, entities.ExitedProject: { - c.JSON(400, gin.H{"message": "User is already not a part of your project"}) + c.JSON(400, gin.H{"message": "User is not a part of your project"}) return } } diff --git a/chaoscenter/authentication/api/handlers/rest/user_handlers.go b/chaoscenter/authentication/api/handlers/rest/user_handlers.go index 3ad333e6f08..ad5164d646d 100644 --- a/chaoscenter/authentication/api/handlers/rest/user_handlers.go +++ b/chaoscenter/authentication/api/handlers/rest/user_handlers.go @@ -164,7 +164,7 @@ func InviteUsers(service services.ApplicationService) gin.HandlerFunc { c.JSON(utils.ErrorStatusCodes[utils.ErrServerError], presenter.CreateErrorResponse(utils.ErrServerError)) return } - c.JSON(200, users) + c.JSON(200, gin.H{"data": users}) } } @@ -224,6 +224,9 @@ func LoginUser(service services.ApplicationService) gin.HandlerFunc { UserID: user.ID, Role: entities.RoleOwner, Invitation: entities.AcceptedInvitation, + Username: user.Username, + Name: user.Name, + Email: user.Email, JoinedAt: time.Now().Unix(), } var members []*entities.Member diff --git a/chaoscenter/authentication/pkg/project/repository.go b/chaoscenter/authentication/pkg/project/repository.go index 4c34e918186..ecdbd1f7abb 100644 --- a/chaoscenter/authentication/pkg/project/repository.go +++ b/chaoscenter/authentication/pkg/project/repository.go @@ -204,6 +204,7 @@ func (r repository) RemoveInvitation(projectID string, userID string, invitation result, err := r.Collection.UpdateOne(context.TODO(), query, update) if err != nil { + // TODO check it's usage if invitation == entities.AcceptedInvitation { return err } diff --git a/chaoscenter/web/src/components/SideNav/SideNav.tsx b/chaoscenter/web/src/components/SideNav/SideNav.tsx index 226b4777bf8..cf1f5fe88ea 100644 --- a/chaoscenter/web/src/components/SideNav/SideNav.tsx +++ b/chaoscenter/web/src/components/SideNav/SideNav.tsx @@ -96,6 +96,7 @@ export default function SideNav(): ReactElement { + diff --git a/chaoscenter/web/src/controllers/ActiveProjectMemberList/ActiveProjectMembers.tsx b/chaoscenter/web/src/controllers/ActiveProjectMemberList/ActiveProjectMembers.tsx new file mode 100644 index 00000000000..1e0e836e220 --- /dev/null +++ b/chaoscenter/web/src/controllers/ActiveProjectMemberList/ActiveProjectMembers.tsx @@ -0,0 +1,17 @@ +import { useParams } from 'react-router-dom'; +import React from 'react'; +import ActiveMembersTableView from '@views/ProjectMembers/ActiveMembersTable'; +import { useGetProjectMembersQuery } from '@api/auth'; + +export default function ActiveProjectMembersController(): React.ReactElement { + const { projectID } = useParams<{ projectID: string }>(); + const { + data, + isLoading, + refetch: getMembersRefetch + } = useGetProjectMembersQuery({ project_id: projectID, state: 'accepted' }); + + return ( + + ); +} diff --git a/chaoscenter/web/src/controllers/ActiveProjectMemberList/index.tsx b/chaoscenter/web/src/controllers/ActiveProjectMemberList/index.tsx new file mode 100644 index 00000000000..cd6728acaff --- /dev/null +++ b/chaoscenter/web/src/controllers/ActiveProjectMemberList/index.tsx @@ -0,0 +1,3 @@ +import ActiveProjectMembersController from './ActiveProjectMembers'; + +export default ActiveProjectMembersController; diff --git a/chaoscenter/web/src/controllers/InviteNewMembers/InviteNewMembers.tsx b/chaoscenter/web/src/controllers/InviteNewMembers/InviteNewMembers.tsx new file mode 100644 index 00000000000..01f9a7385d5 --- /dev/null +++ b/chaoscenter/web/src/controllers/InviteNewMembers/InviteNewMembers.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; +import { ExpandingSearchInput } from '@harnessio/uicore'; +import { useStrings } from '@strings'; +import { useGetUsersForInvitationQuery, User } from '@api/auth'; +import InviteNewMembersView from '@views/InviteNewMembers/InviteNewMembers'; +import type { ProjectPathParams } from '@routes/RouteInterfaces'; + +interface InviteUsersControllerProps { + handleClose: () => void; +} + +export default function InviteUsersController({ handleClose }: InviteUsersControllerProps): React.ReactElement { + const { projectID } = useParams(); + const { data, isLoading, refetch: getUsers } = useGetUsersForInvitationQuery({ project_id: projectID }); + const { getString } = useStrings(); + + const [searchQuery, setSearchQuery] = React.useState(''); + const searchInput = ( + setSearchQuery(value)} /> + ); + + function doesFilterCriteriaMatch(user: User): boolean { + const updatedSearchQuery = searchQuery.trim(); + if ( + user.name?.toLowerCase().includes(updatedSearchQuery.toLowerCase()) || + user.username?.toLowerCase().includes(updatedSearchQuery.toLowerCase()) || + user.email?.toLowerCase().includes(updatedSearchQuery.toLowerCase()) + ) + return true; + return false; + } + + const filteredData = React.useMemo(() => { + if (!data?.data) return []; + return data.data.filter(user => doesFilterCriteriaMatch(user)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data, searchQuery]); + + return ( + + ); +} diff --git a/chaoscenter/web/src/controllers/InviteNewMembers/helper.ts b/chaoscenter/web/src/controllers/InviteNewMembers/helper.ts new file mode 100644 index 00000000000..db164b5e8ac --- /dev/null +++ b/chaoscenter/web/src/controllers/InviteNewMembers/helper.ts @@ -0,0 +1,17 @@ +import type { User } from '@api/auth'; +import type { InviteUserDetails } from './types'; + +export function generateInviteUsersTableContent(userData: Array | undefined): Array { + const content: Array = + userData && userData.length > 0 + ? userData.map(user => { + return { + id: user.userID, + username: user.username, + email: user.email ?? '', + name: user.name ?? '' + }; + }) + : []; + return content; +} diff --git a/chaoscenter/web/src/controllers/InviteNewMembers/index.tsx b/chaoscenter/web/src/controllers/InviteNewMembers/index.tsx new file mode 100644 index 00000000000..0e73c464066 --- /dev/null +++ b/chaoscenter/web/src/controllers/InviteNewMembers/index.tsx @@ -0,0 +1,3 @@ +import InviteUsersController from './InviteNewMembers'; + +export default InviteUsersController; diff --git a/chaoscenter/web/src/controllers/InviteNewMembers/types.ts b/chaoscenter/web/src/controllers/InviteNewMembers/types.ts new file mode 100644 index 00000000000..d8d5ff773d6 --- /dev/null +++ b/chaoscenter/web/src/controllers/InviteNewMembers/types.ts @@ -0,0 +1,6 @@ +export interface InviteUserDetails { + username: string; + id: string; + email: string; + name: string; +} diff --git a/chaoscenter/web/src/controllers/PendingProjectMemberList/PendingProjectMembers.tsx b/chaoscenter/web/src/controllers/PendingProjectMemberList/PendingProjectMembers.tsx new file mode 100644 index 00000000000..e3b8bcf52ec --- /dev/null +++ b/chaoscenter/web/src/controllers/PendingProjectMemberList/PendingProjectMembers.tsx @@ -0,0 +1,21 @@ +import { useParams } from 'react-router-dom'; +import React from 'react'; +import PendingMembersTableView from '@views/ProjectMembers/PendingMembersTable'; +import { useGetProjectMembersQuery } from '@api/auth'; + +export default function PendingProjectMembersController(): React.ReactElement { + const { projectID } = useParams<{ projectID: string }>(); + const { + data, + isLoading, + refetch: getPendingMembersRefetch + } = useGetProjectMembersQuery({ project_id: projectID, state: 'not_accepted' }); + + return ( + + ); +} diff --git a/chaoscenter/web/src/controllers/PendingProjectMemberList/index.tsx b/chaoscenter/web/src/controllers/PendingProjectMemberList/index.tsx new file mode 100644 index 00000000000..7edadbed282 --- /dev/null +++ b/chaoscenter/web/src/controllers/PendingProjectMemberList/index.tsx @@ -0,0 +1,3 @@ +import type PendingProjectMembersController from './PendingProjectMembers'; + +export default PendingProjectMembersController; diff --git a/chaoscenter/web/src/controllers/RemoveMember/RemoveMember.tsx b/chaoscenter/web/src/controllers/RemoveMember/RemoveMember.tsx new file mode 100644 index 00000000000..b6adc28b6ae --- /dev/null +++ b/chaoscenter/web/src/controllers/RemoveMember/RemoveMember.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { useToaster } from '@harnessio/uicore'; +import type { QueryObserverResult, RefetchOptions, RefetchQueryFilters } from '@tanstack/react-query'; +import { GetProjectMembersOkResponse, useRemoveInvitationMutation } from '@api/auth'; +import RemoveMemberView from '@views/RemoveMember'; + +interface RemoveMemberControllerProps { + userID: string; + username: string; + hideDeleteModal: () => void; + getMembersRefetch: ( + options?: (RefetchOptions & RefetchQueryFilters) | undefined + ) => Promise>; +} +export default function RemoveMemberController(props: RemoveMemberControllerProps): React.ReactElement { + const { userID, username, hideDeleteModal, getMembersRefetch } = props; + const { showSuccess } = useToaster(); + + const { mutate: removeMemberMutation } = useRemoveInvitationMutation( + {}, + { + onSuccess: data => { + getMembersRefetch(); + showSuccess(data.message); + } + } + ); + + return ( + + ); +} diff --git a/chaoscenter/web/src/views/Environments/EnvironmentPage.tsx b/chaoscenter/web/src/controllers/RemoveMember/index.tsx similarity index 100% rename from chaoscenter/web/src/views/Environments/EnvironmentPage.tsx rename to chaoscenter/web/src/controllers/RemoveMember/index.tsx diff --git a/chaoscenter/web/src/models/index.ts b/chaoscenter/web/src/models/index.ts index f59c3a574a5..4a01c6c5ded 100644 --- a/chaoscenter/web/src/models/index.ts +++ b/chaoscenter/web/src/models/index.ts @@ -9,5 +9,6 @@ export * from './chaosStudio'; export * from './license'; export * from './rbac'; export * from './user'; +export * from './projectMembers'; export * from './token'; export * from './projects'; diff --git a/chaoscenter/web/src/models/projectMembers.ts b/chaoscenter/web/src/models/projectMembers.ts new file mode 100644 index 00000000000..e3aaf1b716a --- /dev/null +++ b/chaoscenter/web/src/models/projectMembers.ts @@ -0,0 +1,4 @@ +export enum MembersTabs { + ACTIVE = 'active-members', + PENDING = 'pending-members' +} diff --git a/chaoscenter/web/src/routes/RouteDefinitions.ts b/chaoscenter/web/src/routes/RouteDefinitions.ts index 46db0c2757c..f55cd8c619c 100644 --- a/chaoscenter/web/src/routes/RouteDefinitions.ts +++ b/chaoscenter/web/src/routes/RouteDefinitions.ts @@ -23,6 +23,8 @@ export interface UseRouteDefinitionsProps { toChaosInfrastructures(params: { environmentID: string }): string; toKubernetesChaosInfrastructures(params: { environmentID: string }): string; toKubernetesChaosInfrastructureDetails(params: { chaosInfrastructureID: string; environmentID: string }): string; + // Project scoped + toProjectMembers(): string; // Account Scoped Routes toAccountSettingsOverview(): string; } @@ -56,5 +58,7 @@ export const paths: UseRouteDefinitionsProps = { toKubernetesChaosInfrastructureDetails: ({ chaosInfrastructureID, environmentID }) => `/environments/${environmentID}/kubernetes/${chaosInfrastructureID}`, // Account Scoped Routes - toAccountSettingsOverview: () => '/settings/overview' + toAccountSettingsOverview: () => '/settings/overview', + // user route + toProjectMembers: () => '/members' }; diff --git a/chaoscenter/web/src/routes/RouteDestinations.tsx b/chaoscenter/web/src/routes/RouteDestinations.tsx index c12911ca200..15479cac599 100644 --- a/chaoscenter/web/src/routes/RouteDestinations.tsx +++ b/chaoscenter/web/src/routes/RouteDestinations.tsx @@ -20,6 +20,7 @@ import { getUserDetails } from '@utils'; import EnvironmentController from '@controllers/Environments'; import { isUserAuthenticated } from 'utils/auth'; import AccountSettingsController from '@controllers/AccountSettings'; +import ProjectMembersView from '@views/ProjectMembers'; const experimentID = ':experimentID'; const runID = ':runID'; @@ -107,6 +108,8 @@ export function RoutesWithAuthentication(): React.ReactElement { path={projectMatchPaths.toKubernetesChaosInfrastructureDetails({ environmentID, chaosInfrastructureID })} component={KubernetesChaosInfrastructureDetailsController} /> + {/* Project */} + ); } diff --git a/chaoscenter/web/src/routes/RouteInterfaces.ts b/chaoscenter/web/src/routes/RouteInterfaces.ts new file mode 100644 index 00000000000..b2bd605859a --- /dev/null +++ b/chaoscenter/web/src/routes/RouteInterfaces.ts @@ -0,0 +1,3 @@ +export interface ProjectPathParams { + projectID: string; +} diff --git a/chaoscenter/web/src/strings/strings.en.yaml b/chaoscenter/web/src/strings/strings.en.yaml index 91aa4f1c8e6..baa1eb2ef21 100644 --- a/chaoscenter/web/src/strings/strings.en.yaml +++ b/chaoscenter/web/src/strings/strings.en.yaml @@ -19,6 +19,7 @@ account: Account accountURL: Account URL actionItems: Action Items active: Active +activeMembers: Active members actualResilienceScore: Actual Resilience Score add: Add addExperimentToChaosHub: Add Experiment to ChaosHub @@ -425,7 +426,10 @@ intervalOptional: Interval (Optional) invalidEmailText: Please enter a valid email address invalidSelection: Invalid Selection invalidText: Invalid +invitationRemoveSuccess: Invitation cancelled successfully +invitationSuccess: Invitation sent successfully invitations: Invitations +inviteAs: Invite as invitedBy: Invited By isRequired: '{{field}} is required field' issueSupport: If the issue still persists, consider reaching out to support! @@ -490,6 +494,8 @@ manualInterruption: Manual Interruption needed markRunAsComplete: Mark Run as Complete markRunAsCompleteDescription: This will mark the run as complete and you will no longer be able to re-run. meetsExpectations: The resilience score has met the expectation. +membersNotAvailableMessage: No active project members available +membersNotAvailableTitle: No members present menuItems: deleteHub: Delete Hub editHub: Edit Hub @@ -534,6 +540,7 @@ newChaosExperiment: New Chaos Experiment newChaosHub: New ChaosHub newChaosInfrastructure: Enable Chaos newExperiment: New Experiment +newMember: New Member newPassword: New Password newProbe: New Probe newUpdates: New Updates @@ -644,6 +651,9 @@ passwordResetSuccess: Password reset successfully passwordsDoNotMatch: Passwords do not match pauseRun: Pause Run pending: Pending +pendingInvitationsNotAvailableMessage: No pending invitations present +pendingInvitationsNotAvailableTitle: No invitations present +pendingMembers: Pending members pendingTime: >- It may take 3-5 minutes after applying the manifest for the infrastructure to be connected. @@ -749,8 +759,12 @@ recurringRun: Recurring Run (Cron) recurringSchedule: Recurring Schedule referencedBy: Referenced By registryName: Registry Name +remove: Remove +removeMember: Remove Member +removeMemberConfirmation: Are you sure you want to remove {{username}}? required: Required rerun: Rerun +resend: Resend resetFilters: Reset Filters resetPassword: Reset Password resilienceOverview: Resilience Overview @@ -963,6 +977,8 @@ userCreatedOn: User Created On userManagement: User Management username: Username usernameIsRequired: Username is a required field +usersNotAvailableMessage: No users available to send invitation +usersNotAvailableTitle: No users available validationError: Validation Error value: Value valuePlaceholder: Enter a value diff --git a/chaoscenter/web/src/strings/types.ts b/chaoscenter/web/src/strings/types.ts index 8e1c107e820..affbb74217f 100644 --- a/chaoscenter/web/src/strings/types.ts +++ b/chaoscenter/web/src/strings/types.ts @@ -22,6 +22,7 @@ export interface StringsMap { 'accountURL': unknown 'actionItems': unknown 'active': unknown + 'activeMembers': unknown 'actualResilienceScore': unknown 'add': unknown 'addExperimentToChaosHub': unknown @@ -352,7 +353,10 @@ export interface StringsMap { 'invalidEmailText': unknown 'invalidSelection': unknown 'invalidText': unknown + 'invitationRemoveSuccess': unknown + 'invitationSuccess': unknown 'invitations': unknown + 'inviteAs': unknown 'invitedBy': unknown 'isRequired': PrimitiveObject<'field'> 'issueSupport': unknown @@ -403,6 +407,8 @@ export interface StringsMap { 'markRunAsComplete': unknown 'markRunAsCompleteDescription': unknown 'meetsExpectations': unknown + 'membersNotAvailableMessage': unknown + 'membersNotAvailableTitle': unknown 'menuItems.deleteHub': unknown 'menuItems.editHub': unknown 'menuItems.syncHub': unknown @@ -441,6 +447,7 @@ export interface StringsMap { 'newChaosHub': unknown 'newChaosInfrastructure': unknown 'newExperiment': unknown + 'newMember': unknown 'newPassword': unknown 'newProbe': unknown 'newUpdates': unknown @@ -530,6 +537,9 @@ export interface StringsMap { 'passwordsDoNotMatch': unknown 'pauseRun': unknown 'pending': unknown + 'pendingInvitationsNotAvailableMessage': unknown + 'pendingInvitationsNotAvailableTitle': unknown + 'pendingMembers': unknown 'pendingTime': unknown 'phase': unknown 'platform': unknown @@ -621,8 +631,12 @@ export interface StringsMap { 'recurringSchedule': unknown 'referencedBy': unknown 'registryName': unknown + 'remove': unknown + 'removeMember': unknown + 'removeMemberConfirmation': PrimitiveObject<'username'> 'required': unknown 'rerun': unknown + 'resend': unknown 'resetFilters': unknown 'resetPassword': unknown 'resilienceOverview': unknown @@ -819,6 +833,8 @@ export interface StringsMap { 'userManagement': unknown 'username': unknown 'usernameIsRequired': unknown + 'usersNotAvailableMessage': unknown + 'usersNotAvailableTitle': unknown 'validationError': unknown 'value': unknown 'valuePlaceholder': unknown diff --git a/chaoscenter/web/src/views/Environments/EnvironmentList/EnvironmentsList.tsx b/chaoscenter/web/src/views/Environments/EnvironmentList/EnvironmentsList.tsx index 5d82e68698b..4e00c1bc809 100644 --- a/chaoscenter/web/src/views/Environments/EnvironmentList/EnvironmentsList.tsx +++ b/chaoscenter/web/src/views/Environments/EnvironmentList/EnvironmentsList.tsx @@ -58,13 +58,6 @@ export default function EnvironmentListView({ accessor: 'name', Cell: EnvironmentName }, - // { - // Header: 'Description', - // id: 'description', - // accessor: 'description', - // width: '35%', - // Cell: EnvironmentDescription - // }, { Header: 'TYPE', id: 'type', @@ -86,7 +79,7 @@ export default function EnvironmentListView({ } ], // eslint-disable-next-line react-hooks/exhaustive-deps - [getString] + [] ); return ( diff --git a/chaoscenter/web/src/views/InviteNewMembers/InviteNewMemberListColumns.tsx b/chaoscenter/web/src/views/InviteNewMembers/InviteNewMemberListColumns.tsx new file mode 100644 index 00000000000..814562a9997 --- /dev/null +++ b/chaoscenter/web/src/views/InviteNewMembers/InviteNewMemberListColumns.tsx @@ -0,0 +1,41 @@ +import { Layout, Text } from '@harnessio/uicore'; +import type { Row } from 'react-table'; +import React from 'react'; +import { Color } from '@harnessio/design-system'; +import { useStrings } from '@strings'; +import type { InviteUserDetails } from '@controllers/InviteNewMembers/types'; + +interface MemberRow { + row: Row; +} + +const UserName = ({ row: { original: data } }: MemberRow): React.ReactElement => { + const { username, id, name } = data; + const { getString } = useStrings(); + return ( + + {name ?? username} + + + {getString('id')}: + + + {id} + + + + ); +}; + +const UserEmail = ({ row: { original: data } }: MemberRow): React.ReactElement => { + const { email } = data; + return ( + + + {email} + + + ); +}; + +export { UserName, UserEmail }; diff --git a/chaoscenter/web/src/views/InviteNewMembers/InviteNewMemberTable.module.scss b/chaoscenter/web/src/views/InviteNewMembers/InviteNewMemberTable.module.scss new file mode 100644 index 00000000000..1d138e6adbb --- /dev/null +++ b/chaoscenter/web/src/views/InviteNewMembers/InviteNewMemberTable.module.scss @@ -0,0 +1,3 @@ +.inviteTable { + width: 100%; +} diff --git a/chaoscenter/web/src/views/InviteNewMembers/InviteNewMemberTable.module.scss.d.ts b/chaoscenter/web/src/views/InviteNewMembers/InviteNewMemberTable.module.scss.d.ts new file mode 100644 index 00000000000..fe5265b4a76 --- /dev/null +++ b/chaoscenter/web/src/views/InviteNewMembers/InviteNewMemberTable.module.scss.d.ts @@ -0,0 +1,9 @@ +declare namespace InviteNewMemberTableModuleScssNamespace { + export interface IInviteNewMemberTableModuleScss { + inviteTable: string; + } +} + +declare const InviteNewMemberTableModuleScssModule: InviteNewMemberTableModuleScssNamespace.IInviteNewMemberTableModuleScss; + +export = InviteNewMemberTableModuleScssModule; diff --git a/chaoscenter/web/src/views/InviteNewMembers/InviteNewMembers.tsx b/chaoscenter/web/src/views/InviteNewMembers/InviteNewMembers.tsx new file mode 100644 index 00000000000..a6de91a1c07 --- /dev/null +++ b/chaoscenter/web/src/views/InviteNewMembers/InviteNewMembers.tsx @@ -0,0 +1,53 @@ +import { FontVariation } from '@harnessio/design-system'; +import { Container, ButtonVariation, Layout, Button, Text } from '@harnessio/uicore'; +import React from 'react'; +import { Icon } from '@harnessio/icons'; +import type { QueryObserverResult, RefetchOptions, RefetchQueryFilters } from '@tanstack/react-query'; +import Loader from '@components/Loader'; +import type { GetUsersForInvitationOkResponse, User } from '@api/auth'; +import { useStrings } from '@strings'; +import InviteUsersTableView from './InviteUsersTable'; + +interface InviteNewMembersViewProps { + isLoading: boolean; + handleClose: () => void; + data: User[]; + searchInput: React.ReactElement; + getUsers: ( + options?: (RefetchOptions & RefetchQueryFilters) | undefined + ) => Promise>; +} + +export default function InviteNewMembersView(props: InviteNewMembersViewProps): React.ReactElement { + const { isLoading, data, handleClose, getUsers, searchInput } = props; + const { getString } = useStrings(); + return ( + + + Choose members to add to the project + handleClose()} /> + + {searchInput} + + data.length === 0, + messageTitle: getString('usersNotAvailableTitle'), + message: getString('usersNotAvailableMessage') + }} + > + + +