feat(sh-admin): enhanced user management in admin dashboard (#3814)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
This commit is contained in:
committed by
GitHub
parent
8fdba760a2
commit
acfb0189df
@@ -29,8 +29,11 @@ declare module '@vue/runtime-core' {
|
||||
HoppSmartTable: typeof import('@hoppscotch/ui')['HoppSmartTable']
|
||||
HoppSmartTabs: typeof import('@hoppscotch/ui')['HoppSmartTabs']
|
||||
HoppSmartToggle: typeof import('@hoppscotch/ui')['HoppSmartToggle']
|
||||
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
|
||||
IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default']
|
||||
IconLucideInbox: typeof import('~icons/lucide/inbox')['default']
|
||||
IconLucideSearch: typeof import('~icons/lucide/search')['default']
|
||||
IconLucideUser: typeof import('~icons/lucide/user')['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']
|
||||
@@ -48,6 +51,7 @@ declare module '@vue/runtime-core' {
|
||||
UsersDetails: typeof import('./components/users/Details.vue')['default']
|
||||
UsersInviteModal: typeof import('./components/users/InviteModal.vue')['default']
|
||||
UsersSharedRequests: typeof import('./components/users/SharedRequests.vue')['default']
|
||||
UsersTable: typeof import('./components/users/Table.vue')['default']
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -24,11 +24,40 @@
|
||||
</div>
|
||||
|
||||
<template v-for="(info, key) in userInfo" :key="key">
|
||||
<div v-if="info.condition">
|
||||
<label class="text-secondaryDark" :for="key">{{ info.label }}</label>
|
||||
<div v-if="key === 'displayName'" class="flex flex-col space-y-3">
|
||||
<label class="text-accentContrast" for="teamname"
|
||||
>{{ t('users.name') }}
|
||||
</label>
|
||||
<div
|
||||
class="w-full p-3 mt-2 bg-divider border-gray-600 rounded-md focus:border-emerald-600 focus:ring focus:ring-opacity-40 focus:ring-emerald-500"
|
||||
class="flex bg-divider rounded-md items-stretch flex-1 border border-divider"
|
||||
:class="{
|
||||
'!border-accent': isNameBeingEdited,
|
||||
}"
|
||||
>
|
||||
<HoppSmartInput
|
||||
v-model="updatedUserName"
|
||||
styles="bg-transparent flex-1 rounded-md !rounded-r-none disabled:select-none border-r-0 disabled:cursor-default disabled:opacity-50"
|
||||
:placeholder="t('users.name')"
|
||||
:disabled="!isNameBeingEdited"
|
||||
>
|
||||
<template #button>
|
||||
<HoppButtonPrimary
|
||||
class="!rounded-l-none"
|
||||
filled
|
||||
:icon="isNameBeingEdited ? IconSave : IconEdit"
|
||||
:label="
|
||||
isNameBeingEdited ? t('users.rename') : t('users.edit')
|
||||
"
|
||||
@click="handleNameEdit"
|
||||
/>
|
||||
</template>
|
||||
</HoppSmartInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="info.condition">
|
||||
<label class="text-secondaryDark" :for="key">{{ info.label }}</label>
|
||||
<div class="w-full p-3 mt-2 bg-divider border-gray-600 rounded-md">
|
||||
<span>{{ info.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -70,10 +99,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMutation } from '@urql/vue';
|
||||
import { format } from 'date-fns';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from '~/composables/i18n';
|
||||
import { useToast } from '~/composables/toast';
|
||||
import { UserInfoQuery } from '~/helpers/backend/graphql';
|
||||
import {
|
||||
UpdateUserDisplayNameByAdminDocument,
|
||||
UserInfoQuery,
|
||||
} from '~/helpers/backend/graphql';
|
||||
import IconEdit from '~icons/lucide/edit';
|
||||
import IconSave from '~icons/lucide/save';
|
||||
import IconTrash from '~icons/lucide/trash';
|
||||
import IconUserCheck from '~icons/lucide/user-check';
|
||||
import IconUserMinus from '~icons/lucide/user-minus';
|
||||
@@ -89,6 +125,7 @@ const emit = defineEmits<{
|
||||
(event: 'delete-user', userID: string): void;
|
||||
(event: 'make-admin', userID: string): void;
|
||||
(event: 'remove-admin', userID: string): void;
|
||||
(event: 'update-user-name', newName: string): void;
|
||||
}>();
|
||||
|
||||
// Get Proper Date Formats
|
||||
@@ -120,4 +157,62 @@ const userInfo = {
|
||||
value: getCreatedDateAndTime(createdOn),
|
||||
},
|
||||
};
|
||||
|
||||
// Contains the actual user name
|
||||
const userName = computed({
|
||||
get: () => props.user.displayName,
|
||||
set: (value) => {
|
||||
return value;
|
||||
},
|
||||
});
|
||||
|
||||
// Contains the stored user name from the actual name before being edited
|
||||
const currentUserName = ref('');
|
||||
|
||||
// Set the current user name to the actual user name
|
||||
onMounted(() => {
|
||||
if (displayName) currentUserName.value = displayName;
|
||||
});
|
||||
|
||||
// Contains the user name that is being edited
|
||||
const updatedUserName = computed({
|
||||
get: () => currentUserName.value,
|
||||
set: (value) => {
|
||||
currentUserName.value = value;
|
||||
},
|
||||
});
|
||||
|
||||
// Rename the user
|
||||
const isNameBeingEdited = ref(false);
|
||||
const userRename = useMutation(UpdateUserDisplayNameByAdminDocument);
|
||||
|
||||
const handleNameEdit = () => {
|
||||
if (isNameBeingEdited.value) {
|
||||
// If the name is not changed, then return control
|
||||
if (userName.value !== updatedUserName.value) {
|
||||
renameUserName();
|
||||
} else isNameBeingEdited.value = false;
|
||||
} else {
|
||||
isNameBeingEdited.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const renameUserName = async () => {
|
||||
if (updatedUserName.value?.trim() === '') {
|
||||
toast.error(t('users.empty_name'));
|
||||
return;
|
||||
}
|
||||
|
||||
const variables = { userUID: uid, name: updatedUserName.value as string };
|
||||
const result = await userRename.executeMutation(variables);
|
||||
|
||||
if (result.error) {
|
||||
toast.error(t('state.rename_user_failure'));
|
||||
} else {
|
||||
isNameBeingEdited.value = false;
|
||||
toast.success(t('state.rename_user_success'));
|
||||
emit('update-user-name', updatedUserName.value as string);
|
||||
userName.value = updatedUserName.value;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
v-model="email"
|
||||
:label="t('users.email_address')"
|
||||
input-styles="floating-input"
|
||||
@submit="sendInvite"
|
||||
/>
|
||||
</template>
|
||||
<template #footer>
|
||||
@@ -18,7 +19,12 @@
|
||||
:label="t('users.send_invite')"
|
||||
@click="sendInvite"
|
||||
/>
|
||||
<HoppButtonSecondary label="Cancel" outline filled @click="hideModal" />
|
||||
<HoppButtonSecondary
|
||||
:label="t('users.cancel')"
|
||||
outline
|
||||
filled
|
||||
@click="hideModal"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</HoppSmartModal>
|
||||
|
||||
@@ -1,78 +1,68 @@
|
||||
<template>
|
||||
<div class="px-4">
|
||||
<div class="px-4 mt-7">
|
||||
<div v-if="fetching" class="flex justify-center">
|
||||
<HoppSmartSpinner />
|
||||
</div>
|
||||
|
||||
<div v-else-if="error">{{ t('shared_requests.load_list_error') }}</div>
|
||||
|
||||
<div v-else-if="sharedRequests.length === 0" class="mt-5">
|
||||
<div v-else-if="sharedRequests.length === 0">
|
||||
{{ t('users.no_shared_requests') }}
|
||||
</div>
|
||||
|
||||
<HoppSmartTable v-else class="mt-8" :list="sharedRequests">
|
||||
<HoppSmartTable v-else :headings="headings" :list="sharedRequests">
|
||||
<template #head>
|
||||
<tr
|
||||
class="text-secondary border-b border-dividerDark text-sm text-left bg-primaryLight"
|
||||
>
|
||||
<th class="px-6 py-2">{{ t('shared_requests.id') }}</th>
|
||||
<th class="px-6 py-2 w-30">{{ t('shared_requests.url') }}</th>
|
||||
<th class="px-6 py-2">{{ t('shared_requests.created_on') }}</th>
|
||||
<!-- Empty Heading for the Action Button -->
|
||||
<th class="px-6 py-2 text-center">
|
||||
{{ t('shared_requests.action') }}
|
||||
</th>
|
||||
</tr>
|
||||
<th class="px-6 py-2">{{ t('shared_requests.id') }}</th>
|
||||
<th class="px-6 py-2 w-30">{{ t('shared_requests.url') }}</th>
|
||||
<th class="px-6 py-2">{{ t('shared_requests.created_on') }}</th>
|
||||
<!-- Empty Heading for the Action Button -->
|
||||
<th class="px-6 py-2 text-center">
|
||||
{{ t('shared_requests.action') }}
|
||||
</th>
|
||||
</template>
|
||||
<template #body="{ list: sharedRequests }">
|
||||
<tr
|
||||
v-for="request in sharedRequests"
|
||||
:key="request.id"
|
||||
class="text-secondaryDark hover:bg-divider hover:cursor-pointer rounded-xl"
|
||||
>
|
||||
<td class="flex py-4 px-7 max-w-50">
|
||||
<span class="truncate">
|
||||
{{ request.id }}
|
||||
</span>
|
||||
</td>
|
||||
<template #body="{ row: sharedRequest }">
|
||||
<td class="flex py-4 px-7 max-w-50">
|
||||
<span class="truncate">
|
||||
{{ sharedRequest.id }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td class="py-4 px-7 w-96">
|
||||
{{ sharedRequestURL(request.request) }}
|
||||
</td>
|
||||
<td class="py-4 px-7 w-96">
|
||||
{{ sharedRequestURL(sharedRequest.request) }}
|
||||
</td>
|
||||
|
||||
<td class="py-2 px-7">
|
||||
{{ getCreatedDate(request.createdOn) }}
|
||||
<div class="text-gray-400 text-tiny">
|
||||
{{ getCreatedTime(request.createdOn) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-2 px-7">
|
||||
{{ getCreatedDate(sharedRequest.createdOn) }}
|
||||
<div class="text-gray-400 text-tiny">
|
||||
{{ getCreatedTime(sharedRequest.createdOn) }}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="flex justify-center">
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('shared_requests.open_request')"
|
||||
:to="`${shortcodeBaseURL}/r/${request.id}`"
|
||||
:blank="true"
|
||||
:icon="IconExternalLink"
|
||||
class="px-3 text-emerald-500 hover:text-accent"
|
||||
/>
|
||||
<td class="flex justify-center">
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('shared_requests.open_request')"
|
||||
:to="`${shortcodeBaseURL}/r/${sharedRequest.id}`"
|
||||
:blank="true"
|
||||
:icon="IconExternalLink"
|
||||
class="px-3 text-emerald-500 hover:text-accent"
|
||||
/>
|
||||
|
||||
<UiAutoResetIcon
|
||||
:title="t('shared_requests.copy')"
|
||||
:icon="{ default: IconCopy, temporary: IconCheck }"
|
||||
@click="copySharedRequest(request.id)"
|
||||
/>
|
||||
<UiAutoResetIcon
|
||||
:title="t('shared_requests.copy')"
|
||||
:icon="{ default: IconCopy, temporary: IconCheck }"
|
||||
@click="copySharedRequest(sharedRequest.id)"
|
||||
/>
|
||||
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('shared_requests.delete')"
|
||||
:icon="IconTrash"
|
||||
color="red"
|
||||
class="px-3"
|
||||
@click="deleteSharedRequest(request.id)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('shared_requests.delete')"
|
||||
:icon="IconTrash"
|
||||
color="red"
|
||||
class="px-3"
|
||||
@click="deleteSharedRequest(sharedRequest.id)"
|
||||
/>
|
||||
</td>
|
||||
</template>
|
||||
</HoppSmartTable>
|
||||
|
||||
@@ -136,11 +126,18 @@ const {
|
||||
} = usePagedQuery(
|
||||
SharedRequestsDocument,
|
||||
(x) => x.infra.allShortcodes,
|
||||
(x) => x.id,
|
||||
sharedRequestsPerPage,
|
||||
{ cursor: undefined, take: sharedRequestsPerPage, email: props.email }
|
||||
{ cursor: undefined, take: sharedRequestsPerPage, email: props.email },
|
||||
(x) => x.id
|
||||
);
|
||||
|
||||
const headings = [
|
||||
{ key: 'id', label: t('shared_requests.id') },
|
||||
{ key: 'request', label: t('shared_requests.url') },
|
||||
{ key: 'createdOn', label: t('shared_requests.created_on') },
|
||||
{ key: 'action', label: t('shared_requests.action') },
|
||||
];
|
||||
|
||||
// Return request endpoint from the request object
|
||||
const sharedRequestURL = (request: string) => {
|
||||
const parsedRequest = JSON.parse(request);
|
||||
@@ -174,17 +171,17 @@ const deleteSharedRequestMutation = async (id: string | null) => {
|
||||
return;
|
||||
}
|
||||
const variables = { codeID: id };
|
||||
await sharedRequestDeletion.executeMutation(variables).then((result) => {
|
||||
if (result.error) {
|
||||
toast.error(t('state.delete_request_failure'));
|
||||
} else {
|
||||
sharedRequests.value = sharedRequests.value.filter(
|
||||
(request) => request.id !== id
|
||||
);
|
||||
refetch();
|
||||
toast.success(t('state.delete_request_success'));
|
||||
}
|
||||
});
|
||||
const result = await sharedRequestDeletion.executeMutation(variables);
|
||||
if (result.error) {
|
||||
toast.error(t('state.delete_request_failure'));
|
||||
} else {
|
||||
sharedRequests.value = sharedRequests.value.filter(
|
||||
(request) => request.id !== id
|
||||
);
|
||||
refetch();
|
||||
toast.success(t('state.delete_request_success'));
|
||||
}
|
||||
|
||||
confirmDeletion.value = false;
|
||||
deleteSharedRequestID.value = null;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Ref, onMounted, ref } from 'vue';
|
||||
import { onMounted, ref, Ref } from 'vue';
|
||||
import { DocumentNode } from 'graphql';
|
||||
import { TypedDocumentNode, useClientHandle } from '@urql/vue';
|
||||
|
||||
@@ -9,9 +9,9 @@ export function usePagedQuery<
|
||||
>(
|
||||
query: string | TypedDocumentNode<Result, Vars> | DocumentNode,
|
||||
getList: (result: Result) => ListItem[],
|
||||
getCursor: (value: ListItem) => string,
|
||||
itemsPerPage: number,
|
||||
variables: Vars
|
||||
baseVariables: Vars,
|
||||
getCursor?: (value: ListItem) => string
|
||||
) {
|
||||
const { client } = useClientHandle();
|
||||
const fetching = ref(true);
|
||||
@@ -20,21 +20,25 @@ export function usePagedQuery<
|
||||
const currentPage = ref(0);
|
||||
const hasNextPage = ref(true);
|
||||
|
||||
const fetchNextPage = async () => {
|
||||
const fetchNextPage = async (additionalVariables?: Vars) => {
|
||||
let variables = { ...baseVariables };
|
||||
|
||||
fetching.value = true;
|
||||
|
||||
const cursor =
|
||||
list.value.length > 0 ? getCursor(list.value.at(-1)!) : undefined;
|
||||
const variablesForPagination = {
|
||||
...variables,
|
||||
take: itemsPerPage,
|
||||
cursor,
|
||||
};
|
||||
|
||||
const result = await client
|
||||
.query(query, variablesForPagination)
|
||||
.toPromise();
|
||||
// Cursor based pagination
|
||||
if (getCursor) {
|
||||
const cursor =
|
||||
list.value.length > 0
|
||||
? getCursor(list.value.at(-1) as ListItem)
|
||||
: undefined;
|
||||
variables = { ...variables, cursor };
|
||||
}
|
||||
// Offset based pagination
|
||||
else if (additionalVariables) {
|
||||
variables = { ...variables, ...additionalVariables };
|
||||
}
|
||||
|
||||
const result = await client.query(query, variables).toPromise();
|
||||
if (result.error) {
|
||||
error.value = true;
|
||||
fetching.value = false;
|
||||
@@ -63,11 +67,14 @@ export function usePagedQuery<
|
||||
}
|
||||
};
|
||||
|
||||
const refetch = async () => {
|
||||
const refetch = async (variables?: Vars) => {
|
||||
currentPage.value = 0;
|
||||
hasNextPage.value = true;
|
||||
list.value = [];
|
||||
await fetchNextPage();
|
||||
|
||||
if (hasNextPage.value) {
|
||||
variables ? await fetchNextPage(variables) : await fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation DemoteUsersByAdmin($userUIDs: [ID!]!) {
|
||||
demoteUsersByAdmin(userUIDs: $userUIDs)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
mutation MakeUserAdmin($uid: ID!) {
|
||||
makeUserAdmin(userUID: $uid)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation MakeUsersAdmin($userUIDs: [ID!]!) {
|
||||
makeUsersAdmin(userUIDs: $userUIDs)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
mutation RemoveUserAsAdmin($uid: ID!) {
|
||||
removeUserAsAdmin(userUID: $uid)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
mutation RemoveUserByAdmin($uid: ID!) {
|
||||
removeUserByAdmin(userUID: $uid)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
mutation RemoveUsersByAdmin($userUIDs: [ID!]!) {
|
||||
removeUsersByAdmin(userUIDs: $userUIDs) {
|
||||
userUID
|
||||
isDeleted
|
||||
errorMessage
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation RevokeUserInvitationsByAdmin($inviteeEmails: [String!]!) {
|
||||
revokeUserInvitationsByAdmin(inviteeEmails: $inviteeEmails)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation UpdateUserDisplayNameByAdmin($userUID: ID!, $name: String!) {
|
||||
updateUserDisplayNameByAdmin(userUID: $userUID, displayName: $name)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
query UsersListV2($searchString: String, $skip: Int, $take: Int) {
|
||||
infra {
|
||||
allUsersV2(searchString: $searchString, skip: $skip, take: $take) {
|
||||
uid
|
||||
displayName
|
||||
email
|
||||
isAdmin
|
||||
photoURL
|
||||
createdOn
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,3 +7,13 @@ export const UNAUTHORIZED = 'Unauthorized' as const;
|
||||
|
||||
// Sometimes the backend returns Unauthorized error message as follows:
|
||||
export const GRAPHQL_UNAUTHORIZED = '[GraphQL] Unauthorized' as const;
|
||||
|
||||
export const DELETE_USER_FAILED_ONLY_ONE_ADMIN =
|
||||
'admin/only_one_admin_account_found' as const;
|
||||
|
||||
export const ADMIN_CANNOT_BE_DELETED =
|
||||
'admin/admin_can_not_be_deleted' as const;
|
||||
|
||||
// When trying to invite a user that is already invited
|
||||
export const USER_ALREADY_INVITED =
|
||||
'[GraphQL] admin/user_already_invited' as const;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user