feat(sh-admin): enhanced user management in admin dashboard (#3814)

Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
This commit is contained in:
Joel Jacob Stephen
2024-03-13 14:45:13 +05:30
committed by GitHub
parent 8fdba760a2
commit acfb0189df
23 changed files with 2698 additions and 2169 deletions

View File

@@ -18,89 +18,83 @@
<div v-else-if="error">{{ t('teams.load_list_error') }}</div>
<HoppSmartTable v-else-if="teamsList.length" :list="teamsList">
<HoppSmartTable
v-else-if="teamsList.length"
:headings="headings"
:list="teamsList"
@onRowClicked="goToTeamDetails"
>
<template #head>
<tr
class="text-secondary border-b border-dividerDark text-sm text-left bg-primaryLight"
>
<th class="px-6 py-2">{{ t('teams.id') }}</th>
<th class="px-6 py-2">{{ t('teams.name') }}</th>
<th class="px-6 py-2">{{ t('teams.members') }}</th>
<!-- Empty Heading for the Action Button -->
<th class="px-6 py-2"></th>
</tr>
<th class="px-6 py-2">{{ t('teams.id') }}</th>
<th class="px-6 py-2">{{ t('teams.name') }}</th>
<th class="px-6 py-2">{{ t('teams.members') }}</th>
<!-- Empty Heading for the Action Button -->
<th class="px-6 py-2"></th>
</template>
<template #body="{ list }">
<tr
v-for="team in list"
:key="team.id"
class="text-secondaryDark hover:bg-divider hover:cursor-pointer rounded-xl"
@click="goToTeamDetails(team.id)"
>
<td class="flex py-4 px-7 max-w-[16rem]">
<span class="truncate">
{{ team.id }}
</span>
</td>
<template #body="{ row: team }">
<td class="flex py-4 px-7 max-w-[16rem]">
<span class="truncate">
{{ team.id }}
</span>
</td>
<td class="py-4 px-7 min-w-[20rem]">
<span
class="flex items-center truncate"
:class="{ truncate: team.name }"
>
{{ team.name ?? t('teams.unnamed') }}
</span>
</td>
<td class="py-4 px-7 min-w-[20rem]">
<span
class="flex items-center truncate"
:class="{ truncate: team.name }"
>
{{ team.name ?? t('teams.unnamed') }}
</span>
</td>
<td class="py-4 px-7">
{{ team.members?.length }}
</td>
<td class="py-4 px-8">
{{ team.members?.length }}
</td>
<td @click.stop>
<div class="relative">
<tippy interactive trigger="click" theme="popover">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconMoreHorizontal"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartItem
:icon="IconTrash"
:label="t('teams.delete_team')"
class="!hover:bg-red-600 w-full"
@click="
() => {
deleteTeam(team.id);
hide();
}
"
/>
</div>
</template>
</tippy>
</div>
</td>
</tr>
<td @click.stop class="flex justify-end mr-10">
<div class="relative">
<tippy interactive trigger="click" theme="popover">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconMoreHorizontal"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartItem
:icon="IconTrash"
:label="t('teams.delete_team')"
class="!hover:bg-red-600 w-full"
@click="
() => {
deleteTeam(team.id);
hide();
}
"
/>
</div>
</template>
</tippy>
</div>
</td>
</template>
</HoppSmartTable>
<div v-else class="px-2 text-lg">
<div v-else class="px-2">
{{ t('teams.no_teams') }}
</div>
<div
v-if="hasNextPage && teamsList.length >= teamsPerPage"
class="flex justify-center my-5 px-3 py-2 cursor-pointer font-semibold rounded-3xl bg-dividerDark hover:bg-divider transition mx-auto w-38 text-secondaryDark"
class="flex items-center w-28 px-3 py-2 mt-5 mx-auto font-semibold text-secondaryDark bg-divider hover:bg-dividerDark rounded-3xl cursor-pointer"
@click="fetchNextTeams"
>
<span>{{ t('teams.show_more') }}</span>
<icon-lucide-chevron-down class="ml-2 text-lg" />
<icon-lucide-chevron-down class="ml-2" />
</div>
</div>
</div>
@@ -135,6 +129,7 @@ import {
CreateTeamDocument,
MetricsDocument,
RemoveTeamDocument,
TeamInfoQuery,
TeamListDocument,
UsersListDocument,
} from '../../helpers/backend/graphql';
@@ -149,9 +144,9 @@ const usersPerPage = computed(() => data.value?.infra.usersCount || 10000);
const { list: usersList } = usePagedQuery(
UsersListDocument,
(x) => x.infra.allUsers,
(x) => x.uid,
usersPerPage.value,
{ cursor: undefined, take: usersPerPage.value }
{ cursor: undefined, take: usersPerPage.value },
(x) => x.uid
);
const allUsersEmail = computed(() => usersList.value.map((user) => user.email));
@@ -168,11 +163,19 @@ const {
} = usePagedQuery(
TeamListDocument,
(x) => x.infra.allTeams,
(x) => x.id,
teamsPerPage,
{ cursor: undefined, take: teamsPerPage }
{ cursor: undefined, take: teamsPerPage },
(x) => x.id
);
// Table Headings
const headings = [
{ key: 'id', label: t('teams.id') },
{ key: 'name', label: t('teams.name') },
{ key: 'members', label: t('teams.members') },
{ key: 'actions', label: '' },
];
// Create Team
const showCreateTeamModal = ref(false);
const createTeamLoading = ref(false);
@@ -212,7 +215,8 @@ const createTeam = async (newTeamName: string, ownerEmail: string) => {
// Go To Individual Team Details Page
const router = useRouter();
const goToTeamDetails = (teamId: string) => router.push('/teams/' + teamId);
const goToTeamDetails = (team: TeamInfoQuery['infra']['teamInfo']) =>
router.push('/teams/' + team.id);
// Team Deletion
const confirmDeletion = ref(false);

View File

@@ -12,7 +12,7 @@
<div class="flex items-center space-x-3">
<h1 class="text-lg text-accentContrast">
{{ user.displayName }}
{{ userName }}
</h1>
<span>/</span>
<h2 class="text-lg text-accentContrast">
@@ -29,6 +29,7 @@
@delete-user="deleteUser"
@make-admin="makeUserAdmin"
@remove-admin="makeAdminToUser"
@update-user-name="(name: string) => (userName = name)"
class="py-8 px-4"
/>
</HoppSmartTab>
@@ -40,19 +41,19 @@
<HoppSmartConfirmModal
:show="confirmDeletion"
:title="t('users.confirm_user_deletion')"
:title="t('state.confirm_user_deletion')"
@hide-modal="confirmDeletion = false"
@resolve="deleteUserMutation(deleteUserUID)"
/>
<HoppSmartConfirmModal
:show="confirmUserToAdmin"
:title="t('users.confirm_user_to_admin')"
:title="t('state.confirm_user_to_admin')"
@hide-modal="confirmUserToAdmin = false"
@resolve="makeUserAdminMutation(userToAdminUID)"
/>
<HoppSmartConfirmModal
:show="confirmAdminToUser"
:title="t('users.confirm_admin_to_user')"
:title="t('state.confirm_admin_to_user')"
@hide-modal="confirmAdminToUser = false"
@resolve="makeAdminToUserMutation(adminToUserUID)"
/>
@@ -67,11 +68,12 @@ import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast';
import { useClientHandler } from '~/composables/useClientHandler';
import {
MakeUserAdminDocument,
RemoveUserAsAdminDocument,
RemoveUserByAdminDocument,
DemoteUsersByAdminDocument,
MakeUsersAdminDocument,
RemoveUsersByAdminDocument,
UserInfoDocument,
} from '~/helpers/backend/graphql';
import { ADMIN_CANNOT_BE_DELETED } from '~/helpers/errors';
const t = useI18n();
const toast = useToast();
@@ -104,6 +106,15 @@ onMounted(async () => {
await fetchData();
});
const userName = computed({
get: () => data.value?.infra.userInfo.displayName,
set: (value) => {
if (value) {
data.value!.infra.userInfo.displayName = value;
}
},
});
const user = computed({
get: () => data.value?.infra.userInfo,
set: (value) => {
@@ -113,43 +124,11 @@ const user = computed({
},
});
// User Deletion
const router = useRouter();
const userDeletion = useMutation(RemoveUserByAdminDocument);
const confirmDeletion = ref(false);
const deleteUserUID = ref<string | null>(null);
const deleteUser = (id: string) => {
confirmDeletion.value = true;
deleteUserUID.value = id;
};
const deleteUserMutation = async (id: string | null) => {
if (!id) {
confirmDeletion.value = false;
toast.error(t('state.delete_user_failure'));
return;
}
const variables = { uid: id };
const result = await userDeletion.executeMutation(variables);
if (result.error) {
toast.error(t('state.delete_user_failure'));
} else {
toast.success(t('state.delete_user_success'));
}
confirmDeletion.value = false;
deleteUserUID.value = null;
router.push('/users');
};
// Make User Admin
const userToAdmin = useMutation(MakeUserAdminDocument);
const confirmUserToAdmin = ref(false);
const userToAdminUID = ref<string | null>(null);
const usersToAdmin = useMutation(MakeUsersAdminDocument);
const makeUserAdmin = (id: string) => {
const makeUserAdmin = (id: string | null) => {
confirmUserToAdmin.value = true;
userToAdminUID.value = id;
};
@@ -160,20 +139,23 @@ const makeUserAdminMutation = async (id: string | null) => {
toast.error(t('state.admin_failure'));
return;
}
const variables = { uid: id };
const result = await userToAdmin.executeMutation(variables);
const userUIDs = [id];
const variables = { userUIDs };
const result = await usersToAdmin.executeMutation(variables);
if (result.error) {
toast.error(t('state.admin_failure'));
} else {
user.value!.isAdmin = true;
toast.success(t('state.admin_success'));
user.value!.isAdmin = true;
}
confirmUserToAdmin.value = false;
userToAdminUID.value = null;
};
// Remove Admin Status from a current admin user
const adminToUser = useMutation(RemoveUserAsAdminDocument);
const adminToUser = useMutation(DemoteUsersByAdminDocument);
const confirmAdminToUser = ref(false);
const adminToUserUID = ref<string | null>(null);
@@ -188,15 +170,56 @@ const makeAdminToUserMutation = async (id: string | null) => {
toast.error(t('state.remove_admin_failure'));
return;
}
const variables = { uid: id };
const userUIDs = [id];
const variables = { userUIDs };
const result = await adminToUser.executeMutation(variables);
if (result.error) {
toast.error(t('state.remove_admin_failure'));
} else {
toast.success(t('state.remove_admin_success'));
user.value!.isAdmin = false;
toast.error(t('state.remove_admin_success'));
}
confirmAdminToUser.value = false;
adminToUserUID.value = null;
};
// User Deletion
const router = useRouter();
const userDeletion = useMutation(RemoveUsersByAdminDocument);
const confirmDeletion = ref(false);
const deleteUserUID = ref<string | null>(null);
const deleteUser = (id: string) => {
confirmDeletion.value = true;
deleteUserUID.value = id;
};
const deleteUserMutation = async (id: string | null) => {
if (!id) {
confirmDeletion.value = false;
toast.error(t('state.delete_user_failure'));
return;
}
const userUIDs = [id];
const variables = { userUIDs };
const result = await userDeletion.executeMutation(variables);
if (result.error) {
toast.error(t('state.delete_user_failure'));
} 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'));
}
confirmDeletion.value = false;
deleteUserUID.value = null;
router.push('/users');
};
</script>

View File

@@ -5,7 +5,7 @@
<h1 class="text-lg font-bold text-secondaryDark">
{{ t('users.users') }}
</h1>
<div class="flex items-center space-x-4 py-10">
<div class="flex items-center space-x-4 mt-10 mb-5">
<HoppButtonPrimary
:label="t('users.invite_user')"
@click="showInviteUserModal = true"
@@ -15,132 +15,189 @@
<HoppButtonSecondary
outline
filled
:label="t('users.invited_users')"
:label="t('users.pending_invites')"
:to="'/users/invited'"
/>
</div>
</div>
<div class="overflow-x-auto">
<div v-if="fetching" class="flex justify-center">
<HoppSmartSpinner />
<div class="mb-3 flex items-center justify-end">
<HoppButtonSecondary
outline
filled
:icon="IconLeft"
:disabled="page === 1"
@click="changePage(PageDirection.Previous)"
/>
<div class="flex h-full w-10 items-center justify-center">
<span>{{ page }}</span>
</div>
<HoppButtonSecondary
outline
filled
:icon="IconRight"
:disabled="page >= totalPages"
@click="changePage(PageDirection.Next)"
/>
</div>
<div v-else-if="error">{{ t('users.load_list_error') }}</div>
<HoppSmartTable v-else-if="usersList.length" :list="usersList">
<HoppSmartTable
v-model:list="finalUsersList"
v-model:selected-rows="selectedRows"
:headings="headings"
:loading="showSpinner"
@onRowClicked="goToUserDetails"
>
<template #extension>
<div class="flex w-full items-center bg-primary">
<icon-lucide-search class="mx-3 text-xs" />
<HoppSmartInput
v-model="query"
styles="w-full bg-primary py-1"
input-styles="h-full border-none"
:placeholder="t('users.searchbar_placeholder')"
/>
</div>
</template>
<template #head>
<tr
class="text-secondary border-b border-dividerDark text-sm text-left bg-primaryLight"
>
<th class="px-6 py-2">{{ t('users.id') }}</th>
<th class="px-6 py-2">{{ t('users.name') }}</th>
<th class="px-6 py-2">{{ t('users.email') }}</th>
<th class="px-6 py-2">{{ t('users.date') }}</th>
<!-- Empty header for Action Button -->
<th class="px-6 py-2"></th>
</tr>
<th class="px-6 py-2">{{ t('users.id') }}</th>
<th class="px-6 py-2">{{ t('users.name') }}</th>
<th class="px-6 py-2">{{ t('users.email') }}</th>
<th class="px-6 py-2">{{ t('users.date') }}</th>
<!-- Empty header for Action Button -->
<th class="w-20 px-6 py-2"></th>
</template>
<template #body="{ list }">
<tr
v-for="user in list"
:key="user.uid"
class="text-secondaryDark hover:bg-divider hover:cursor-pointer rounded-xl"
@click="goToUserDetails(user.uid)"
>
<td class="py-2 px-7 max-w-[8rem] truncate">
{{ user.uid }}
</td>
<template #empty-state>
<td colspan="6">
<span class="flex justify-center p-3">
{{ error ? t('users.load_list_error') : t('users.no_users') }}
</span>
</td>
</template>
<td class="py-2 px-7">
<div class="flex items-center space-x-2">
<span>
{{ user.displayName ?? t('users.unnamed') }}
</span>
<template #body="{ row: user }">
<td class="py-2 px-7 max-w-[8rem] truncate">
{{ user.uid }}
</td>
<span
v-if="user.isAdmin"
class="text-xs font-medium px-3 py-0.5 rounded-full bg-green-900 text-green-300"
>
{{ t('users.admin') }}
</span>
</div>
</td>
<td class="py-2 px-7">
<div class="flex items-center space-x-2">
<span>
{{ user.displayName ?? t('users.unnamed') }}
</span>
<td class="py-2 px-7">
{{ user.email }}
</td>
<span
v-if="user.isAdmin"
class="text-xs font-medium px-3 py-0.5 rounded-full bg-green-900 text-green-300"
>
{{ t('users.admin') }}
</span>
</div>
</td>
<td class="py-2 px-7">
{{ getCreatedDate(user.createdOn) }}
<div class="text-gray-400 text-tiny">
{{ getCreatedTime(user.createdOn) }}
</div>
</td>
<td class="py-2 px-7 truncate">
{{ user.email }}
</td>
<td @click.stop>
<div class="relative">
<tippy interactive trigger="click" theme="popover">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconMoreHorizontal"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartItem
:icon="user.isAdmin ? IconUserMinus : IconUserCheck"
:label="
<td class="py-2 px-7">
{{ getCreatedDate(user.createdOn) }}
<div class="text-gray-400 text-tiny">
{{ getCreatedTime(user.createdOn) }}
</div>
</td>
<td @click.stop class="flex justify-end w-20">
<div class="mt-2 mr-5">
<tippy interactive trigger="click" theme="popover">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconMoreHorizontal"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartItem
:icon="user.isAdmin ? IconUserMinus : IconUserCheck"
:label="
user.isAdmin
? t('users.remove_admin_status')
: t('users.make_admin')
"
class="!hover:bg-emerald-600"
@click="
() => {
user.isAdmin
? t('users.remove_admin_status')
: t('users.make_admin')
"
class="!hover:bg-emerald-600"
@click="
() => {
user.isAdmin
? makeAdminToUser(user.uid)
: makeUserAdmin(user.uid);
hide();
}
"
/>
<HoppSmartItem
v-if="!user.isAdmin"
:icon="IconTrash"
:label="t('users.delete_user')"
class="!hover:bg-red-600"
@click="
() => {
deleteUser(user.uid);
hide();
}
"
/>
</div>
</template>
</tippy>
</div>
</td>
</tr>
? confirmAdminToUser(user.uid)
: confirmUserToAdmin(user.uid);
hide();
}
"
/>
<HoppSmartItem
v-if="!user.isAdmin"
:icon="IconTrash"
:label="t('users.delete_user')"
class="!hover:bg-red-600"
@click="
() => {
confirmUserDeletion(user.uid);
hide();
}
"
/>
</div>
</template>
</tippy>
</div>
</td>
</template>
</HoppSmartTable>
<div v-else-if="usersList.length === 0" class="flex justify-center">
{{ t('users.no_users') }}
</div>
<!-- Actions for Selected Rows -->
<div
v-if="hasNextPage && usersList.length >= usersPerPage"
class="flex justify-center my-5 px-3 py-2 cursor-pointer font-semibold rounded-3xl bg-dividerDark hover:bg-divider transition mx-auto w-38 text-secondaryDark"
@click="fetchNextUsers"
v-if="selectedRows.length"
class="fixed m-2 bottom-0 left-40 right-0 w-min mx-auto shadow-2xl"
>
<span>{{ t('users.show_more') }}</span>
<icon-lucide-chevron-down class="ml-2 text-lg" />
<div
class="flex justify-center items-end bg-primaryLight border border-divider rounded-md mb-5"
>
<HoppButtonSecondary
:icon="IconCheck"
:label="t('state.selected', { count: selectedRows.length })"
class="py-4 border-divider rounded-r-none bg-emerald-800 text-secondaryDark"
/>
<HoppButtonSecondary
: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
: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
:icon="IconTrash"
:label="t('users.delete_users')"
class="py-4 border-divider rounded-none hover:bg-red-500"
@click="confirmUsersDeletion = true"
/>
<HoppButtonSecondary
:icon="IconX"
:label="t('state.clear_selection')"
class="py-4 border-divider rounded-l-none text-secondaryDark bg-red-600 hover:bg-red-500"
@click="selectedRows.splice(0, selectedRows.length)"
/>
</div>
</div>
</div>
</div>
@@ -151,46 +208,70 @@
@send-invite="sendInvite"
/>
<HoppSmartConfirmModal
:show="confirmDeletion"
:title="t('users.confirm_user_deletion')"
@hide-modal="confirmDeletion = false"
@resolve="deleteUserMutation(deleteUserUID)"
:show="confirmUsersToAdmin"
:title="
AreMultipleUsersSelected
? t('state.confirm_users_to_admin')
: t('state.confirm_user_to_admin')
"
@hide-modal="resetConfirmUserToAdmin"
@resolve="makeUsersToAdmin(usersToAdminUID)"
/>
<HoppSmartConfirmModal
:show="confirmUserToAdmin"
:title="t('users.confirm_user_to_admin')"
@hide-modal="confirmUserToAdmin = false"
@resolve="makeUserAdminMutation(userToAdminUID)"
:show="confirmAdminsToUsers"
:title="
AreMultipleUsersSelectedToAdmin
? t('state.confirm_admins_to_users')
: t('state.confirm_admin_to_user')
"
@hide-modal="resetConfirmAdminToUser"
@resolve="makeAdminsToUsers(adminsToUserUID)"
/>
<HoppSmartConfirmModal
:show="confirmAdminToUser"
:title="t('users.confirm_admin_to_user')"
@hide-modal="confirmAdminToUser = false"
@resolve="makeAdminToUserMutation(adminToUserUID)"
:show="confirmUsersDeletion"
:title="
AreMultipleUsersSelectedForDeletion
? t('state.confirm_users_deletion')
: t('state.confirm_user_deletion')
"
@hide-modal="resetConfirmUserDeletion"
@resolve="deleteUsers(deleteUserUID)"
/>
</div>
</template>
<script setup lang="ts">
import { useMutation } from '@urql/vue';
import { useMutation, useQuery } from '@urql/vue';
import { format } from 'date-fns';
import { ref } from 'vue';
import { computed, onUnmounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast';
import { usePagedQuery } from '~/composables/usePagedQuery';
import {
DemoteUsersByAdminDocument,
InviteNewUserDocument,
MakeUsersAdminDocument,
MetricsDocument,
RemoveUsersByAdminDocument,
UserInfoQuery,
UsersListQuery,
UsersListV2Document,
} from '~/helpers/backend/graphql';
import {
ADMIN_CANNOT_BE_DELETED,
DELETE_USER_FAILED_ONLY_ONE_ADMIN,
USER_ALREADY_INVITED,
} from '~/helpers/errors';
import IconCheck from '~icons/lucide/check';
import IconLeft from '~icons/lucide/chevron-left';
import IconRight from '~icons/lucide/chevron-right';
import IconMoreHorizontal from '~icons/lucide/more-horizontal';
import IconTrash from '~icons/lucide/trash';
import IconUserCheck from '~icons/lucide/user-check';
import IconUserMinus from '~icons/lucide/user-minus';
import IconAddUser from '~icons/lucide/user-plus';
import {
InviteNewUserDocument,
MakeUserAdminDocument,
RemoveUserAsAdminDocument,
RemoveUserByAdminDocument,
UsersListDocument,
} from '~/helpers/backend/graphql';
import IconX from '~icons/lucide/x';
// Get Proper Date Formats
const t = useI18n();
@@ -199,25 +280,165 @@ const toast = useToast();
const getCreatedDate = (date: string) => format(new Date(date), 'dd-MM-yyyy');
const getCreatedTime = (date: string) => format(new Date(date), 'hh:mm a');
// Table Headings
const headings = [
{ key: 'uid', label: t('users.id') },
{ key: 'displayName', label: t('users.name') },
{ key: 'email', label: t('users.email') },
{ key: 'createdOn', label: t('users.date') },
{ key: '', label: '' },
];
// Get Paginated Results of all the users in the infra
const usersPerPage = 20;
const {
fetching,
error,
goToNextPage: fetchNextUsers,
refetch,
list: usersList,
hasNextPage,
} = usePagedQuery(
UsersListDocument,
(x) => x.infra.allUsers,
(x) => x.uid,
UsersListV2Document,
(x) => x.infra.allUsersV2,
usersPerPage,
{ cursor: undefined, take: usersPerPage }
{ searchString: '', take: usersPerPage, skip: 0 }
);
// Selected Rows
const selectedRows = ref<UsersListQuery['infra']['allUsers']>([]);
// Ensure this variable is declared outside the debounce function
let debounceTimeout: ReturnType<typeof setTimeout> | null = null;
let toastTimeout: ReturnType<typeof setTimeout> | null = null;
onUnmounted(() => {
if (debounceTimeout) {
clearTimeout(debounceTimeout);
}
if (toastTimeout) {
clearTimeout(toastTimeout);
}
});
// Debounce Function
const debounce = (func: () => void, delay: number) => {
if (debounceTimeout) clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(func, delay);
};
// Search
const query = ref('');
// Query which is sent to the backend after debouncing
const searchQuery = ref('');
const handleSearch = async (input: string) => {
searchQuery.value = input;
if (input.length === 0) {
await refetch({
searchString: '',
take: usersPerPage,
skip: (page.value - 1) * usersPerPage,
});
} else {
// If search query is present, fetch all the users filtered by the search query
await refetch({ searchString: input, take: usersCount.value!, skip: 0 });
}
// Reset the page to 1 when the search query changes
page.value = 1;
};
watch(query, () => {
if (query.value.length === 0) {
handleSearch(query.value);
} else {
debounce(() => {
handleSearch(query.value);
}, 500);
}
});
// Final Users List after Search and Pagination operations
const finalUsersList = computed(() =>
// If search query is present, filter the list based on the search query and return the paginated results
// Else just return the paginated results directly
searchQuery.value.length > 0
? usersList.value.slice(
(page.value - 1) * usersPerPage,
page.value * usersPerPage
)
: usersList.value
);
// Spinner
const showSpinner = ref(false);
watch(fetching, (fetching) => {
if (fetching) {
showSpinner.value = true;
debounce(() => {
showSpinner.value = false;
}, 500);
}
});
// Pagination
enum PageDirection {
Previous,
Next,
}
const page = ref(1);
const { data } = useQuery({ query: MetricsDocument });
const usersCount = computed(() => data?.value?.infra.usersCount);
const changePage = (direction: PageDirection) => {
const isPrevious = direction === PageDirection.Previous;
const isValidPreviousAction = isPrevious && page.value > 1;
const isValidNextAction = !isPrevious && page.value < totalPages.value;
if (isValidNextAction || isValidPreviousAction) {
page.value += isPrevious ? -1 : 1;
}
};
const totalPages = computed(() => {
if (!usersCount.value) return 0;
if (query.value.length > 0) {
return Math.ceil(usersList.value.length / usersPerPage);
}
return Math.ceil(usersCount.value / usersPerPage);
});
watch(page, async () => {
if (page.value < 1 || page.value > totalPages.value) {
return;
}
// Show spinner when moving to a different page when search query is present
else if (query.value.length > 0) {
showSpinner.value = true;
debounce(() => (showSpinner.value = false), 500);
} else {
await refetch({
searchString: '',
take: usersPerPage,
skip: (page.value - 1) * usersPerPage,
});
}
});
// Go to Individual User Details Page
const router = useRouter();
const goToUserDetails = (user: UserInfoQuery['infra']['userInfo']) =>
router.push('/users/' + user.uid);
// Send Invitation through Email
const sendInvitation = useMutation(InviteNewUserDocument);
const showInviteUserModal = ref(false);
const sendInvitation = useMutation(InviteNewUserDocument);
const sendInvite = async (email: string) => {
if (!email.trim()) {
@@ -227,104 +448,172 @@ const sendInvite = async (email: string) => {
const variables = { inviteeEmail: email.trim() };
const result = await sendInvitation.executeMutation(variables);
if (result.error) {
toast.error(t('state.email_failure'));
if (result.error.message === USER_ALREADY_INVITED)
toast.error(t('state.user_already_invited'));
else toast.error(t('state.email_failure'));
} else {
toast.success(t('state.email_success'));
showInviteUserModal.value = false;
}
};
// Go to Individual User Details Page
const router = useRouter();
const goToUserDetails = (uid: string) => router.push('/users/' + uid);
// Make Multiple Users Admin
const confirmUsersToAdmin = ref(false);
const usersToAdminUID = ref<string | null>(null);
const usersToAdmin = useMutation(MakeUsersAdminDocument);
// User Deletion
const userDeletion = useMutation(RemoveUserByAdminDocument);
const confirmDeletion = ref(false);
const deleteUserUID = ref<string | null>(null);
const AreMultipleUsersSelected = computed(() => selectedRows.value.length > 1);
const deleteUserMutation = async (id: string | null) => {
if (!id) {
confirmDeletion.value = false;
toast.error(t('state.delete_user_failure'));
return;
}
const variables = { uid: id };
const result = await userDeletion.executeMutation(variables);
if (result.error) {
toast.error(t('state.delete_user_failure'));
} else {
toast.success(t('state.delete_user_success'));
usersList.value = usersList.value.filter((user) => user.uid !== id);
}
confirmDeletion.value = false;
deleteUserUID.value = null;
const confirmUserToAdmin = (id: string | null) => {
confirmUsersToAdmin.value = true;
usersToAdminUID.value = id;
};
// Make User Admin
const userToAdmin = useMutation(MakeUserAdminDocument);
const confirmUserToAdmin = ref(false);
const userToAdminUID = ref<string | null>(null);
const makeUserAdmin = (id: string) => {
confirmUserToAdmin.value = true;
userToAdminUID.value = id;
// Resets variables if user cancels the confirmation
const resetConfirmUserToAdmin = () => {
confirmUsersToAdmin.value = false;
usersToAdminUID.value = null;
};
const makeUserAdminMutation = async (id: string | null) => {
if (!id) {
confirmUserToAdmin.value = false;
toast.error(t('state.admin_failure'));
return;
}
const variables = { uid: id };
const result = await userToAdmin.executeMutation(variables);
const makeUsersToAdmin = async (id: string | null) => {
const userUIDs = id ? [id] : selectedRows.value.map((user) => user.uid);
const variables = { userUIDs };
const result = await usersToAdmin.executeMutation(variables);
if (result.error) {
toast.error(t('state.admin_failure'));
toast.error(
id ? t('state.admin_failure') : t('state.users_to_admin_failure')
);
} else {
toast.success(t('state.admin_success'));
toast.success(
id ? t('state.admin_success') : t('state.users_to_admin_success')
);
usersList.value = usersList.value.map((user) => ({
...user,
isAdmin: user.uid === id ? true : user.isAdmin,
isAdmin: userUIDs.includes(user.uid) ? true : user.isAdmin,
}));
selectedRows.value.splice(0, selectedRows.value.length);
}
confirmUserToAdmin.value = false;
userToAdminUID.value = null;
confirmUsersToAdmin.value = false;
usersToAdminUID.value = null;
};
// Remove Admin Status from a current Admin
const adminToUser = useMutation(RemoveUserAsAdminDocument);
const confirmAdminToUser = ref(false);
const adminToUserUID = ref<string | null>(null);
// Remove Admin Status from Multiple Users
const confirmAdminsToUsers = ref(false);
const adminsToUserUID = ref<string | null>(null);
const adminsToUser = useMutation(DemoteUsersByAdminDocument);
const makeAdminToUser = (id: string) => {
confirmAdminToUser.value = true;
adminToUserUID.value = id;
const confirmAdminToUser = (id: string | null) => {
confirmAdminsToUsers.value = true;
adminsToUserUID.value = id;
};
const deleteUser = (id: string) => {
confirmDeletion.value = true;
// Resets variables if user cancels the confirmation
const resetConfirmAdminToUser = () => {
confirmAdminsToUsers.value = false;
adminsToUserUID.value = null;
};
const AreMultipleUsersSelectedToAdmin = computed(
() => selectedRows.value.length > 1
);
const makeAdminsToUsers = async (id: string | null) => {
const userUIDs = id ? [id] : selectedRows.value.map((user) => user.uid);
const variables = { userUIDs };
const result = await adminsToUser.executeMutation(variables);
if (result.error) {
toast.error(
id
? t('state.remove_admin_failure')
: t('state.remove_admin_from_users_failure')
);
} else {
toast.success(
id
? t('state.remove_admin_success')
: t('state.remove_admin_from_users_success')
);
usersList.value = usersList.value.map((user) => ({
...user,
isAdmin: userUIDs.includes(user.uid) ? false : user.isAdmin,
}));
selectedRows.value.splice(0, selectedRows.value.length);
}
confirmAdminsToUsers.value = false;
adminsToUserUID.value = null;
};
// Delete Multiple Users
const confirmUsersDeletion = ref(false);
const deleteUserUID = ref<string | null>(null);
const usersDeletion = useMutation(RemoveUsersByAdminDocument);
const confirmUserDeletion = (id: string | null) => {
confirmUsersDeletion.value = true;
deleteUserUID.value = id;
};
const makeAdminToUserMutation = async (id: string | null) => {
if (!id) {
confirmAdminToUser.value = false;
toast.error(t('state.remove_admin_failure'));
return;
}
const variables = { uid: id };
const result = await adminToUser.executeMutation(variables);
// Resets variables if user cancels the confirmation
const resetConfirmUserDeletion = () => {
confirmUsersDeletion.value = false;
deleteUserUID.value = null;
};
const AreMultipleUsersSelectedForDeletion = computed(
() => selectedRows.value.length > 1
);
const deleteUsers = async (id: string | null) => {
const userUIDs = id ? [id] : selectedRows.value.map((user) => user.uid);
const variables = { userUIDs };
const result = await usersDeletion.executeMutation(variables);
if (result.error) {
toast.error(t('state.remove_admin_failure'));
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');
toast.error(errorMessage);
} else {
toast.success(t('state.remove_admin_success'));
usersList.value = usersList.value.map((user) => ({
...user,
isAdmin: user.uid === id ? false : user.isAdmin,
}));
const deletedUsers = result.data?.removeUsersByAdmin || [];
const deletedIDs = deletedUsers
.filter((user) => user.isDeleted)
.map((user) => user.userUID);
const isAdminError = deletedUsers.some(
(user) => user.errorMessage === ADMIN_CANNOT_BE_DELETED
);
usersList.value = usersList.value.filter(
(user) => !deletedIDs.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);
}
confirmAdminToUser.value = false;
adminToUserUID.value = null;
confirmUsersDeletion.value = false;
deleteUserUID.value = null;
};
</script>

View File

@@ -6,24 +6,29 @@
</button>
</div>
<h3 class="text-lg font-bold text-accentContrast py-6">
{{ t('users.invited_users') }}
<h3 class="text-lg font-bold text-accentContrast pt-6 pb-4">
{{ t('users.pending_invites') }}
</h3>
<div class="flex flex-col">
<div class="py-2 overflow-x-auto">
<div class="relative py-2 overflow-x-auto">
<div v-if="fetching" class="flex justify-center">
<HoppSmartSpinner />
</div>
<div v-else-if="error" class="text-xl">
<div v-else-if="error">
{{ t('users.invite_load_list_error') }}
</div>
<div v-else-if="pendingInvites?.length === 0">
{{ t('users.no_invite') }}
</div>
<HoppSmartTable
v-else-if="invitedUsers?.length"
:list="invitedUsers"
v-else
:headings="headings"
:list="pendingInvites"
:selected-rows="selectedRows"
>
<template #invitedOn="{ item }">
<div v-if="item" class="pr-2 truncate">
@@ -37,32 +42,91 @@
</div>
<span v-else> - </span>
</template>
<template #action="{ item }">
<div v-if="item" class="my-1 mr-2">
<HoppButtonSecondary
v-if="xlAndLarger"
:icon="IconTrash"
:label="t('users.revoke_invitation')"
class="text-secondaryDark bg-red-500 hover:bg-red-600"
@click="confirmInviteDeletion(item.inviteeEmail)"
/>
<HoppButtonSecondary
v-else
v-tippy="{ theme: 'tooltip' }"
:icon="IconTrash"
:title="t('users.revoke_invitation')"
class="ml-2 !text-red-500"
@click="confirmInviteDeletion(item.inviteeEmail)"
/>
</div>
</template>
</HoppSmartTable>
<div v-else class="text-lg">{{ t('users.no_invite') }}</div>
<div
v-if="selectedRows.length"
class="fixed m-2 bottom-0 left-40 right-0 w-min mx-auto shadow-2xl"
>
<div
class="flex justify-center items-end bg-primaryLight border border-divider rounded-md mb-5"
>
<HoppButtonSecondary
:label="t('state.selected', { count: selectedRows.length })"
class="py-4 border-divider rounded-r-none bg-emerald-800 text-secondaryDark"
/>
<HoppButtonSecondary
:icon="IconTrash"
:label="t('users.revoke_invitation')"
class="py-4 border-divider rounded-l-none hover:bg-red-500"
@click="confirmDeletion = true"
/>
</div>
</div>
</div>
</div>
<HoppSmartConfirmModal
:show="confirmDeletion"
:title="
selectedRows.length > 0
? t('state.confirm_delete_invites')
: t('state.confirm_delete_invite')
"
@hide-modal="confirmDeletion = false"
@resolve="deleteInvitation(inviteToBeDeleted)"
/>
</div>
</template>
<script setup lang="ts">
import { useQuery } from '@urql/vue';
import { useMutation, useQuery } from '@urql/vue';
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core';
import { format } from 'date-fns';
import { computed } from 'vue';
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from '~/composables/i18n';
import { InvitedUsersDocument } from '~/helpers/backend/graphql';
import { useToast } from '~/composables/toast';
import IconTrash from '~icons/lucide/trash';
import {
InvitedUsersDocument,
InvitedUsersQuery,
RevokeUserInvitationsByAdminDocument,
} from '../../helpers/backend/graphql';
const t = useI18n();
const toast = useToast();
const router = useRouter();
const breakpoints = useBreakpoints(breakpointsTailwind);
const xlAndLarger = breakpoints.greater('xl');
// Get Proper Date Formats
const getCreatedDate = (date: string) => format(new Date(date), 'dd-MM-yyyy');
const getCreatedTime = (date: string) => format(new Date(date), 'hh:mm a');
// Get Invited Users
const { fetching, error, data } = useQuery({ query: InvitedUsersDocument });
const invitedUsers = computed(() => data?.value?.infra.invitedUsers);
// Table Headings
const headings = [
@@ -70,5 +134,56 @@ const headings = [
{ key: 'adminEmail', label: t('users.admin_email') },
{ key: 'inviteeEmail', label: t('users.invitee_email') },
{ key: 'invitedOn', label: t('users.invited_on') },
{ key: 'action', label: 'Action' },
];
// Selected Rows
const selectedRows = ref<InvitedUsersQuery['infra']['invitedUsers']>([]);
// Invited Users
const pendingInvites = computed({
get: () => data.value?.infra.invitedUsers,
set: (value) => {
if (!value) return;
data.value!.infra.invitedUsers = value;
},
});
// Delete Invite
const confirmDeletion = ref(false);
const inviteToBeDeleted = ref<string | null>(null);
const deleteInvitationMutation = useMutation(
RevokeUserInvitationsByAdminDocument
);
const confirmInviteDeletion = (inviteeEmail: string | null) => {
confirmDeletion.value = true;
inviteToBeDeleted.value = inviteeEmail;
};
const deleteInvitation = async (email: string | null) => {
const inviteeEmails = email
? [email]
: selectedRows.value.map((row) => row.inviteeEmail);
const variables = { inviteeEmails };
const result = await deleteInvitationMutation.executeMutation(variables);
if (result.error) {
email
? toast.error(t('state.delete_invite_failure'))
: toast.error(t('state.delete_invites_failure'));
} else {
pendingInvites.value = pendingInvites.value?.filter(
(user) => !inviteeEmails.includes(user.inviteeEmail)
);
selectedRows.value.splice(0, selectedRows.value.length);
email
? toast.success(t('state.delete_invite_success'))
: toast.success(t('state.delete_invites_success'));
}
confirmDeletion.value = false;
inviteToBeDeleted.value = null;
};
</script>