Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(MembersRoute): add actions dropdown to the table of organization members TASK-987 TASK-990 #5309

Merged
merged 92 commits into from
Dec 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
92 commits
Select commit Hold shift + click to select a range
e17f6a7
Add endpoints to handle organization members
rajpatel24 Nov 6, 2024
024f911
Merge branch 'main' of github.com:kobotoolbox/kpi into task-963-creat…
rajpatel24 Nov 7, 2024
56cd7cf
add initial files for organization members route (WIP)
magicznyleszek Nov 12, 2024
95e48c4
pass org id to hook (WIP)
magicznyleszek Nov 12, 2024
0401fc4
Refactor organization member API to eliminate redundancy and optimize…
rajpatel24 Nov 13, 2024
1dec6eb
Merge branch 'main' into task-963-create-endpoints-to-handle-org-members
rajpatel24 Nov 13, 2024
99eea0c
Add `queryHookOptions` to `PaginatedQueryUniversalTable` to make the …
magicznyleszek Nov 13, 2024
9527848
Merge branch 'main' into leszek/task-980-members-table
magicznyleszek Nov 13, 2024
a75ca3b
add initial files for organization members route (WIP)
magicznyleszek Nov 12, 2024
f2301b7
pass org id to hook (WIP)
magicznyleszek Nov 12, 2024
2066a3b
Add `queryHookOptions` to `PaginatedQueryUniversalTable` to make the …
magicznyleszek Nov 13, 2024
864e244
style(universalTable) use Array<T> for columns prop (#5260)
magicznyleszek Nov 13, 2024
47f6cf1
Merge branch 'leszek/task-980-members-table' of github.com:kobotoolbo…
magicznyleszek Nov 13, 2024
1f1cc12
small fixes (WIP)
magicznyleszek Nov 13, 2024
3c174df
Add role-based validation tests for organization member permissions
rajpatel24 Nov 14, 2024
f432404
Merge branch 'main' into task-963-create-endpoints-to-handle-org-members
rajpatel24 Nov 14, 2024
d876fbc
Revert unintended change and fix linting issue
rajpatel24 Nov 14, 2024
89721fe
Merge branch 'task-963-create-endpoints-to-handle-org-members' into l…
magicznyleszek Nov 15, 2024
953b716
Refactor organization members API with updated permission logic and c…
rajpatel24 Nov 15, 2024
f8cb95b
Refactor organization members API with updated permission logic and c…
rajpatel24 Nov 15, 2024
3d0372a
Resolve merge conflicts
rajpatel24 Nov 15, 2024
906be3d
Fix failing tests
rajpatel24 Nov 15, 2024
d122576
Merge branch 'main' into task-963-create-endpoints-to-handle-org-members
rajpatel24 Nov 18, 2024
837c1ac
adjust MembersRoute table column sizes
magicznyleszek Nov 19, 2024
1222115
Merge branch 'task-963-create-endpoints-to-handle-org-members' into l…
magicznyleszek Nov 19, 2024
08bc12e
display badges in MembersRoute table and remove some WIP code
magicznyleszek Nov 19, 2024
78a4b51
Merge branch 'leszek/task-980-members-table' of github.com:kobotoolbo…
magicznyleszek Nov 19, 2024
94b47cb
Merge branch 'main' into task-963-create-endpoints-to-handle-org-members
magicznyleszek Nov 19, 2024
adf5cad
Merge branch 'task-963-create-endpoints-to-handle-org-members' into l…
magicznyleszek Nov 19, 2024
696bef0
Merge branch 'leszek/task-980-members-table' of github.com:kobotoolbo…
magicznyleszek Nov 19, 2024
01c189c
Update delete logic to remove user from user table along with organiz…
rajpatel24 Nov 19, 2024
89db1d1
Merge branch 'main' into task-963-create-endpoints-to-handle-org-members
magicznyleszek Nov 19, 2024
42d4f23
Merge branch 'task-963-create-endpoints-to-handle-org-members' into l…
magicznyleszek Nov 19, 2024
b5ccac7
add some comments
magicznyleszek Nov 19, 2024
3ea9420
add missing semicolon
magicznyleszek Nov 19, 2024
988b10b
Merge branch 'main' into task-963-create-endpoints-to-handle-org-members
magicznyleszek Nov 19, 2024
4951a1c
Merge branch 'task-963-create-endpoints-to-handle-org-members' into l…
magicznyleszek Nov 19, 2024
ac80999
use latest Avatar version in the table
magicznyleszek Nov 19, 2024
e5688a3
add functions for updating and removing organization member
magicznyleszek Nov 19, 2024
e5abfa0
Merge branch 'main' into task-963-create-endpoints-to-handle-org-members
magicznyleszek Nov 19, 2024
19d1b6a
Merge branch 'task-963-create-endpoints-to-handle-org-members' into l…
magicznyleszek Nov 19, 2024
7672143
Use useOrganizationQuery directly in useOrganizationMembersQuery
jamesrkiger Nov 19, 2024
c20c0f4
remove queryHookOptions from PaginatedQueryUniversalTable
magicznyleszek Nov 19, 2024
f250c27
add helpful comment
magicznyleszek Nov 20, 2024
7afa649
Merge branch 'leszek/task-980-members-table' into leszek/task-985-mem…
magicznyleszek Nov 20, 2024
4f0f55a
update comments
magicznyleszek Nov 20, 2024
1389d6e
Merge branch 'leszek/task-980-members-table' into leszek/task-985-mem…
magicznyleszek Nov 20, 2024
def1a7a
Refactor permissions to block external users from listing organizatio…
rajpatel24 Nov 20, 2024
c2cf803
Merge branch 'main' into task-963-create-endpoints-to-handle-org-members
magicznyleszek Nov 20, 2024
8c7ed27
Merge branch 'task-963-create-endpoints-to-handle-org-members' into l…
magicznyleszek Nov 20, 2024
40ca814
use OrganizationUserRole in membersQuery
magicznyleszek Nov 20, 2024
500df96
Merge branch 'leszek/task-980-members-table' into leszek/task-985-mem…
magicznyleszek Nov 21, 2024
3847958
Merge branch 'main' into leszek/task-985-member-mutation-api
magicznyleszek Nov 25, 2024
3e9048e
post merge conflict fixes
magicznyleszek Nov 25, 2024
eada4f4
create MemberRemoveModal
magicznyleszek Nov 27, 2024
a2552f9
add MemberActionsDropdown component
magicznyleszek Nov 27, 2024
3029c3b
use MemberActionsDropdown in MembersRoute
magicznyleszek Nov 27, 2024
76a39f1
Merge branch 'main' into task-987-members-table-actions-dropdown
magicznyleszek Nov 27, 2024
476df92
todo comments
magicznyleszek Nov 27, 2024
c40fe18
Merge branch 'main' into task-987-members-table-actions-dropdown
magicznyleszek Nov 28, 2024
c535196
most of work done
magicznyleszek Nov 28, 2024
3fd1042
change icon
magicznyleszek Nov 28, 2024
f44874c
polishing
magicznyleszek Nov 28, 2024
f6f1ea0
Merge branch 'main' into task-987-members-table-actions-dropdown
magicznyleszek Nov 28, 2024
126f000
linter fixes
magicznyleszek Nov 29, 2024
03c90c0
Merge branch 'leszek/task-985-member-mutation-api' into task-987-memb…
magicznyleszek Nov 29, 2024
5c8d255
Merge branch 'main' into leszek/task-985-member-mutation-api
magicznyleszek Nov 29, 2024
aa86b5b
Merge branch 'leszek/task-985-member-mutation-api' into task-987-memb…
magicznyleszek Nov 29, 2024
6774de0
Merge branch 'leszek/task-985-member-mutation-api' into task-987-memb…
magicznyleszek Nov 29, 2024
b44c430
Merge branch 'task-987-members-table-actions-dropdown' of github.com:…
magicznyleszek Nov 29, 2024
3611b5f
remove member using query
magicznyleszek Dec 2, 2024
90161f9
add hooks for member mutation
magicznyleszek Dec 2, 2024
bed7f67
Merge branch 'leszek/task-985-member-mutation-api' into task-987-memb…
magicznyleszek Dec 2, 2024
baa8002
self review cleanup
magicznyleszek Dec 2, 2024
680fd3b
simplify member mutation hooks
magicznyleszek Dec 2, 2024
e8bd8c4
Merge branch 'leszek/task-985-member-mutation-api' into task-987-memb…
magicznyleszek Dec 2, 2024
fd6d4a7
move removing logic closer to place it's being triggered
magicznyleszek Dec 2, 2024
c4b8947
move member removing closer to place it's being triggered
magicznyleszek Dec 2, 2024
2d48f6a
cleanup comment
magicznyleszek Dec 2, 2024
8907bf8
Merge branch 'main' into leszek/task-985-member-mutation-api
magicznyleszek Dec 3, 2024
fb16410
Merge branch 'leszek/task-985-member-mutation-api' into task-987-memb…
magicznyleszek Dec 3, 2024
9ddf55d
stop requiring orgId as parameter
magicznyleszek Dec 5, 2024
afd593e
Merge branch 'leszek/task-985-member-mutation-api' into task-987-memb…
magicznyleszek Dec 5, 2024
d8c6581
stop requreing orgId
magicznyleszek Dec 5, 2024
88646f2
Merge branch 'main' into task-987-members-table-actions-dropdown
jamesrkiger Dec 10, 2024
049492f
update placeholder
magicznyleszek Dec 10, 2024
2791697
rename username prop
magicznyleszek Dec 10, 2024
ec90b08
simplify text to display flow
magicznyleszek Dec 10, 2024
ebbc129
dont import unnecessarily
magicznyleszek Dec 10, 2024
6c9ca6f
remove comment
magicznyleszek Dec 10, 2024
1ad007f
code review fixes
magicznyleszek Dec 11, 2024
8846df1
Merge branch 'main' into task-987-members-table-actions-dropdown
magicznyleszek Dec 11, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions jsapp/js/account/organization/MemberActionsDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Libraries
import {useState} from 'react';
import cx from 'classnames';

// Partial components
import KoboDropdown from 'jsapp/js/components/common/koboDropdown';
import Button from 'jsapp/js/components/common/button';
import MemberRemoveModal from './MemberRemoveModal';

// Stores, hooks and utilities
import {useSession} from 'jsapp/js/stores/useSession';
import {getSimpleMMOLabel} from './organization.utils';
import envStore from 'jsapp/js/envStore';
import subscriptionStore from 'jsapp/js/account/subscriptionStore';

// Constants and types
import {OrganizationUserRole} from './organizationQuery';

// Styles
import styles from './memberActionsDropdown.module.scss';

interface MemberActionsDropdownProps {
targetUsername: string;
/**
* The role of the currently logged in user, i.e. the role of the user that
* wants to do the actions (not the role of the target member).
*/
currentUserRole: OrganizationUserRole;
}

/**
* A dropdown with all actions that can be taken towards an organization member.
*/
export default function MemberActionsDropdown(
{targetUsername, currentUserRole}: MemberActionsDropdownProps
) {
const session = useSession();
const [isRemoveModalVisible, setIsRemoveModalVisible] = useState(false);

// Wait for session
if (!session.currentLoggedAccount?.username) {
return null;
}

// Should Not Happenβ„’, but let's make it foolproof :) Members are not allowed
// to do anything here under any circumstances.
if (currentUserRole === OrganizationUserRole.member) {
return null;
}

// If logged in user is an admin and tries to remove themselves, we need
// different UI - thus we check it here.
const isAdminRemovingSelf = Boolean(
targetUsername === session.currentLoggedAccount?.username &&
currentUserRole === OrganizationUserRole.admin
);

// Different button label when user is removing themselves
let removeButtonLabel = t('Remove');
if (isAdminRemovingSelf) {
const mmoLabel = getSimpleMMOLabel(
envStore.data,
subscriptionStore.activeSubscriptions[0],
false,
false
);
removeButtonLabel = t('Leave ##TEAM_OR_ORGANIZATION##')
.replace('##TEAM_OR_ORGANIZATION##', mmoLabel);
}

return (
<>
{isRemoveModalVisible &&
<MemberRemoveModal
username={targetUsername}
isRemovingSelf={isAdminRemovingSelf}
onConfirmDone={() => {
setIsRemoveModalVisible(false);
}}
onCancel={() => setIsRemoveModalVisible(false)}
/>
}

<KoboDropdown
name={`member-actions-dropdown-${targetUsername}`}
placement='down-right'
hideOnMenuClick
triggerContent={<Button type='text' size='m' startIcon='more'/>}
menuContent={
<div className={styles.menuContenet}>
<Button
className={cx(styles.menuButton, styles.menuButtonRed)}
type='text'
size='m'
label={removeButtonLabel}
onClick={() => setIsRemoveModalVisible(true)}
/>
</div>
}
/>
</>
);
}
105 changes: 105 additions & 0 deletions jsapp/js/account/organization/MemberRemoveModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// Partial components
import Button from 'jsapp/js/components/common/button';
import InlineMessage from 'jsapp/js/components/common/inlineMessage';
import KoboModal from 'jsapp/js/components/modals/koboModal';
import KoboModalHeader from 'jsapp/js/components/modals/koboModalHeader';
import KoboModalContent from 'jsapp/js/components/modals/koboModalContent';
import KoboModalFooter from 'jsapp/js/components/modals/koboModalFooter';

// Stores, hooks and utilities
import {getSimpleMMOLabel} from './organization.utils';
import envStore from 'jsapp/js/envStore';
import subscriptionStore from 'jsapp/js/account/subscriptionStore';
import {useRemoveOrganizationMember} from './membersQuery';
import {notify} from 'alertifyjs';

interface MemberRemoveModalProps {
username: string;
isRemovingSelf: boolean;
onConfirmDone: () => void;
onCancel: () => void;
}

/**
* A confirmation prompt modal for removing a user from organization. Displays
* two buttons and warning message.
*
* Note: it's always open - if you need to hide it, just don't render it at
* the parent level.
*/
export default function MemberRemoveModal(
{
username,
isRemovingSelf,
onConfirmDone,
onCancel,
}: MemberRemoveModalProps
) {
const removeMember = useRemoveOrganizationMember();
const mmoLabel = getSimpleMMOLabel(
envStore.data,
subscriptionStore.activeSubscriptions[0],
false,
false
);

// There are two different sets of strings - one for removing a member, and
// one for leaving the organization.
const REMOVE_MEMBER_TEXT = {
title: t('Remove ##username## from this ##TEAM_OR_ORGANIZATION##'),
description: t('Are you sure you want to remove ##username## from this ##TEAM_OR_ORGANIZATION##?'),
dangerMessage: t('Removing them from this ##TEAM_OR_ORGANIZATION## also means they will immediately lose access to any projects owned by your ##TEAM_OR_ORGANIZATION##. This action cannot be undone.'),
confirmButtonLabel: t('Remove member'),
};
const REMOVE_SELF_TEXT = {
title: t('Leave this ##TEAM_OR_ORGANIZATION##'),
description: t('Are you sure you want to leave this ##TEAM_OR_ORGANIZATION##?'),
dangerMessage: t('You will immediately lose access to any projects owned by this ##TEAM_OR_ORGANIZATION##. This action cannot be undone.'),
confirmButtonLabel: t('Leave ##TEAM_OR_ORGANIZATION##'),
};
const textToDisplay = isRemovingSelf ? REMOVE_SELF_TEXT : REMOVE_MEMBER_TEXT;
// Replace placeholders with proper strings in chosen set:
for (const key in textToDisplay) {
const keyCast = key as keyof typeof textToDisplay;
textToDisplay[keyCast] = textToDisplay[keyCast]
.replaceAll('##username##', username)
.replaceAll('##TEAM_OR_ORGANIZATION##', mmoLabel);
}

return (
<KoboModal isOpen size='medium' onRequestClose={() => onCancel()}>
<KoboModalHeader>{textToDisplay.title}</KoboModalHeader>

<KoboModalContent>
<p>{textToDisplay.description}</p>

<InlineMessage type='error' icon='alert' message={textToDisplay.dangerMessage}/>
</KoboModalContent>

<KoboModalFooter>
<Button
type='secondary'
size='m'
onClick={onCancel}
label={t('Cancel')}
/>

<Button
type='danger'
size='m'
onClick={async () => {
try {
removeMember.mutateAsync(username);
} catch (error) {
notify('Failed to remove member', 'error');
} finally {
onConfirmDone();
}
}}
label={textToDisplay.confirmButtonLabel}
isPending={removeMember.isPending}
/>
</KoboModalFooter>
</KoboModal>
);
}
145 changes: 82 additions & 63 deletions jsapp/js/account/organization/MembersRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import PaginatedQueryUniversalTable from 'js/universalTable/paginatedQueryUniver
import LoadingSpinner from 'js/components/common/loadingSpinner';
import Avatar from 'js/components/common/avatar';
import Badge from 'jsapp/js/components/common/badge';
import MemberActionsDropdown from './MemberActionsDropdown';

// Stores, hooks and utilities
import {formatTime} from 'js/utils';
import {useOrganizationQuery} from './organizationQuery';
import {OrganizationUserRole, useOrganizationQuery} from './organizationQuery';
import useOrganizationMembersQuery from './membersQuery';

// Constants and types
Expand All @@ -21,12 +22,90 @@ import styles from './membersRoute.module.scss';
export default function MembersRoute() {
const orgQuery = useOrganizationQuery();

if (!orgQuery.data?.id) {
if (!orgQuery.data) {
return (
<LoadingSpinner />
);
}

const columns = [
{
key: 'user__extra_details__name',
label: t('Name'),
cellFormatter: (member: OrganizationMember) => (
<Avatar
size='m'
username={member.user__username}
isUsernameVisible
email={member.user__email}
// We pass `undefined` for the case it's an empty string
fullName={member.user__extra_details__name || undefined}
/>
),
size: 360,
},
{
key: 'invite',
label: t('Status'),
size: 120,
cellFormatter: (member: OrganizationMember) => {
if (member.invite?.status) {
return member.invite.status;
} else {
return <Badge color='light-green' size='s' label={t('Active')} />;
}
return null;
},
},
{
key: 'date_joined',
label: t('Date added'),
size: 140,
cellFormatter: (member: OrganizationMember) => formatTime(member.date_joined),
},
{
key: 'role',
label: t('Role'),
size: 120,
},
{
key: 'user__has_mfa_enabled',
label: t('2FA'),
size: 90,
cellFormatter: (member: OrganizationMember) => {
if (member.user__has_mfa_enabled) {
return <Badge size='s' color='light-blue' icon='check' />;
}
return <Badge size='s' color='light-storm' icon='minus' />;
},
},
];

// Actions column is only for owner and admins.
if (
orgQuery.data.request_user_role === OrganizationUserRole.admin ||
orgQuery.data.request_user_role === OrganizationUserRole.owner
) {
columns.push({
key: 'url',
label: '',
size: 64,
cellFormatter: (member: OrganizationMember) => {
// There is no action that can be done on an owner
if (member.role === OrganizationUserRole.owner) {
return null;
}

return (
<MemberActionsDropdown
targetUsername={member.user__username}
currentUserRole={orgQuery.data.request_user_role}
/>
);
},
});
}

return (
<div className={styles.membersRouteRoot}>
<header className={styles.header}>
Expand All @@ -35,67 +114,7 @@ export default function MembersRoute() {

<PaginatedQueryUniversalTable<OrganizationMember>
queryHook={useOrganizationMembersQuery}
columns={[
{
key: 'user__extra_details__name',
label: t('Name'),
cellFormatter: (member: OrganizationMember) => (
<Avatar
size='m'
username={member.user__username}
isUsernameVisible
email={member.user__email}
// We pass `undefined` for the case it's an empty string
fullName={member.user__extra_details__name || undefined}
/>
),
size: 360,
},
{
key: 'invite',
label: t('Status'),
size: 120,
cellFormatter: (member: OrganizationMember) => {
if (member.invite?.status) {
return member.invite.status;
} else {
return <Badge color='light-green' size='s' label={t('Active')} />;
}
return null;
},
},
{
key: 'date_joined',
label: t('Date added'),
size: 140,
cellFormatter: (member: OrganizationMember) => formatTime(member.date_joined),
},
{
key: 'role',
label: t('Role'),
size: 120,
},
{
key: 'user__has_mfa_enabled',
label: t('2FA'),
size: 90,
cellFormatter: (member: OrganizationMember) => {
if (member.user__has_mfa_enabled) {
return <Badge size='s' color='light-blue' icon='check' />;
}
return <Badge size='s' color='light-storm' icon='minus' />;
},
},
{
// We use `url` here, but the cell would contain interactive UI
// element
key: 'url',
label: '',
size: 64,
// TODO: this will be added soon
cellFormatter: () => (' '),
},
]}
columns={columns}
/>
</div>
);
Expand Down
Loading
Loading