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')
+ }}
+ >
+
+
+
+
+
+
+ );
+}
diff --git a/chaoscenter/web/src/views/InviteNewMembers/InviteUsersTable.tsx b/chaoscenter/web/src/views/InviteNewMembers/InviteUsersTable.tsx
new file mode 100644
index 00000000000..d2383e35f36
--- /dev/null
+++ b/chaoscenter/web/src/views/InviteNewMembers/InviteUsersTable.tsx
@@ -0,0 +1,104 @@
+import { ButtonVariation, Layout, SplitButton, SplitButtonOption, TableV2, useToaster } from '@harnessio/uicore';
+import React, { useMemo } from 'react';
+import type { Column, Row } from 'react-table';
+import type { QueryObserverResult, RefetchOptions, RefetchQueryFilters } from '@tanstack/react-query';
+import { useParams } from 'react-router-dom';
+import { PopoverPosition } from '@blueprintjs/core';
+import { useStrings } from '@strings';
+import { GetUsersForInvitationOkResponse, User, useSendInvitationMutation } from '@api/auth';
+import { killEvent } from '@utils';
+import { UserEmail, UserName } from './InviteNewMemberListColumns';
+import css from './InviteNewMemberTable.module.scss';
+
+interface InviteUsersTableViewProps {
+ users: User[];
+ getUsers: (
+ options?: (RefetchOptions & RefetchQueryFilters) | undefined
+ ) => Promise>;
+}
+
+export default function InviteUsersTableView({ users, getUsers }: InviteUsersTableViewProps): React.ReactElement {
+ const { getString } = useStrings();
+ const envColumns: Column[] = useMemo(
+ () => [
+ {
+ Header: 'MEMBERS',
+ id: 'username',
+ width: '40%',
+ Cell: UserName
+ },
+ {
+ Header: 'EMAIL',
+ id: 'email',
+ width: '30%',
+ Cell: UserEmail
+ },
+ {
+ Header: '',
+ id: 'threeDotMenu',
+ disableSortBy: true,
+ Cell: ({ row: { original: data } }: { row: Row }) => {
+ const { projectID } = useParams<{ projectID: string }>();
+ const { showSuccess } = useToaster();
+ const { mutate: sendInvitationMutation, isLoading } = useSendInvitationMutation(
+ {},
+ {
+ onSuccess: () => {
+ showSuccess(getString('invitationSuccess'));
+ getUsers();
+ }
+ }
+ );
+
+ return (
+
+
+
+ sendInvitationMutation({
+ body: {
+ projectID: projectID,
+ role: 'Editor',
+ userID: data.userID
+ }
+ })
+ }
+ />
+
+ sendInvitationMutation({
+ body: {
+ projectID: projectID,
+ role: 'Viewer',
+ userID: data.userID
+ }
+ })
+ }
+ />
+
+
+ );
+ }
+ }
+ ],
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ []
+ );
+ return columns={envColumns} data={users} className={css.inviteTable} />;
+}
diff --git a/chaoscenter/web/src/views/InviteNewMembers/index.tsx b/chaoscenter/web/src/views/InviteNewMembers/index.tsx
new file mode 100644
index 00000000000..2aa71ad06f5
--- /dev/null
+++ b/chaoscenter/web/src/views/InviteNewMembers/index.tsx
@@ -0,0 +1,3 @@
+import InviteUsersTableView from './InviteUsersTable';
+
+export default InviteUsersTableView;
diff --git a/chaoscenter/web/src/views/ProjectMembers/ActiveMembersListColumns.tsx b/chaoscenter/web/src/views/ProjectMembers/ActiveMembersListColumns.tsx
new file mode 100644
index 00000000000..0ba5a5b345b
--- /dev/null
+++ b/chaoscenter/web/src/views/ProjectMembers/ActiveMembersListColumns.tsx
@@ -0,0 +1,151 @@
+import type { Row } from 'react-table';
+import React from 'react';
+import { Color } from '@harnessio/design-system';
+import { Button, ButtonVariation, DropDown, Layout, SelectOption, Text, useToaster } from '@harnessio/uicore';
+import { useParams } from 'react-router-dom';
+import { useStrings } from '@strings';
+import { ProjectMember, useRemoveInvitationMutation, useSendInvitationMutation } from '@api/auth';
+interface MemberRow {
+ row: Row;
+}
+
+const MemberName = ({ row: { original: data } }: MemberRow): React.ReactElement => {
+ const { username, userID, name } = data;
+ const { getString } = useStrings();
+ return (
+
+
+ {name ?? username}
+
+
+
+ {getString('id')}: {userID}
+
+
+ );
+};
+
+const MemberEmail = ({ row: { original: data } }: MemberRow): React.ReactElement => {
+ const { email } = data;
+ return (
+
+
+ {email}
+
+
+ );
+};
+
+const MemberPermission = ({ row: { original: data } }: MemberRow): React.ReactElement => {
+ const { role } = data;
+ return (
+
+
+ {role}
+
+
+ );
+};
+
+const MemberPermissionDropdown = ({ row: { original: data } }: MemberRow): React.ReactElement => {
+ const { role } = data;
+ const [memberRole, setMemberRole] = React.useState(role);
+ const rolesDropDown: SelectOption[] = [
+ {
+ label: 'Editor',
+ value: 'Editor'
+ },
+ {
+ label: 'Viewer',
+ value: 'Viewer'
+ }
+ ];
+ return (
+
+
+ setMemberRole(option.label)} />
+
+
+ );
+};
+
+const InvitationOperation = ({ row: { original: data } }: MemberRow): React.ReactElement => {
+ const { projectID } = useParams<{ projectID: string }>();
+ const { getString } = useStrings();
+ const { role } = data;
+ const [memberRole, setMemberRole] = React.useState<'Editor' | 'Owner' | 'Viewer'>(role);
+ const rolesDropDown: SelectOption[] = [
+ {
+ label: 'Editor',
+ value: 'Editor'
+ },
+ {
+ label: 'Viewer',
+ value: 'Viewer'
+ }
+ ];
+
+ const { showSuccess } = useToaster();
+ const { mutate: sendInvitationMutation, isLoading: sendLoading } = useSendInvitationMutation(
+ {},
+ {
+ onSuccess: () => {
+ showSuccess(getString('invitationSuccess'));
+ }
+ }
+ );
+
+ const { mutate: removeInvitationMutation, isLoading: removeLoading } = useRemoveInvitationMutation(
+ {},
+ {
+ onSuccess: () => {
+ showSuccess(getString('invitationRemoveSuccess'));
+ }
+ }
+ );
+
+ return (
+
+
+ setMemberRole(option.label as 'Editor' | 'Owner' | 'Viewer')}
+ />
+
+
+
+
+ );
+};
+
+export { MemberName, MemberEmail, MemberPermission, InvitationOperation, MemberPermissionDropdown };
diff --git a/chaoscenter/web/src/views/ProjectMembers/ActiveMembersTable.tsx b/chaoscenter/web/src/views/ProjectMembers/ActiveMembersTable.tsx
new file mode 100644
index 00000000000..c910047994f
--- /dev/null
+++ b/chaoscenter/web/src/views/ProjectMembers/ActiveMembersTable.tsx
@@ -0,0 +1,119 @@
+import { Button, ButtonVariation, Dialog, Layout, Popover, TableV2, Text, useToggleOpen } from '@harnessio/uicore';
+import React, { useMemo } from 'react';
+import type { Column, Row } from 'react-table';
+import type { QueryObserverResult, RefetchOptions, RefetchQueryFilters } from '@tanstack/react-query';
+import { Classes, Menu, PopoverInteractionKind, Position } from '@blueprintjs/core';
+import { FontVariation } from '@harnessio/design-system';
+import { useStrings } from '@strings';
+import type { GetProjectMembersOkResponse, ProjectMember } from '@api/auth';
+import { killEvent } from '@utils';
+import { PermissionGroup } from '@models';
+import RbacMenuItem from '@components/RbacMenuItem';
+import RemoveMemberController from '@controllers/RemoveMember/RemoveMember';
+import Loader from '@components/Loader';
+import { MemberEmail, MemberName, MemberPermission } from './ActiveMembersListColumns';
+import css from './ProjectMember.module.scss';
+
+interface ActiveMembersTableViewProps {
+ activeMembers: ProjectMember[] | undefined;
+ isLoading: boolean;
+ getMembersRefetch: (
+ options?: (RefetchOptions & RefetchQueryFilters) | undefined
+ ) => Promise>;
+}
+export default function ActiveMembersTableView({
+ activeMembers,
+ getMembersRefetch,
+ isLoading
+}: ActiveMembersTableViewProps): React.ReactElement {
+ const { getString } = useStrings();
+ const envColumns: Column[] = useMemo(
+ () => [
+ {
+ Header: 'MEMBERS',
+ id: 'username',
+ width: '40%',
+ accessor: 'username',
+ Cell: MemberName
+ },
+ {
+ Header: 'EMAIL',
+ id: 'email',
+ accessor: 'email',
+ width: '30%',
+ Cell: MemberEmail
+ },
+ {
+ Header: 'PERMISSIONS',
+ id: 'role',
+ accessor: 'role',
+ width: '30%',
+ Cell: MemberPermission
+ },
+ {
+ Header: '',
+ id: 'threeDotMenu',
+ disableSortBy: true,
+ Cell: ({ row: { original: data } }: { row: Row }) => {
+ const { open: openDeleteModal, isOpen: isDeleteModalOpen, close: hideDeleteModal } = useToggleOpen();
+ // const { open: openEditModal, isOpen: isEditOpen, close: hideEditModal } = useToggleOpen();
+
+ return (
+
+
+
+
+
+ {isDeleteModalOpen && (
+
+ )}
+
+ );
+ }
+ }
+ ],
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ []
+ );
+ return (
+
+ Total Members: {activeMembers?.length ?? 0}
+ activeMembers?.length === 0,
+ messageTitle: getString('membersNotAvailableTitle'),
+ message: getString('membersNotAvailableMessage')
+ }}
+ >
+ {activeMembers && columns={envColumns} sortable data={activeMembers} />}
+
+
+ );
+}
diff --git a/chaoscenter/web/src/views/ProjectMembers/PendingMembersTable.tsx b/chaoscenter/web/src/views/ProjectMembers/PendingMembersTable.tsx
new file mode 100644
index 00000000000..2e6e22e5e1f
--- /dev/null
+++ b/chaoscenter/web/src/views/ProjectMembers/PendingMembersTable.tsx
@@ -0,0 +1,151 @@
+import { Button, ButtonVariation, DropDown, Layout, SelectOption, TableV2, Text, useToaster } from '@harnessio/uicore';
+import React, { useMemo } from 'react';
+import type { Column, Row } from 'react-table';
+import type { QueryObserverResult, RefetchOptions, RefetchQueryFilters } from '@tanstack/react-query';
+import { useParams } from 'react-router-dom';
+import { FontVariation } from '@harnessio/design-system';
+import { useStrings } from '@strings';
+import {
+ GetProjectMembersOkResponse,
+ ProjectMember,
+ useRemoveInvitationMutation,
+ useSendInvitationMutation
+} from '@api/auth';
+import Loader from '@components/Loader';
+import { MemberEmail, MemberName } from './ActiveMembersListColumns';
+
+interface PendingMembersTableViewProps {
+ pendingMembers: ProjectMember[] | undefined;
+ isLoading: boolean;
+ getPendingMembersRefetch: (
+ options?: (RefetchOptions & RefetchQueryFilters) | undefined
+ ) => Promise>;
+}
+export default function PendingMembersTableView({
+ pendingMembers,
+ getPendingMembersRefetch,
+ isLoading
+}: PendingMembersTableViewProps): React.ReactElement {
+ const { getString } = useStrings();
+
+ const envColumns: Column[] = useMemo(
+ () => [
+ {
+ Header: 'MEMBERS',
+ id: 'username',
+ width: '25%',
+ accessor: 'username',
+ Cell: MemberName
+ },
+ {
+ Header: 'EMAIL',
+ id: 'email',
+ accessor: 'email',
+ width: '25%',
+ Cell: MemberEmail
+ },
+ {
+ Header: 'PERMISSIONS',
+ id: 'Role',
+ width: '50%',
+ disableSortBy: true,
+ Cell: ({ row: { original: data } }: { row: Row }) => {
+ const { projectID } = useParams<{ projectID: string }>();
+ const { role } = data;
+ const [memberRole, setMemberRole] = React.useState<'Editor' | 'Owner' | 'Viewer'>(role);
+ const rolesDropDown: SelectOption[] = [
+ {
+ label: 'Editor',
+ value: 'Editor'
+ },
+ {
+ label: 'Viewer',
+ value: 'Viewer'
+ }
+ ];
+
+ const { showSuccess } = useToaster();
+ const { mutate: sendInvitationMutation, isLoading: sendLoading } = useSendInvitationMutation(
+ {},
+ {
+ onSuccess: () => {
+ showSuccess(getString('invitationSuccess'));
+ getPendingMembersRefetch();
+ }
+ }
+ );
+
+ const { mutate: removeInvitationMutation, isLoading: removeLoading } = useRemoveInvitationMutation(
+ {},
+ {
+ onSuccess: () => {
+ showSuccess(getString('invitationRemoveSuccess'));
+ getPendingMembersRefetch();
+ }
+ }
+ );
+
+ return (
+
+
+ setMemberRole(option.label as 'Editor' | 'Owner' | 'Viewer')}
+ />
+
+
+
+ sendInvitationMutation({
+ body: {
+ projectID: projectID,
+ userID: data.userID,
+ role: memberRole
+ }
+ })
+ }
+ variation={ButtonVariation.PRIMARY}
+ text={getString('resend')}
+ />
+
+ removeInvitationMutation({
+ body: {
+ projectID: projectID,
+ userID: data.userID
+ }
+ })
+ }
+ variation={ButtonVariation.SECONDARY}
+ text={getString('remove')}
+ />
+
+
+ );
+ }
+ }
+ ],
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ []
+ );
+ return (
+
+ Total Pending Invitations: {pendingMembers?.length ?? 0}
+ pendingMembers?.length === 0,
+ messageTitle: getString('pendingInvitationsNotAvailableTitle'),
+ message: getString('pendingInvitationsNotAvailableMessage')
+ }}
+ >
+ {pendingMembers && columns={envColumns} sortable data={pendingMembers} />}
+
+
+ );
+}
diff --git a/chaoscenter/web/src/views/ProjectMembers/ProjectMember.module.scss b/chaoscenter/web/src/views/ProjectMembers/ProjectMember.module.scss
new file mode 100644
index 00000000000..f681b564209
--- /dev/null
+++ b/chaoscenter/web/src/views/ProjectMembers/ProjectMember.module.scss
@@ -0,0 +1,34 @@
+.tabContainer {
+ :global {
+ .bp3-tabs {
+ display: flex;
+ flex-direction: column;
+ height: calc(100vh - 80px);
+ .bp3-tab-list {
+ width: 100%;
+ height: 40px;
+ border-bottom: 1px solid var(--grey-200);
+ padding: 0 var(--spacing-medium) !important;
+ }
+ .bp3-tab-panel {
+ width: 100%;
+ flex-grow: 1;
+ margin-top: 0 !important;
+ }
+ }
+ }
+}
+
+.toolbar {
+ margin-top: 0 !important;
+ border-bottom: 1px solid var(--grey-200);
+ box-shadow: 0px 0px 1px rgba(40, 41, 61, 0.04), 0px 2px 4px rgba(96, 97, 112, 0.16);
+}
+
+.nameChangeDialog {
+ padding-bottom: 0 !important;
+}
+
+.modalWithHelpPanel {
+ width: auto !important;
+}
diff --git a/chaoscenter/web/src/views/ProjectMembers/ProjectMember.module.scss.d.ts b/chaoscenter/web/src/views/ProjectMembers/ProjectMember.module.scss.d.ts
new file mode 100644
index 00000000000..666ae0b72d9
--- /dev/null
+++ b/chaoscenter/web/src/views/ProjectMembers/ProjectMember.module.scss.d.ts
@@ -0,0 +1,12 @@
+declare namespace ProjectMemberModuleScssNamespace {
+ export interface IProjectMemberModuleScss {
+ modalWithHelpPanel: string;
+ nameChangeDialog: string;
+ tabContainer: string;
+ toolbar: string;
+ }
+}
+
+declare const ProjectMemberModuleScssModule: ProjectMemberModuleScssNamespace.IProjectMemberModuleScss;
+
+export = ProjectMemberModuleScssModule;
diff --git a/chaoscenter/web/src/views/ProjectMembers/ProjectMembers.tsx b/chaoscenter/web/src/views/ProjectMembers/ProjectMembers.tsx
new file mode 100644
index 00000000000..d9d1e93aaed
--- /dev/null
+++ b/chaoscenter/web/src/views/ProjectMembers/ProjectMembers.tsx
@@ -0,0 +1,101 @@
+import React from 'react';
+import { Container, Layout, Tabs, useToggleOpen } from '@harnessio/uicore';
+import { Dialog, TabId } from '@blueprintjs/core';
+import DefaultLayout from '@components/DefaultLayout';
+import { useSearchParams, useUpdateSearchParams } from '@hooks';
+import { MembersTabs, PermissionGroup } from '@models';
+import RbacButton from '@components/RbacButton';
+import ActiveProjectMembersController from '@controllers/ActiveProjectMemberList/ActiveProjectMembers';
+import InviteUsersController from '@controllers/InviteNewMembers';
+import PendingProjectMembersController from '@controllers/PendingProjectMemberList/PendingProjectMembers';
+import { useStrings } from '@strings';
+import styles from './ProjectMember.module.scss';
+
+export default function ProjectMembersView(): React.ReactElement {
+ const searchParams = useSearchParams();
+ const updateSearchParams = useUpdateSearchParams();
+ const selectedTabId = searchParams.get('tab') as MembersTabs;
+ const { isOpen, close, open } = useToggleOpen();
+ const [activeTab, setActiveTab] = React.useState('overview');
+ const { getString } = useStrings();
+
+ React.useEffect(() => {
+ if (!selectedTabId) {
+ updateSearchParams({ tab: MembersTabs.ACTIVE });
+ } else {
+ setActiveTab(selectedTabId);
+ updateSearchParams({ tab: selectedTabId });
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedTabId]);
+
+ const handleTabChange = (tabID: MembersTabs): void => {
+ switch (tabID) {
+ case MembersTabs.ACTIVE:
+ setActiveTab(tabID);
+ updateSearchParams({ tab: MembersTabs.ACTIVE });
+ break;
+ case MembersTabs.PENDING:
+ setActiveTab(tabID);
+ updateSearchParams({ tab: MembersTabs.PENDING });
+ break;
+ }
+ };
+
+ return (
+
+
+
+
+
+ {
+ open();
+ }}
+ />
+
+ {isOpen && (
+
+ )}
+
+
+
+ )
+ },
+ {
+ id: 'pending-members',
+ title: getString('pendingMembers'),
+ panel:
+ }
+ ]}
+ />
+
+
+ );
+}
diff --git a/chaoscenter/web/src/views/ProjectMembers/index.tsx b/chaoscenter/web/src/views/ProjectMembers/index.tsx
new file mode 100644
index 00000000000..723ec8e9986
--- /dev/null
+++ b/chaoscenter/web/src/views/ProjectMembers/index.tsx
@@ -0,0 +1,3 @@
+import ProjectMembersView from './ProjectMembers';
+
+export default ProjectMembersView;
diff --git a/chaoscenter/web/src/views/RemoveMember/RemoveMember.tsx b/chaoscenter/web/src/views/RemoveMember/RemoveMember.tsx
new file mode 100644
index 00000000000..941208e3743
--- /dev/null
+++ b/chaoscenter/web/src/views/RemoveMember/RemoveMember.tsx
@@ -0,0 +1,55 @@
+import React from 'react';
+import { Button, ButtonVariation, Layout, Text } from '@harnessio/uicore';
+import { FontVariation } from '@harnessio/design-system';
+import type { UseMutateFunction } from '@tanstack/react-query';
+import { useParams } from 'react-router-dom';
+import { useStrings } from '@strings';
+import type { RemoveInvitationOkResponse, RemoveInvitationMutationProps } from '@api/auth';
+
+interface RemoveMemberViewProps {
+ handleClose: () => void;
+ userID: string;
+ username: string;
+ removeMemberMutation: UseMutateFunction<
+ RemoveInvitationOkResponse,
+ unknown,
+ RemoveInvitationMutationProps,
+ unknown
+ >;
+}
+
+export default function RemoveMemberView(props: RemoveMemberViewProps): React.ReactElement {
+ const { handleClose, username, removeMemberMutation, userID } = props;
+ const { getString } = useStrings();
+ const { projectID } = useParams<{ projectID: string }>();
+ return (
+
+
+ {getString('removeMember')}
+
+ {getString('removeMemberConfirmation', { username })}
+
+
+ removeMemberMutation(
+ {
+ body: {
+ projectID: projectID,
+ userID: userID
+ }
+ },
+ {
+ onSuccess: () => handleClose()
+ }
+ )
+ }
+ />
+ handleClose()} />
+
+
+ );
+}
diff --git a/chaoscenter/web/src/views/RemoveMember/index.ts b/chaoscenter/web/src/views/RemoveMember/index.ts
new file mode 100644
index 00000000000..10c84c40236
--- /dev/null
+++ b/chaoscenter/web/src/views/RemoveMember/index.ts
@@ -0,0 +1,3 @@
+import RemoveMemberView from './RemoveMember';
+
+export default RemoveMemberView;