Skip to content

Commit

Permalink
feat(MembersRoute): add actions dropdown to the table of organization…
Browse files Browse the repository at this point in the history
… members TASK-987 TASK-990 (#5309)

### 📣 Summary
In Members Table (from Account → Team/Organization → Members) add
actions dropdown with one action. The action allows removing a member or
leaving the organization.

### 📖 Description
The availability of "Remove" action is based on the role of the logged
in user. When trying to remove user (or leave organization) a
confirmation prompt is being shown.

### 👀 Preview steps
1. ℹ️ have multiple different users
2. for one of the users (e.g. "joe"), use
http://kf.kobo.local/admin/organizations/organization/ to add multiple
users into joe's organization
3. For one of the users (e.g. "sue") set the role to "admin"
4. enable "Multi-members override" for joe's organization
5. enable feature flag `mmosEnabled`
6. navigate to `#/account/organization/members`
7. 🟢 notice that a table of organization members displays a list of
users added in step 2
8. 🟢 notice that in the table "joe" (as owner) has an ability to remove
all organization members (excluding themselves) including "sue" who's an
admin

As continuation for above steps:
1. log in as "sue"
2. navigate to `#/account/organization/members`
3. 🟢 notice that in the table "joe" (the owner) has no actions available
4. 🟢 notice that any "member" user in the table has actions available,
and if you click "Remove" a confirmation prompt will appear
5. 🟢 notice that for "sue" the action available is "Leave team"/"Leave
organization", and if you click it, a confirmation prompt will appear -
and it will have different content than confirmation prompt for removing
a member

As continuation for above steps:
1. pick a "member" user and use "Remove" action
2. confirm prompt
3. 🟢 verify that user was removed successfuly

### 💭 Notes
Build atop #5281
  • Loading branch information
magicznyleszek authored Dec 11, 2024
1 parent 18628b1 commit af28266
Show file tree
Hide file tree
Showing 5 changed files with 327 additions and 71 deletions.
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

0 comments on commit af28266

Please sign in to comment.