diff --git a/packages/hoppscotch-sh-admin/locales/en.json b/packages/hoppscotch-sh-admin/locales/en.json index ad3210122..cb06e5e59 100644 --- a/packages/hoppscotch-sh-admin/locales/en.json +++ b/packages/hoppscotch-sh-admin/locales/en.json @@ -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!!", diff --git a/packages/hoppscotch-sh-admin/src/helpers/errors.ts b/packages/hoppscotch-sh-admin/src/helpers/errors.ts index cd45cea93..fb7f8f2a3 100644 --- a/packages/hoppscotch-sh-admin/src/helpers/errors.ts +++ b/packages/hoppscotch-sh-admin/src/helpers/errors.ts @@ -8,8 +8,8 @@ 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; @@ -17,3 +17,6 @@ export const ADMIN_CANNOT_BE_DELETED = // 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; diff --git a/packages/hoppscotch-sh-admin/src/helpers/userManagement.ts b/packages/hoppscotch-sh-admin/src/helpers/userManagement.ts new file mode 100644 index 000000000..605602cb7 --- /dev/null +++ b/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; + + 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); +}; diff --git a/packages/hoppscotch-sh-admin/src/modules/i18n.ts b/packages/hoppscotch-sh-admin/src/modules/i18n.ts index ac123b501..a7cd885b1 100644 --- a/packages/hoppscotch-sh-admin/src/modules/i18n.ts +++ b/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, + Record, + Record, + string, + false +> | null = null; + +/** + * Returns the i18n instance + */ +export function getI18n() { + return i18nInstance!.global.t; +} + export default { onVueAppInit(app) { const i18n = createI18n({ @@ -11,6 +27,9 @@ export default { legacy: false, allowComposition: true, }); + app.use(i18n); + + i18nInstance = i18n; }, }; diff --git a/packages/hoppscotch-sh-admin/src/pages/users/_id.vue b/packages/hoppscotch-sh-admin/src/pages/users/_id.vue index d01c2c6f6..05b22059d 100644 --- a/packages/hoppscotch-sh-admin/src/pages/users/_id.vue +++ b/packages/hoppscotch-sh-admin/src/pages/users/_id.vue @@ -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(); @@ -207,19 +207,13 @@ const deleteUserMutation = async (id: string | null) => { if (result.error) { toast.error(t('state.delete_user_failure')); + router.push('/users'); } 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; - router.push('/users'); }; diff --git a/packages/hoppscotch-sh-admin/src/pages/users/index.vue b/packages/hoppscotch-sh-admin/src/pages/users/index.vue index b8b7e66c3..ad2079105 100644 --- a/packages/hoppscotch-sh-admin/src/pages/users/index.vue +++ b/packages/hoppscotch-sh-admin/src/pages/users/index.vue @@ -210,7 +210,7 @@ ([]); // Ensure this variable is declared outside the debounce function let debounceTimeout: ReturnType | null = null; -let toastTimeout: ReturnType | null = null; - onUnmounted(() => { if (debounceTimeout) { clearTimeout(debounceTimeout); } - - if (toastTimeout) { - clearTimeout(toastTimeout); - } }); // Debounce Function @@ -462,7 +456,7 @@ const confirmUsersToAdmin = ref(false); const usersToAdminUID = ref(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; @@ -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, @@ -514,7 +512,7 @@ const resetConfirmAdminToUser = () => { adminsToUserUID.value = null; }; -const AreMultipleUsersSelectedToAdmin = computed( +const areMultipleUsersSelectedToAdmin = computed( () => selectedRows.value.length > 1 ); @@ -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, @@ -562,7 +564,7 @@ const resetConfirmUserDeletion = () => { deleteUserUID.value = null; }; -const AreMultipleUsersSelectedForDeletion = computed( +const areMultipleUsersSelectedForDeletion = computed( () => selectedRows.value.length > 1 ); @@ -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') - : 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;