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_secret": "CLIENT SECRET",
"description": "Configure authentication providers for your server",
"provider_not_specified": "Please enable at least one authentication provider",
"scope": "SCOPE",
"tenant": "TENANT",
"title": "Authentication Providers",
@@ -146,6 +147,7 @@
"email_failure": "Failed to send invitation",
"email_signin_failure": "Failed to login with Email",
"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!!",
"error": "Something went wrong",
"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_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_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_owner_for_deletion": "One or more users are team owners. Update ownership before deletion!!",
"remove_invitee_failure": "Removal of invitee failed!!",
@@ -193,7 +196,6 @@
"sign_in_options": "All sign in option",
"sign_out": "Sign out",
"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_not_found": "User not found in the infra!!",
"users_to_admin_success": "Selected users are elevated to admin status!!",

View File

@@ -1,50 +1,51 @@
// generated by unplugin-vue-components
// We suggest you to commit this file into source control
// Read more: https://github.com/vuejs/core/pull/3399
import '@vue/runtime-core'
import '@vue/runtime-core';
export {}
export {};
declare module '@vue/runtime-core' {
export interface GlobalComponents {
AppHeader: typeof import('./components/app/Header.vue')['default']
AppLogin: typeof import('./components/app/Login.vue')['default']
AppLogout: typeof import('./components/app/Logout.vue')['default']
AppModal: typeof import('./components/app/Modal.vue')['default']
AppSidebar: typeof import('./components/app/Sidebar.vue')['default']
AppToast: typeof import('./components/app/Toast.vue')['default']
DashboardMetricsCard: typeof import('./components/dashboard/MetricsCard.vue')['default']
HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary']
HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary']
HoppSmartAnchor: typeof import('@hoppscotch/ui')['HoppSmartAnchor']
HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal']
HoppSmartInput: typeof import('@hoppscotch/ui')['HoppSmartInput']
HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem']
HoppSmartLink: typeof import('@hoppscotch/ui')['HoppSmartLink']
HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal']
HoppSmartPicture: typeof import('@hoppscotch/ui')['HoppSmartPicture']
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner']
HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab']
HoppSmartTabs: typeof import('@hoppscotch/ui')['HoppSmartTabs']
HoppSmartToggle: typeof import('@hoppscotch/ui')['HoppSmartToggle']
IconLucideInbox: typeof import('~icons/lucide/inbox')['default']
SettingsAuthProvider: typeof import('./components/settings/AuthProvider.vue')['default']
SettingsConfigurations: typeof import('./components/settings/Configurations.vue')['default']
SettingsDataSharing: typeof import('./components/settings/DataSharing.vue')['default']
SettingsReset: typeof import('./components/settings/Reset.vue')['default']
SettingsServerRestart: typeof import('./components/settings/ServerRestart.vue')['default']
SettingsSmtpConfiguration: typeof import('./components/settings/SmtpConfiguration.vue')['default']
SetupDataSharingAndNewsletter: typeof import('./components/setup/DataSharingAndNewsletter.vue')['default']
TeamsAdd: typeof import('./components/teams/Add.vue')['default']
TeamsDetails: typeof import('./components/teams/Details.vue')['default']
TeamsInvite: typeof import('./components/teams/Invite.vue')['default']
TeamsMembers: typeof import('./components/teams/Members.vue')['default']
TeamsPendingInvites: typeof import('./components/teams/PendingInvites.vue')['default']
Tippy: typeof import('vue-tippy')['Tippy']
UiAutoResetIcon: typeof import('./components/ui/AutoResetIcon.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']
AppHeader: typeof import('./components/app/Header.vue')['default'];
AppLogin: typeof import('./components/app/Login.vue')['default'];
AppLogout: typeof import('./components/app/Logout.vue')['default'];
AppModal: typeof import('./components/app/Modal.vue')['default'];
AppSidebar: typeof import('./components/app/Sidebar.vue')['default'];
AppToast: typeof import('./components/app/Toast.vue')['default'];
DashboardMetricsCard: typeof import('./components/dashboard/MetricsCard.vue')['default'];
HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary'];
HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary'];
HoppSmartAnchor: typeof import('@hoppscotch/ui')['HoppSmartAnchor'];
HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal'];
HoppSmartInput: typeof import('@hoppscotch/ui')['HoppSmartInput'];
HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem'];
HoppSmartLink: typeof import('@hoppscotch/ui')['HoppSmartLink'];
HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal'];
HoppSmartPicture: typeof import('@hoppscotch/ui')['HoppSmartPicture'];
HoppSmartPlaceholder: typeof import('@hoppscotch/ui')['HoppSmartPlaceholder'];
HoppSmartSelectWrapper: typeof import('@hoppscotch/ui')['HoppSmartSelectWrapper'];
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner'];
HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab'];
HoppSmartTabs: typeof import('@hoppscotch/ui')['HoppSmartTabs'];
HoppSmartToggle: typeof import('@hoppscotch/ui')['HoppSmartToggle'];
IconLucideInbox: typeof import('~icons/lucide/inbox')['default'];
SettingsAuthProvider: typeof import('./components/settings/AuthProvider.vue')['default'];
SettingsConfigurations: typeof import('./components/settings/Configurations.vue')['default'];
SettingsDataSharing: typeof import('./components/settings/DataSharing.vue')['default'];
SettingsReset: typeof import('./components/settings/Reset.vue')['default'];
SettingsServerRestart: typeof import('./components/settings/ServerRestart.vue')['default'];
SettingsSmtpConfiguration: typeof import('./components/settings/SmtpConfiguration.vue')['default'];
SetupDataSharingAndNewsletter: typeof import('./components/setup/DataSharingAndNewsletter.vue')['default'];
TeamsAdd: typeof import('./components/teams/Add.vue')['default'];
TeamsDetails: typeof import('./components/teams/Details.vue')['default'];
TeamsInvite: typeof import('./components/teams/Invite.vue')['default'];
TeamsMembers: typeof import('./components/teams/Members.vue')['default'];
TeamsPendingInvites: typeof import('./components/teams/PendingInvites.vue')['default'];
Tippy: typeof import('vue-tippy')['Tippy'];
UiAutoResetIcon: typeof import('./components/ui/AutoResetIcon.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 { useToast } from '~/composables/toast';
import { useClientHandler } from '~/composables/useClientHandler';
import { getCompiledErrorMessage } from '~/helpers/errors';
import IconChevronDown from '~icons/lucide/chevron-down';
import IconCircle from '~icons/lucide/circle';
import IconCircleDot from '~icons/lucide/circle-dot';
@@ -353,7 +354,13 @@ const removeExistingTeamMember = async (userID: string, index: number) => {
team.value.id
)();
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 {
team.value.teamMembers = team.value.teamMembers?.filter(
(member: any) => member.user.uid !== userID

View File

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

View File

@@ -27,6 +27,7 @@ import {
ServerConfigs,
UpdatedConfigs,
} from '~/helpers/configs';
import { getCompiledErrorMessage } from '~/helpers/errors';
import { useToast } from './toast';
import { useClientHandler } from './useClientHandler';
@@ -201,7 +202,12 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
const result = await mutation.executeMutation(variables);
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;
}

View File

@@ -8,9 +8,14 @@ export const UNAUTHORIZED = 'Unauthorized' as const;
// Sometimes the backend returns Unauthorized error message as follows:
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 =
'[GraphQL] admin/only_one_admin_account_found' as const;
// When trying to delete an admin account
export const ADMIN_CANNOT_BE_DELETED =
'admin/admin_can_not_be_deleted' as const;
@@ -19,4 +24,53 @@ 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;
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 { getI18n } from '~/modules/i18n';
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 = {
message: string;
@@ -49,14 +53,12 @@ export const handleUserDeletion = (deletedUsersList: UserDeletionResult[]) => {
}
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'),
[ADMIN_CANNOT_BE_DELETED]: t(
getCompiledErrorMessage(ADMIN_CANNOT_BE_DELETED, isBulkAction)
),
[USER_IS_OWNER]: t(getCompiledErrorMessage(USER_IS_OWNER, isBulkAction)),
};
const errMsgMapKeys = Object.keys(errMsgMap);
const toastMessages: ToastMessage[] = [];

View File

@@ -174,18 +174,21 @@
class="py-4 border-divider rounded-r-none bg-emerald-800 text-secondaryDark"
/>
<HoppButtonSecondary
v-if="areNonAdminsSelected"
:icon="IconUserCheck"
:label="t('users.make_admin')"
class="py-4 border-divider border-r-1 rounded-none hover:bg-emerald-600"
@click="confirmUsersToAdmin = true"
/>
<HoppButtonSecondary
v-if="areAdminsSelected"
:icon="IconUserMinus"
:label="t('users.remove_admin_status')"
class="py-4 border-divider border-r-1 rounded-none hover:bg-orange-500"
@click="confirmAdminsToUsers = true"
/>
<HoppButtonSecondary
v-if="areNonAdminsSelected"
:icon="IconTrash"
:label="t('users.delete_users')"
class="py-4 border-divider rounded-none hover:bg-red-500"
@@ -203,7 +206,7 @@
</div>
<UsersInviteModal
:show="showInviteUserModal"
v-if="showInviteUserModal"
@hide-modal="showInviteUserModal = false"
@send-invite="sendInvite"
/>
@@ -258,10 +261,7 @@ import {
UsersListQuery,
UsersListV2Document,
} from '~/helpers/backend/graphql';
import {
ONLY_ONE_ADMIN_ACCOUNT_FOUND,
USER_ALREADY_INVITED,
} from '~/helpers/errors';
import { getCompiledErrorMessage } from '~/helpers/errors';
import { handleUserDeletion } from '~/helpers/userManagement';
import IconCheck from '~icons/lucide/check';
import IconLeft from '~icons/lucide/chevron-left';
@@ -291,6 +291,7 @@ const headings = [
// Get Paginated Results of all the users in the infra
const usersPerPage = 20;
const {
fetching,
error,
@@ -306,6 +307,19 @@ const {
// Selected Rows
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
let debounceTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -442,9 +456,12 @@ const sendInvite = async (email: string) => {
const variables = { inviteeEmail: email.trim() };
const result = await sendInvitation.executeMutation(variables);
if (result.error) {
if (result.error.message === USER_ALREADY_INVITED)
toast.error(t('state.user_already_invited'));
else toast.error(t('state.email_failure'));
const { message } = result.error;
const compiledErrorMessage = getCompiledErrorMessage(message);
compiledErrorMessage
? toast.error(t(compiledErrorMessage))
: toast.error(t('state.email_failure'));
} else {
toast.success(t('state.email_success'));
showInviteUserModal.value = false;
@@ -522,8 +539,10 @@ 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'));
const compiledErrorMessage = getCompiledErrorMessage(result.error.message);
if (compiledErrorMessage) {
return toast.error(t(getCompiledErrorMessage(result.error.message)));
}
toast.error(