refactor(sh-admin): improved error handling and dynamic user actions in admin dashboard (#4044)

* feat: new helper functions for better error management

* refactor: new i18n strings

* refactor: better error handling in invite modal and members component

* refactor: better user management

* refactor: better error handling in config handler

* refactor: updated logic of dynamic action row

* refactor: better naming for computed properties

* feat: new error message when an admin tries to invite himself

* refactor: updated error message when user is already invited

* refactor: reverted i18n string for user already invited back to the old string

* refactor: removed show prop from invite modal

* refactor: improved implementation for getting the compiled error messages

* feat: new error message when email inputted is of an invalid format

* refactor: minor optimization

---------

Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
This commit is contained in:
Joel Jacob Stephen
2024-06-03 17:17:46 +05:30
committed by GitHub
parent 5fd7c28894
commit 5805826994
8 changed files with 154 additions and 73 deletions

View File

@@ -14,6 +14,7 @@
"client_id": "CLIENT ID", "client_id": "CLIENT ID",
"client_secret": "CLIENT SECRET", "client_secret": "CLIENT SECRET",
"description": "Configure authentication providers for your server", "description": "Configure authentication providers for your server",
"provider_not_specified": "Please enable at least one authentication provider",
"scope": "SCOPE", "scope": "SCOPE",
"tenant": "TENANT", "tenant": "TENANT",
"title": "Authentication Providers", "title": "Authentication Providers",
@@ -146,6 +147,7 @@
"email_failure": "Failed to send invitation", "email_failure": "Failed to send invitation",
"email_signin_failure": "Failed to login with Email", "email_signin_failure": "Failed to login with Email",
"email_success": "Email invitation sent successfully", "email_success": "Email invitation sent successfully",
"emails_cannot_be_same": "You cannot invite yourself, please choose a different email address!!",
"enter_team_email": "Please enter email of workspace owner!!", "enter_team_email": "Please enter email of workspace owner!!",
"error": "Something went wrong", "error": "Something went wrong",
"error_auth_providers": "Unable to load auth providers", "error_auth_providers": "Unable to load auth providers",
@@ -171,6 +173,7 @@
"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_owner_to_delete_user": "Remove team ownership status to delete the user!!",
"remove_owner_failure_only_one_owner": "Failed to remove member. There should be atleast one owner in a team!!",
"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_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!!",
@@ -193,7 +196,6 @@
"sign_in_options": "All sign in option", "sign_in_options": "All sign in option",
"sign_out": "Sign out", "sign_out": "Sign out",
"team_name_too_short": "Workspace name should be atleast 6 characters long!!", "team_name_too_short": "Workspace name should be atleast 6 characters long!!",
"team_name_long": "Workspace name should be atleast 6 characters long!!",
"user_already_invited": "Failed to send invite. User is already invited!!", "user_already_invited": "Failed to send invite. User is already invited!!",
"user_not_found": "User not found in the infra!!", "user_not_found": "User not found in the infra!!",
"users_to_admin_success": "Selected users are elevated to admin status!!", "users_to_admin_success": "Selected users are elevated to admin status!!",

View File

@@ -1,50 +1,51 @@
// generated by unplugin-vue-components // generated by unplugin-vue-components
// We suggest you to commit this file into source control // We suggest you to commit this file into source control
// Read more: https://github.com/vuejs/core/pull/3399 // Read more: https://github.com/vuejs/core/pull/3399
import '@vue/runtime-core' import '@vue/runtime-core';
export {} export {};
declare module '@vue/runtime-core' { declare module '@vue/runtime-core' {
export interface GlobalComponents { export interface GlobalComponents {
AppHeader: typeof import('./components/app/Header.vue')['default'] AppHeader: typeof import('./components/app/Header.vue')['default'];
AppLogin: typeof import('./components/app/Login.vue')['default'] AppLogin: typeof import('./components/app/Login.vue')['default'];
AppLogout: typeof import('./components/app/Logout.vue')['default'] AppLogout: typeof import('./components/app/Logout.vue')['default'];
AppModal: typeof import('./components/app/Modal.vue')['default'] AppModal: typeof import('./components/app/Modal.vue')['default'];
AppSidebar: typeof import('./components/app/Sidebar.vue')['default'] AppSidebar: typeof import('./components/app/Sidebar.vue')['default'];
AppToast: typeof import('./components/app/Toast.vue')['default'] AppToast: typeof import('./components/app/Toast.vue')['default'];
DashboardMetricsCard: typeof import('./components/dashboard/MetricsCard.vue')['default'] DashboardMetricsCard: typeof import('./components/dashboard/MetricsCard.vue')['default'];
HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary'] HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary'];
HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary'] HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary'];
HoppSmartAnchor: typeof import('@hoppscotch/ui')['HoppSmartAnchor'] HoppSmartAnchor: typeof import('@hoppscotch/ui')['HoppSmartAnchor'];
HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal'] HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal'];
HoppSmartInput: typeof import('@hoppscotch/ui')['HoppSmartInput'] HoppSmartInput: typeof import('@hoppscotch/ui')['HoppSmartInput'];
HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem'] HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem'];
HoppSmartLink: typeof import('@hoppscotch/ui')['HoppSmartLink'] HoppSmartLink: typeof import('@hoppscotch/ui')['HoppSmartLink'];
HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal'] HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal'];
HoppSmartPicture: typeof import('@hoppscotch/ui')['HoppSmartPicture'] HoppSmartPicture: typeof import('@hoppscotch/ui')['HoppSmartPicture'];
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner'] HoppSmartPlaceholder: typeof import('@hoppscotch/ui')['HoppSmartPlaceholder'];
HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab'] HoppSmartSelectWrapper: typeof import('@hoppscotch/ui')['HoppSmartSelectWrapper'];
HoppSmartTabs: typeof import('@hoppscotch/ui')['HoppSmartTabs'] HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner'];
HoppSmartToggle: typeof import('@hoppscotch/ui')['HoppSmartToggle'] HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab'];
IconLucideInbox: typeof import('~icons/lucide/inbox')['default'] HoppSmartTabs: typeof import('@hoppscotch/ui')['HoppSmartTabs'];
SettingsAuthProvider: typeof import('./components/settings/AuthProvider.vue')['default'] HoppSmartToggle: typeof import('@hoppscotch/ui')['HoppSmartToggle'];
SettingsConfigurations: typeof import('./components/settings/Configurations.vue')['default'] IconLucideInbox: typeof import('~icons/lucide/inbox')['default'];
SettingsDataSharing: typeof import('./components/settings/DataSharing.vue')['default'] SettingsAuthProvider: typeof import('./components/settings/AuthProvider.vue')['default'];
SettingsReset: typeof import('./components/settings/Reset.vue')['default'] SettingsConfigurations: typeof import('./components/settings/Configurations.vue')['default'];
SettingsServerRestart: typeof import('./components/settings/ServerRestart.vue')['default'] SettingsDataSharing: typeof import('./components/settings/DataSharing.vue')['default'];
SettingsSmtpConfiguration: typeof import('./components/settings/SmtpConfiguration.vue')['default'] SettingsReset: typeof import('./components/settings/Reset.vue')['default'];
SetupDataSharingAndNewsletter: typeof import('./components/setup/DataSharingAndNewsletter.vue')['default'] SettingsServerRestart: typeof import('./components/settings/ServerRestart.vue')['default'];
TeamsAdd: typeof import('./components/teams/Add.vue')['default'] SettingsSmtpConfiguration: typeof import('./components/settings/SmtpConfiguration.vue')['default'];
TeamsDetails: typeof import('./components/teams/Details.vue')['default'] SetupDataSharingAndNewsletter: typeof import('./components/setup/DataSharingAndNewsletter.vue')['default'];
TeamsInvite: typeof import('./components/teams/Invite.vue')['default'] TeamsAdd: typeof import('./components/teams/Add.vue')['default'];
TeamsMembers: typeof import('./components/teams/Members.vue')['default'] TeamsDetails: typeof import('./components/teams/Details.vue')['default'];
TeamsPendingInvites: typeof import('./components/teams/PendingInvites.vue')['default'] TeamsInvite: typeof import('./components/teams/Invite.vue')['default'];
Tippy: typeof import('vue-tippy')['Tippy'] TeamsMembers: typeof import('./components/teams/Members.vue')['default'];
UiAutoResetIcon: typeof import('./components/ui/AutoResetIcon.vue')['default'] TeamsPendingInvites: typeof import('./components/teams/PendingInvites.vue')['default'];
UsersDetails: typeof import('./components/users/Details.vue')['default'] Tippy: typeof import('vue-tippy')['Tippy'];
UsersInviteModal: typeof import('./components/users/InviteModal.vue')['default'] UiAutoResetIcon: typeof import('./components/ui/AutoResetIcon.vue')['default'];
UsersSharedRequests: typeof import('./components/users/SharedRequests.vue')['default'] UsersDetails: typeof import('./components/users/Details.vue')['default'];
UsersInviteModal: typeof import('./components/users/InviteModal.vue')['default'];
UsersSharedRequests: typeof import('./components/users/SharedRequests.vue')['default'];
} }
} }

View File

@@ -168,6 +168,7 @@ import { useRoute } from 'vue-router';
import { useI18n } from '~/composables/i18n'; import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast'; import { useToast } from '~/composables/toast';
import { useClientHandler } from '~/composables/useClientHandler'; import { useClientHandler } from '~/composables/useClientHandler';
import { getCompiledErrorMessage } from '~/helpers/errors';
import IconChevronDown from '~icons/lucide/chevron-down'; import IconChevronDown from '~icons/lucide/chevron-down';
import IconCircle from '~icons/lucide/circle'; import IconCircle from '~icons/lucide/circle';
import IconCircleDot from '~icons/lucide/circle-dot'; import IconCircleDot from '~icons/lucide/circle-dot';
@@ -353,7 +354,13 @@ const removeExistingTeamMember = async (userID: string, index: number) => {
team.value.id team.value.id
)(); )();
if (removeTeamMemberResult.error) { if (removeTeamMemberResult.error) {
toast.error(t('state.remove_member_failure')); const compiledErrorMessage = getCompiledErrorMessage(
removeTeamMemberResult.error.message
);
compiledErrorMessage
? toast.error(compiledErrorMessage)
: toast.error(t('state.remove_member_failure'));
} else { } else {
team.value.teamMembers = team.value.teamMembers?.filter( team.value.teamMembers = team.value.teamMembers?.filter(
(member: any) => member.user.uid !== userID (member: any) => member.user.uid !== userID

View File

@@ -1,6 +1,5 @@
<template> <template>
<HoppSmartModal <HoppSmartModal
v-if="show"
dialog dialog
:title="t('users.invite_user')" :title="t('users.invite_user')"
@close="emit('hide-modal')" @close="emit('hide-modal')"
@@ -38,15 +37,6 @@ import { useToast } from '~/composables/toast';
const t = useI18n(); const t = useI18n();
const toast = useToast(); const toast = useToast();
withDefaults(
defineProps<{
show: boolean;
}>(),
{
show: false,
}
);
const emit = defineEmits<{ const emit = defineEmits<{
(event: 'hide-modal'): void; (event: 'hide-modal'): void;
(event: 'send-invite', email: string): void; (event: 'send-invite', email: string): void;

View File

@@ -27,6 +27,7 @@ import {
ServerConfigs, ServerConfigs,
UpdatedConfigs, UpdatedConfigs,
} from '~/helpers/configs'; } from '~/helpers/configs';
import { getCompiledErrorMessage } from '~/helpers/errors';
import { useToast } from './toast'; import { useToast } from './toast';
import { useClientHandler } from './useClientHandler'; import { useClientHandler } from './useClientHandler';
@@ -201,7 +202,12 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
const result = await mutation.executeMutation(variables); const result = await mutation.executeMutation(variables);
if (result.error) { if (result.error) {
toast.error(t(errorMessage)); const { message } = result.error;
const compiledErrorMessage = getCompiledErrorMessage(message);
compiledErrorMessage
? toast.error(t(compiledErrorMessage))
: toast.error(t(errorMessage));
return false; return false;
} }

View File

@@ -8,9 +8,14 @@ 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;
// When the email is invalid
export const INVALID_EMAIL = '[GraphQL] invalid/email' as const;
// When trying to remove the only admin account
export const ONLY_ONE_ADMIN_ACCOUNT_FOUND = export const ONLY_ONE_ADMIN_ACCOUNT_FOUND =
'[GraphQL] admin/only_one_admin_account_found' as const; '[GraphQL] admin/only_one_admin_account_found' as const;
// When trying to delete an admin account
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;
@@ -19,4 +24,53 @@ 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 // When attempting to delete a user who is an owner of a team
export const USER_IS_OWNER = 'user/is_owner' as const; export const USER_IS_OWNER = 'user/is_owner';
// When attempting to delete a user who is the only owner of a team
export const TEAM_ONLY_ONE_OWNER = '[GraphQL] team/only_one_owner';
// Even one auth provider is not specified
export const AUTH_PROVIDER_NOT_SPECIFIED =
'[GraphQL] auth/provider_not_specified' as const;
export const BOTH_EMAILS_CANNOT_BE_SAME =
'[GraphQL] email/both_emails_cannot_be_same' as const;
type ErrorMessages = {
message: string;
alternateMessage?: string;
};
const ERROR_MESSAGES: Record<string, ErrorMessages> = {
[INVALID_EMAIL]: {
message: 'state.invalid_email',
},
[ONLY_ONE_ADMIN_ACCOUNT_FOUND]: {
message: 'state.remove_admin_failure_only_one_admin',
},
[ADMIN_CANNOT_BE_DELETED]: {
message: 'state.remove_admin_to_delete_user',
alternateMessage: 'state.remove_admin_for_deletion',
},
[USER_ALREADY_INVITED]: {
message: 'state.user_already_invited',
},
[USER_IS_OWNER]: {
message: 'state.remove_owner_to_delete_user',
alternateMessage: 'state.remove_owner_for_deletion',
},
[TEAM_ONLY_ONE_OWNER]: {
message: 'state.remove_owner_failure_only_one_owner',
},
[AUTH_PROVIDER_NOT_SPECIFIED]: {
message: 'configs.auth_providers.provider_not_specified',
},
[BOTH_EMAILS_CANNOT_BE_SAME]: {
message: 'state.emails_cannot_be_same',
},
};
export const getCompiledErrorMessage = (name: string, altMessage = false) => {
const error = ERROR_MESSAGES[name];
return altMessage ? error?.alternateMessage ?? '' : error?.message ?? '';
};

View File

@@ -1,7 +1,11 @@
import { useToast } from '~/composables/toast'; import { useToast } from '~/composables/toast';
import { getI18n } from '~/modules/i18n'; import { getI18n } from '~/modules/i18n';
import { UserDeletionResult } from './backend/graphql'; import { UserDeletionResult } from './backend/graphql';
import { ADMIN_CANNOT_BE_DELETED, USER_IS_OWNER } from './errors'; import {
ADMIN_CANNOT_BE_DELETED,
USER_IS_OWNER,
getCompiledErrorMessage,
} from './errors';
type ToastMessage = { type ToastMessage = {
message: string; message: string;
@@ -49,14 +53,12 @@ export const handleUserDeletion = (deletedUsersList: UserDeletionResult[]) => {
} }
const errMsgMap = { const errMsgMap = {
[ADMIN_CANNOT_BE_DELETED]: isBulkAction [ADMIN_CANNOT_BE_DELETED]: t(
? t('state.remove_admin_for_deletion') getCompiledErrorMessage(ADMIN_CANNOT_BE_DELETED, isBulkAction)
: t('state.remove_admin_to_delete_user'), ),
[USER_IS_OWNER]: t(getCompiledErrorMessage(USER_IS_OWNER, isBulkAction)),
[USER_IS_OWNER]: isBulkAction
? t('state.remove_owner_for_deletion')
: t('state.remove_owner_to_delete_user'),
}; };
const errMsgMapKeys = Object.keys(errMsgMap); const errMsgMapKeys = Object.keys(errMsgMap);
const toastMessages: ToastMessage[] = []; const toastMessages: ToastMessage[] = [];

View File

@@ -174,18 +174,21 @@
class="py-4 border-divider rounded-r-none bg-emerald-800 text-secondaryDark" class="py-4 border-divider rounded-r-none bg-emerald-800 text-secondaryDark"
/> />
<HoppButtonSecondary <HoppButtonSecondary
v-if="areNonAdminsSelected"
:icon="IconUserCheck" :icon="IconUserCheck"
:label="t('users.make_admin')" :label="t('users.make_admin')"
class="py-4 border-divider border-r-1 rounded-none hover:bg-emerald-600" class="py-4 border-divider border-r-1 rounded-none hover:bg-emerald-600"
@click="confirmUsersToAdmin = true" @click="confirmUsersToAdmin = true"
/> />
<HoppButtonSecondary <HoppButtonSecondary
v-if="areAdminsSelected"
:icon="IconUserMinus" :icon="IconUserMinus"
:label="t('users.remove_admin_status')" :label="t('users.remove_admin_status')"
class="py-4 border-divider border-r-1 rounded-none hover:bg-orange-500" class="py-4 border-divider border-r-1 rounded-none hover:bg-orange-500"
@click="confirmAdminsToUsers = true" @click="confirmAdminsToUsers = true"
/> />
<HoppButtonSecondary <HoppButtonSecondary
v-if="areNonAdminsSelected"
:icon="IconTrash" :icon="IconTrash"
:label="t('users.delete_users')" :label="t('users.delete_users')"
class="py-4 border-divider rounded-none hover:bg-red-500" class="py-4 border-divider rounded-none hover:bg-red-500"
@@ -203,7 +206,7 @@
</div> </div>
<UsersInviteModal <UsersInviteModal
:show="showInviteUserModal" v-if="showInviteUserModal"
@hide-modal="showInviteUserModal = false" @hide-modal="showInviteUserModal = false"
@send-invite="sendInvite" @send-invite="sendInvite"
/> />
@@ -258,10 +261,7 @@ import {
UsersListQuery, UsersListQuery,
UsersListV2Document, UsersListV2Document,
} from '~/helpers/backend/graphql'; } from '~/helpers/backend/graphql';
import { import { getCompiledErrorMessage } from '~/helpers/errors';
ONLY_ONE_ADMIN_ACCOUNT_FOUND,
USER_ALREADY_INVITED,
} from '~/helpers/errors';
import { handleUserDeletion } from '~/helpers/userManagement'; 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';
@@ -291,6 +291,7 @@ const headings = [
// Get Paginated Results of all the users in the infra // Get Paginated Results of all the users in the infra
const usersPerPage = 20; const usersPerPage = 20;
const { const {
fetching, fetching,
error, error,
@@ -306,6 +307,19 @@ const {
// Selected Rows // Selected Rows
const selectedRows = ref<UsersListQuery['infra']['allUsers']>([]); const selectedRows = ref<UsersListQuery['infra']['allUsers']>([]);
const areAdminsSelected = computed(() =>
selectedRows.value.some((user) => user.isAdmin)
);
const areNonAdminsSelected = computed(() => {
// No Admins selected implicitly conveys that all the selected users are non-Admins assuming `selectedRows.length` > 0 (markup render condition)
if (!areAdminsSelected.value) {
return true;
}
return selectedRows.value.some((user) => !user.isAdmin);
});
// 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;
@@ -442,9 +456,12 @@ const sendInvite = async (email: string) => {
const variables = { inviteeEmail: email.trim() }; const variables = { inviteeEmail: email.trim() };
const result = await sendInvitation.executeMutation(variables); const result = await sendInvitation.executeMutation(variables);
if (result.error) { if (result.error) {
if (result.error.message === USER_ALREADY_INVITED) const { message } = result.error;
toast.error(t('state.user_already_invited')); const compiledErrorMessage = getCompiledErrorMessage(message);
else toast.error(t('state.email_failure'));
compiledErrorMessage
? toast.error(t(compiledErrorMessage))
: toast.error(t('state.email_failure'));
} else { } else {
toast.success(t('state.email_success')); toast.success(t('state.email_success'));
showInviteUserModal.value = false; showInviteUserModal.value = false;
@@ -522,8 +539,10 @@ 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) { const compiledErrorMessage = getCompiledErrorMessage(result.error.message);
return toast.error(t('state.remove_admin_failure_only_one_admin'));
if (compiledErrorMessage) {
return toast.error(t(getCompiledErrorMessage(result.error.message)));
} }
toast.error( toast.error(