Compare commits

...

6 Commits

Author SHA1 Message Date
nivedin
d6babae291 chore: redirect to users page only in error case after user deletion 2024-03-28 19:48:32 +05:30
amk-dev
986a4b1d54 refactor: remove metadata + simplify types 2024-03-28 17:25:10 +05:30
jamesgeorge007
dee7864a08 chore: address CR comments
- Implicitly infer the action type (bulk/individual) from the supplied deleted users list.
- Display toast messages one after the other by relying on the native toast APIs refraining from the need to maintain timeouts separately.
- Ensure the toast message about user deletion success/failure with the count is displayed only when above `0`.
- Cleanup.

Co-authored-by: amk-dev <akash.k.mohan98@gmail.com>
Co-authored-by: nivedin <nivedinp@gmail.com>
2024-03-28 12:58:09 +05:30
jamesgeorge007
8a8cdcf78b chore: more specific error message while removing Admin status
Action leading to a scenario where there are no users with Admin privileges.
2024-03-27 14:50:38 +05:30
jamesgeorge007
17db483a35 refactor: leverage helpers 2024-03-27 13:11:44 +05:30
jamesgeorge007
80b9941399 chore: alert the user while deleting users who are team owners
SH Admin user management.
2024-03-27 00:34:48 +05:30
6 changed files with 180 additions and 63 deletions

View File

@@ -164,11 +164,14 @@
"privacy_policy": "Privacy Policy", "privacy_policy": "Privacy Policy",
"reenter_email": "Re-enter email", "reenter_email": "Re-enter email",
"remove_admin_failure": "Failed to remove admin status!!", "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_success": "Admin status removed!!",
"remove_admin_from_users_failure": "Failed to remove admin status from selected users!!", "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_from_users_success": "Admin status removed from selected users!!",
"remove_admin_to_delete_user": "Remove admin privilege to delete the user!!", "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_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_failure": "Removal of invitee failed!!",
"remove_invitee_success": "Removal of invitee is successfull!!", "remove_invitee_success": "Removal of invitee is successfull!!",
"remove_member_failure": "Member couldn't be removed!!", "remove_member_failure": "Member couldn't be removed!!",

View File

@@ -8,8 +8,8 @@ export const UNAUTHORIZED = 'Unauthorized' as const;
// Sometimes the backend returns Unauthorized error message as follows: // Sometimes the backend returns Unauthorized error message as follows:
export const GRAPHQL_UNAUTHORIZED = '[GraphQL] Unauthorized' as const; export const GRAPHQL_UNAUTHORIZED = '[GraphQL] Unauthorized' as const;
export const DELETE_USER_FAILED_ONLY_ONE_ADMIN = export const ONLY_ONE_ADMIN_ACCOUNT_FOUND =
'admin/only_one_admin_account_found' as const; '[GraphQL] admin/only_one_admin_account_found' as const;
export const ADMIN_CANNOT_BE_DELETED = export const ADMIN_CANNOT_BE_DELETED =
'admin/admin_can_not_be_deleted' as const; '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 // When trying to invite a user that is already invited
export const USER_ALREADY_INVITED = export const USER_ALREADY_INVITED =
'[GraphQL] admin/user_already_invited' as const; '[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;

View File

@@ -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>;
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);
};

View File

@@ -1,7 +1,23 @@
import { createI18n } from 'vue-i18n'; import { I18n, createI18n } from 'vue-i18n';
import { HoppModule } from '.'; import { HoppModule } from '.';
import messages from '@intlify/unplugin-vue-i18n/messages'; 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>{ export default <HoppModule>{
onVueAppInit(app) { onVueAppInit(app) {
const i18n = createI18n({ const i18n = createI18n({
@@ -11,6 +27,9 @@ export default <HoppModule>{
legacy: false, legacy: false,
allowComposition: true, allowComposition: true,
}); });
app.use(i18n); app.use(i18n);
i18nInstance = i18n;
}, },
}; };

View File

@@ -73,7 +73,7 @@ import {
RemoveUsersByAdminDocument, RemoveUsersByAdminDocument,
UserInfoDocument, UserInfoDocument,
} from '~/helpers/backend/graphql'; } from '~/helpers/backend/graphql';
import { ADMIN_CANNOT_BE_DELETED } from '~/helpers/errors'; import { handleUserDeletion } from '~/helpers/userManagement';
const t = useI18n(); const t = useI18n();
const toast = useToast(); const toast = useToast();
@@ -207,19 +207,13 @@ const deleteUserMutation = async (id: string | null) => {
if (result.error) { if (result.error) {
toast.error(t('state.delete_user_failure')); toast.error(t('state.delete_user_failure'));
router.push('/users');
} else { } else {
const deletedUsers = result.data?.removeUsersByAdmin || []; const deletedUsers = result.data?.removeUsersByAdmin || [];
const isAdminError = deletedUsers.some( handleUserDeletion(deletedUsers);
(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'));
} }
confirmDeletion.value = false; confirmDeletion.value = false;
deleteUserUID.value = null; deleteUserUID.value = null;
router.push('/users');
}; };
</script> </script>

View File

@@ -210,7 +210,7 @@
<HoppSmartConfirmModal <HoppSmartConfirmModal
:show="confirmUsersToAdmin" :show="confirmUsersToAdmin"
:title=" :title="
AreMultipleUsersSelected areMultipleUsersSelected
? t('state.confirm_users_to_admin') ? t('state.confirm_users_to_admin')
: t('state.confirm_user_to_admin') : t('state.confirm_user_to_admin')
" "
@@ -220,7 +220,7 @@
<HoppSmartConfirmModal <HoppSmartConfirmModal
:show="confirmAdminsToUsers" :show="confirmAdminsToUsers"
:title=" :title="
AreMultipleUsersSelectedToAdmin areMultipleUsersSelectedToAdmin
? t('state.confirm_admins_to_users') ? t('state.confirm_admins_to_users')
: t('state.confirm_admin_to_user') : t('state.confirm_admin_to_user')
" "
@@ -230,7 +230,7 @@
<HoppSmartConfirmModal <HoppSmartConfirmModal
:show="confirmUsersDeletion" :show="confirmUsersDeletion"
:title=" :title="
AreMultipleUsersSelectedForDeletion areMultipleUsersSelectedForDeletion
? t('state.confirm_users_deletion') ? t('state.confirm_users_deletion')
: t('state.confirm_user_deletion') : t('state.confirm_user_deletion')
" "
@@ -259,10 +259,10 @@ import {
UsersListV2Document, UsersListV2Document,
} from '~/helpers/backend/graphql'; } from '~/helpers/backend/graphql';
import { import {
ADMIN_CANNOT_BE_DELETED, ONLY_ONE_ADMIN_ACCOUNT_FOUND,
DELETE_USER_FAILED_ONLY_ONE_ADMIN,
USER_ALREADY_INVITED, USER_ALREADY_INVITED,
} from '~/helpers/errors'; } from '~/helpers/errors';
import { handleUserDeletion } from '~/helpers/userManagement';
import IconCheck from '~icons/lucide/check'; import IconCheck from '~icons/lucide/check';
import IconLeft from '~icons/lucide/chevron-left'; import IconLeft from '~icons/lucide/chevron-left';
import IconRight from '~icons/lucide/chevron-right'; import IconRight from '~icons/lucide/chevron-right';
@@ -309,16 +309,10 @@ const selectedRows = ref<UsersListQuery['infra']['allUsers']>([]);
// Ensure this variable is declared outside the debounce function // Ensure this variable is declared outside the debounce function
let debounceTimeout: ReturnType<typeof setTimeout> | null = null; let debounceTimeout: ReturnType<typeof setTimeout> | null = null;
let toastTimeout: ReturnType<typeof setTimeout> | null = null;
onUnmounted(() => { onUnmounted(() => {
if (debounceTimeout) { if (debounceTimeout) {
clearTimeout(debounceTimeout); clearTimeout(debounceTimeout);
} }
if (toastTimeout) {
clearTimeout(toastTimeout);
}
}); });
// Debounce Function // Debounce Function
@@ -462,7 +456,7 @@ const confirmUsersToAdmin = ref(false);
const usersToAdminUID = ref<string | null>(null); const usersToAdminUID = ref<string | null>(null);
const usersToAdmin = useMutation(MakeUsersAdminDocument); const usersToAdmin = useMutation(MakeUsersAdminDocument);
const AreMultipleUsersSelected = computed(() => selectedRows.value.length > 1); const areMultipleUsersSelected = computed(() => selectedRows.value.length > 1);
const confirmUserToAdmin = (id: string | null) => { const confirmUserToAdmin = (id: string | null) => {
confirmUsersToAdmin.value = true; confirmUsersToAdmin.value = true;
@@ -482,11 +476,15 @@ const makeUsersToAdmin = async (id: string | null) => {
if (result.error) { if (result.error) {
toast.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 { } else {
toast.success( 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) => ({ usersList.value = usersList.value.map((user) => ({
...user, ...user,
@@ -514,7 +512,7 @@ const resetConfirmAdminToUser = () => {
adminsToUserUID.value = null; adminsToUserUID.value = null;
}; };
const AreMultipleUsersSelectedToAdmin = computed( const areMultipleUsersSelectedToAdmin = computed(
() => selectedRows.value.length > 1 () => selectedRows.value.length > 1
); );
@@ -524,16 +522,20 @@ const makeAdminsToUsers = async (id: string | null) => {
const variables = { userUIDs }; const variables = { userUIDs };
const result = await adminsToUser.executeMutation(variables); const result = await adminsToUser.executeMutation(variables);
if (result.error) { 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( toast.error(
id areMultipleUsersSelected.value
? t('state.remove_admin_failure') ? t('state.remove_admin_from_users_failure')
: t('state.remove_admin_from_users_failure') : t('state.remove_admin_failure')
); );
} else { } else {
toast.success( toast.success(
id areMultipleUsersSelected.value
? t('state.remove_admin_success') ? t('state.remove_admin_from_users_success')
: t('state.remove_admin_from_users_success') : t('state.remove_admin_success')
); );
usersList.value = usersList.value.map((user) => ({ usersList.value = usersList.value.map((user) => ({
...user, ...user,
@@ -562,7 +564,7 @@ const resetConfirmUserDeletion = () => {
deleteUserUID.value = null; deleteUserUID.value = null;
}; };
const AreMultipleUsersSelectedForDeletion = computed( const areMultipleUsersSelectedForDeletion = computed(
() => selectedRows.value.length > 1 () => selectedRows.value.length > 1
); );
@@ -572,45 +574,22 @@ const deleteUsers = async (id: string | null) => {
const result = await usersDeletion.executeMutation(variables); const result = await usersDeletion.executeMutation(variables);
if (result.error) { if (result.error) {
const errorMessage = const errorMessage = areMultipleUsersSelected.value
result.error.message === DELETE_USER_FAILED_ONLY_ONE_ADMIN ? t('state.delete_users_failure')
? t('state.delete_user_failed_only_one_admin') : t('state.delete_user_failure');
: id
? t('state.delete_user_failure')
: t('state.delete_users_failure');
toast.error(errorMessage); toast.error(errorMessage);
} else { } else {
const deletedUsers = result.data?.removeUsersByAdmin || []; const deletedUsers = result.data?.removeUsersByAdmin || [];
const deletedIDs = deletedUsers const deletedUserIDs = deletedUsers
.filter((user) => user.isDeleted) .filter((user) => user.isDeleted)
.map((user) => user.userUID); .map((user) => user.userUID);
const isAdminError = deletedUsers.some( handleUserDeletion(deletedUsers);
(user) => user.errorMessage === ADMIN_CANNOT_BE_DELETED
);
usersList.value = usersList.value.filter( 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); selectedRows.value.splice(0, selectedRows.value.length);
} }
confirmUsersDeletion.value = false; confirmUsersDeletion.value = false;