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

chore(sh-admin): alert the user while deleting users who are team owners #3937

Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/hoppscotch-sh-admin/locales/en.json
Expand Up @@ -164,11 +164,14 @@
"privacy_policy": "Privacy Policy",
"reenter_email": "Re-enter email",
"remove_admin_failure": "Failed to remove admin status!!",
"remove_admin_failure_only_one_admin": "Failed to remove admin status. There should be at least one admin!!",
"remove_admin_success": "Admin status removed!!",
"remove_admin_from_users_failure": "Failed to remove admin status from selected users!!",
"remove_admin_from_users_success": "Admin status removed from selected users!!",
"remove_admin_to_delete_user": "Remove admin privilege to delete the user!!",
"remove_owner_to_delete_user": "Remove team ownership status to delete the user!!",
"remove_admin_for_deletion": "Remove admin status before attempting deletion!!",
"remove_owner_for_deletion": "One or more users are team owners. Update ownership before deletion!!",
"remove_invitee_failure": "Removal of invitee failed!!",
"remove_invitee_success": "Removal of invitee is successfull!!",
"remove_member_failure": "Member couldn't be removed!!",
Expand Down
7 changes: 5 additions & 2 deletions packages/hoppscotch-sh-admin/src/helpers/errors.ts
Expand Up @@ -8,12 +8,15 @@ export const UNAUTHORIZED = 'Unauthorized' as const;
// Sometimes the backend returns Unauthorized error message as follows:
export const GRAPHQL_UNAUTHORIZED = '[GraphQL] Unauthorized' as const;

export const DELETE_USER_FAILED_ONLY_ONE_ADMIN =
'admin/only_one_admin_account_found' as const;
export const ONLY_ONE_ADMIN_ACCOUNT_FOUND =
'[GraphQL] admin/only_one_admin_account_found' as const;

export const ADMIN_CANNOT_BE_DELETED =
'admin/admin_can_not_be_deleted' as const;

// When trying to invite a user that is already invited
export const USER_ALREADY_INVITED =
'[GraphQL] admin/user_already_invited' as const;

// When attempting to delete a user who is an owner of a team
export const USER_IS_OWNER = 'user/is_owner' as const;
119 changes: 119 additions & 0 deletions packages/hoppscotch-sh-admin/src/helpers/userManagement.ts
@@ -0,0 +1,119 @@
import { useToast } from '~/composables/toast';
import { getI18n } from '~/modules/i18n';
import { UserDeletionResult } from './backend/graphql';
import { ADMIN_CANNOT_BE_DELETED, USER_IS_OWNER } from './errors';

type ToastMessage = {
message: string;
state: 'success' | 'error';
};

const t = getI18n();
const toast = useToast();

const displayToastMessages = (
toastMessages: ToastMessage[],
currentIndex: number
) => {
const { message, state } = toastMessages[currentIndex];

toast[state](message, {
duration: 2000,
onComplete: () => {
if (currentIndex < toastMessages.length - 1) {
displayToastMessages(toastMessages, currentIndex + 1);
}
},
});
};

export const handleUserDeletion = (deletedUsersList: UserDeletionResult[]) => {
const uniqueErrorMessages = new Set(
deletedUsersList.map(({ errorMessage }) => errorMessage).filter(Boolean)
) as Set<string>;
jamesgeorge007 marked this conversation as resolved.
Show resolved Hide resolved

const isBulkAction = deletedUsersList.length > 1;

const deletedUserIDs = deletedUsersList
.filter((user) => user.isDeleted)
.map((user) => user.userUID);

// Show the success toast based on the action type if there are no errors
if (uniqueErrorMessages.size === 0) {
if (isBulkAction) {
toast.success(
isBulkAction
? t('state.delete_user_success')
: t('state.delete_users_success')
);

return;
}

toast.success(t('state.delete_user_success'));
return;
}

const errMsgMap = {
[ADMIN_CANNOT_BE_DELETED]: isBulkAction
? t('state.remove_admin_for_deletion')
: t('state.remove_admin_to_delete_user'),

[USER_IS_OWNER]: isBulkAction
? t('state.remove_owner_for_deletion')
: t('state.remove_owner_to_delete_user'),
};
const errMsgMapKeys = Object.keys(errMsgMap);

const toastMessages: ToastMessage[] = [];

if (isBulkAction) {
// Indicates the actual count of users deleted (filtered via the `isDeleted` field)
const deletedUsersCount = deletedUserIDs.length;

if (isBulkAction && deletedUsersCount > 0) {
toastMessages.push({
message: t('state.delete_some_users_success', {
count: deletedUsersCount,
}),
state: 'success',
});
}
const remainingDeletionsCount = deletedUsersList.length - deletedUsersCount;
if (remainingDeletionsCount > 0) {
toastMessages.push({
message: t('state.delete_some_users_failure', {
count: remainingDeletionsCount,
}),
state: 'error',
});
}
}

uniqueErrorMessages.forEach((errorMessage) => {
if (errMsgMapKeys.includes(errorMessage)) {
toastMessages.push({
message: errMsgMap[errorMessage as keyof typeof errMsgMap],
state: 'error',
});
}
});

// Fallback for the case where the error message is not in the compiled list
if (
Array.from(uniqueErrorMessages).some(
(key) => !((key as string) in errMsgMap)
)
) {
const fallbackErrMsg = isBulkAction
? t('state.delete_users_failure')
: t('state.delete_user_failure');

toastMessages.push({
message: fallbackErrMsg,
state: 'error',
});
}

displayToastMessages(toastMessages, 0);
};
21 changes: 20 additions & 1 deletion packages/hoppscotch-sh-admin/src/modules/i18n.ts
@@ -1,7 +1,23 @@
import { createI18n } from 'vue-i18n';
import { I18n, createI18n } from 'vue-i18n';
import { HoppModule } from '.';
import messages from '@intlify/unplugin-vue-i18n/messages';

// A reference to the i18n instance
let i18nInstance: I18n<
Record<string, unknown>,
Record<string, unknown>,
Record<string, unknown>,
string,
false
> | null = null;

/**
* Returns the i18n instance
*/
export function getI18n() {
return i18nInstance!.global.t;
}

export default <HoppModule>{
jamesgeorge007 marked this conversation as resolved.
Show resolved Hide resolved
onVueAppInit(app) {
const i18n = createI18n({
Expand All @@ -11,6 +27,9 @@ export default <HoppModule>{
legacy: false,
allowComposition: true,
});

app.use(i18n);

i18nInstance = i18n;
},
};
10 changes: 2 additions & 8 deletions packages/hoppscotch-sh-admin/src/pages/users/_id.vue
Expand Up @@ -73,7 +73,7 @@ import {
RemoveUsersByAdminDocument,
UserInfoDocument,
} from '~/helpers/backend/graphql';
import { ADMIN_CANNOT_BE_DELETED } from '~/helpers/errors';
import { handleUserDeletion } from '~/helpers/userManagement';

const t = useI18n();
const toast = useToast();
Expand Down Expand Up @@ -210,13 +210,7 @@ const deleteUserMutation = async (id: string | null) => {
} else {
const deletedUsers = result.data?.removeUsersByAdmin || [];

const isAdminError = deletedUsers.some(
(user) => user.errorMessage === ADMIN_CANNOT_BE_DELETED
);

isAdminError
? toast.error(t('state.delete_user_failed_only_one_admin'))
: toast.success(t('state.delete_user_success'));
handleUserDeletion(deletedUsers);
}
confirmDeletion.value = false;
deleteUserUID.value = null;
Expand Down
81 changes: 30 additions & 51 deletions packages/hoppscotch-sh-admin/src/pages/users/index.vue
Expand Up @@ -210,7 +210,7 @@
<HoppSmartConfirmModal
:show="confirmUsersToAdmin"
:title="
AreMultipleUsersSelected
areMultipleUsersSelected
? t('state.confirm_users_to_admin')
: t('state.confirm_user_to_admin')
"
Expand All @@ -220,7 +220,7 @@
<HoppSmartConfirmModal
:show="confirmAdminsToUsers"
:title="
AreMultipleUsersSelectedToAdmin
areMultipleUsersSelectedToAdmin
? t('state.confirm_admins_to_users')
: t('state.confirm_admin_to_user')
"
Expand All @@ -230,7 +230,7 @@
<HoppSmartConfirmModal
:show="confirmUsersDeletion"
:title="
AreMultipleUsersSelectedForDeletion
areMultipleUsersSelectedForDeletion
? t('state.confirm_users_deletion')
: t('state.confirm_user_deletion')
"
Expand Down Expand Up @@ -259,10 +259,10 @@ import {
UsersListV2Document,
} from '~/helpers/backend/graphql';
import {
ADMIN_CANNOT_BE_DELETED,
DELETE_USER_FAILED_ONLY_ONE_ADMIN,
ONLY_ONE_ADMIN_ACCOUNT_FOUND,
USER_ALREADY_INVITED,
} from '~/helpers/errors';
import { handleUserDeletion } from '~/helpers/userManagement';
import IconCheck from '~icons/lucide/check';
import IconLeft from '~icons/lucide/chevron-left';
import IconRight from '~icons/lucide/chevron-right';
Expand Down Expand Up @@ -309,16 +309,10 @@ const selectedRows = ref<UsersListQuery['infra']['allUsers']>([]);
// Ensure this variable is declared outside the debounce function
let debounceTimeout: ReturnType<typeof setTimeout> | null = null;

let toastTimeout: ReturnType<typeof setTimeout> | null = null;

onUnmounted(() => {
if (debounceTimeout) {
clearTimeout(debounceTimeout);
}

if (toastTimeout) {
clearTimeout(toastTimeout);
}
});

// Debounce Function
Expand Down Expand Up @@ -462,7 +456,7 @@ const confirmUsersToAdmin = ref(false);
const usersToAdminUID = ref<string | null>(null);
const usersToAdmin = useMutation(MakeUsersAdminDocument);

const AreMultipleUsersSelected = computed(() => selectedRows.value.length > 1);
const areMultipleUsersSelected = computed(() => selectedRows.value.length > 1);

const confirmUserToAdmin = (id: string | null) => {
confirmUsersToAdmin.value = true;
Expand All @@ -482,11 +476,15 @@ const makeUsersToAdmin = async (id: string | null) => {

if (result.error) {
toast.error(
id ? t('state.admin_failure') : t('state.users_to_admin_failure')
areMultipleUsersSelected.value
? t('state.users_to_admin_failure')
: t('state.admin_failure')
);
} else {
toast.success(
id ? t('state.admin_success') : t('state.users_to_admin_success')
areMultipleUsersSelected.value
? t('state.users_to_admin_success')
: t('state.admin_success')
);
usersList.value = usersList.value.map((user) => ({
...user,
Expand Down Expand Up @@ -514,7 +512,7 @@ const resetConfirmAdminToUser = () => {
adminsToUserUID.value = null;
};

const AreMultipleUsersSelectedToAdmin = computed(
const areMultipleUsersSelectedToAdmin = computed(
() => selectedRows.value.length > 1
);

Expand All @@ -524,16 +522,20 @@ const makeAdminsToUsers = async (id: string | null) => {
const variables = { userUIDs };
const result = await adminsToUser.executeMutation(variables);
if (result.error) {
if (result.error.message === ONLY_ONE_ADMIN_ACCOUNT_FOUND) {
return toast.error(t('state.remove_admin_failure_only_one_admin'));
}

toast.error(
id
? t('state.remove_admin_failure')
: t('state.remove_admin_from_users_failure')
areMultipleUsersSelected.value
? t('state.remove_admin_from_users_failure')
: t('state.remove_admin_failure')
);
} else {
toast.success(
id
? t('state.remove_admin_success')
: t('state.remove_admin_from_users_success')
areMultipleUsersSelected.value
? t('state.remove_admin_from_users_success')
: t('state.remove_admin_success')
);
usersList.value = usersList.value.map((user) => ({
...user,
Expand Down Expand Up @@ -562,7 +564,7 @@ const resetConfirmUserDeletion = () => {
deleteUserUID.value = null;
};

const AreMultipleUsersSelectedForDeletion = computed(
const areMultipleUsersSelectedForDeletion = computed(
() => selectedRows.value.length > 1
);

Expand All @@ -572,45 +574,22 @@ const deleteUsers = async (id: string | null) => {
const result = await usersDeletion.executeMutation(variables);

if (result.error) {
const errorMessage =
result.error.message === DELETE_USER_FAILED_ONLY_ONE_ADMIN
? t('state.delete_user_failed_only_one_admin')
jamesgeorge007 marked this conversation as resolved.
Show resolved Hide resolved
: id
? t('state.delete_user_failure')
: t('state.delete_users_failure');
const errorMessage = areMultipleUsersSelected.value
? t('state.delete_users_failure')
: t('state.delete_user_failure');
toast.error(errorMessage);
} else {
const deletedUsers = result.data?.removeUsersByAdmin || [];
const deletedIDs = deletedUsers
const deletedUserIDs = deletedUsers
.filter((user) => user.isDeleted)
.map((user) => user.userUID);

const isAdminError = deletedUsers.some(
(user) => user.errorMessage === ADMIN_CANNOT_BE_DELETED
);
handleUserDeletion(deletedUsers);

usersList.value = usersList.value.filter(
(user) => !deletedIDs.includes(user.uid)
(user) => !deletedUserIDs.includes(user.uid)
);

if (isAdminError) {
toast.success(
t('state.delete_some_users_success', { count: deletedIDs.length })
);
toast.error(
t('state.delete_some_users_failure', {
count: deletedUsers.length - deletedIDs.length,
})
);
toastTimeout = setTimeout(() => {
toast.error(t('state.remove_admin_for_deletion'));
}, 2000);
} else {
toast.success(
id ? t('state.delete_user_success') : t('state.delete_users_success')
);
}

selectedRows.value.splice(0, selectedRows.value.length);
}
confirmUsersDeletion.value = false;
Expand Down