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:
committed by
GitHub
parent
5fd7c28894
commit
5805826994
@@ -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!!",
|
||||
|
||||
83
packages/hoppscotch-sh-admin/src/components.d.ts
vendored
83
packages/hoppscotch-sh-admin/src/components.d.ts
vendored
@@ -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'];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ?? '';
|
||||
};
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user