feat: introducing shared requests to admin dashboard (#3537)

Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
This commit is contained in:
Joel Jacob Stephen
2023-12-06 00:21:28 +05:30
committed by GitHub
parent 6fa722df7b
commit d9c75ed79e
11 changed files with 489 additions and 136 deletions

View File

@@ -19,6 +19,25 @@
"owner": "OWNER",
"viewer": "VIEWER"
},
"shared_requests": {
"clear_filter": "Clear Filter",
"confirm_request_deletion": "Confirm deletion of the selected shared request?",
"copy": "Copy",
"created_on": "Created On",
"delete": "Delete",
"email": "Email",
"filter": "Filter",
"filter_by_email": "Filter by email",
"id": "ID",
"load_list_error": "Unable to load shared requests list",
"no_requests": "No shared requests found",
"open_request": "Open Request",
"properties": "Properties",
"request": "Request",
"show_more": "Show more",
"title": "Shared Requests",
"url": "URL"
},
"state": {
"add_user_failure": "Failed to add user to the team!!",
"add_user_success": "User is now a member of the team!!",
@@ -31,8 +50,11 @@
"continue_github": "Continue with Github",
"continue_google": "Continue with Google",
"continue_microsoft": "Continue with Microsoft",
"copied_to_clipboard": "Copied to clipboard",
"create_team_failure": "Failed to create team!!",
"create_team_success": "Team created successfully!!",
"delete_request_failure": "Shared Request deletion failed!!",
"delete_request_success": "Shared Request deleted successfully!!",
"delete_team_failure": "Team deletion failed!!",
"delete_team_success": "Team deleted successfully!!",
"delete_user_failure": "User deletion failed!!",
@@ -128,10 +150,12 @@
"date": "Date",
"delete": "Delete",
"delete_user": "Delete User",
"details": "Details",
"email": "Email",
"email_address": "Email Address",
"id": "User ID",
"invalid_user": "Invalid User",
"invite_load_list_error": "Unable to Load Invited Users List",
"invite_user": "Invite User",
"invited_on": "Invited On",
"invited_users": "Invited Users",
@@ -139,9 +163,9 @@
"load_info_error": "Unable to load user info",
"load_list_error": "Unable to Load Users List",
"make_admin": "Make admin",
"invite_load_list_error": "Unable to Load Invited Users List",
"name": "Name",
"no_invite": "No invited users found",
"no_shared_requests": "No shared requests created by the user",
"no_users": "No users found",
"not_found": "User not found",
"remove_admin_privilege": "Remove Admin Privilege",

View File

@@ -19,25 +19,22 @@ declare module '@vue/runtime-core' {
HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary']
HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary']
HoppSmartAnchor: typeof import('@hoppscotch/ui')['HoppSmartAnchor']
HoppSmartAutoComplete: typeof import('@hoppscotch/ui')['HoppSmartAutoComplete']
HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal']
HoppSmartInput: typeof import('@hoppscotch/ui')['HoppSmartInput']
HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem']
HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal']
HoppSmartPicture: typeof import('@hoppscotch/ui')['HoppSmartPicture']
HoppSmartPlaceholder: typeof import('@hoppscotch/ui')['HoppSmartPlaceholder']
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner']
HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab']
HoppSmartTable: typeof import('@hoppscotch/ui')['HoppSmartTable']
HoppSmartTabs: typeof import('@hoppscotch/ui')['HoppSmartTabs']
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default']
IconLucideHelpCircle: typeof import('~icons/lucide/help-circle')['default']
IconLucideInbox: typeof import('~icons/lucide/inbox')['default']
IconLucideUser: typeof import('~icons/lucide/user')['default']
SmartAnchor: typeof import('./../../hoppscotch-ui/src/components/smart/Anchor.vue')['default']
SmartAutoComplete: typeof import('./../../hoppscotch-ui/src/components/smart/AutoComplete.vue')['default']
SmartCheckbox: typeof import('./../../hoppscotch-ui/src/components/smart/Checkbox.vue')['default']
SmartConfirmModal: typeof import('./../../hoppscotch-ui/src/components/smart/ConfirmModal.vue')['default']
SmartEnvInput: typeof import('./components/smart/EnvInput.vue')['default']
SmartExpand: typeof import('./../../hoppscotch-ui/src/components/smart/Expand.vue')['default']
SmartFileChip: typeof import('./../../hoppscotch-ui/src/components/smart/FileChip.vue')['default']
SmartInput: typeof import('./../../hoppscotch-ui/src/components/smart/Input.vue')['default']
@@ -66,7 +63,10 @@ declare module '@vue/runtime-core' {
TeamsMembers: typeof import('./components/teams/Members.vue')['default']
TeamsPendingInvites: typeof import('./components/teams/PendingInvites.vue')['default']
Tippy: typeof import('vue-tippy')['Tippy']
UiAutoResetIcon: typeof import('./components/ui/AutoResetIcon.vue')['default']
UsersDetails: typeof import('./components/users/Details.vue')['default']
UsersInviteModal: typeof import('./components/users/InviteModal.vue')['default']
UsersSharedRequests: typeof import('./components/users/SharedRequests.vue')['default']
}
}

View File

@@ -0,0 +1,47 @@
<template>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="title"
:icon="icon"
:color="icon === props.icon.default ? props.color : props.resetColor"
class="px-3"
@click="iconClickHandler"
/>
</template>
<script setup lang="ts">
import { FunctionalComponent, SVGAttributes } from 'vue';
import { refAutoReset } from '@vueuse/core';
const props = withDefaults(
defineProps<{
title: string;
color?: string;
// color to be displayed temporarily until reset
resetColor?: string;
icon: {
default: FunctionalComponent<SVGAttributes, {}>;
// icon to be displayed temporarily until reset
temporary: FunctionalComponent<SVGAttributes, {}>;
};
}>(),
{
color: 'white',
resetColor: 'emerald',
}
);
const emit = defineEmits<{
(e: 'click'): void;
}>();
const icon = refAutoReset<FunctionalComponent<SVGAttributes, {}>>(
props.icon.default,
1000
);
const iconClickHandler = () => {
icon.value = props.icon.temporary;
emit('click');
};
</script>

View File

@@ -0,0 +1,127 @@
<template>
<div class="rounded-md">
<div class="grid gap-6 mt-4">
<div
class="relative"
:class="
user.photoURL
? 'h-20 w-20'
: 'bg-primaryDark w-16 p-3 rounded-2xl mb-3'
"
>
<img
v-if="user.photoURL"
class="object-cover rounded-3xl mb-3"
:src="user.photoURL"
/>
<icon-lucide-user v-else class="text-4xl" />
<span
v-if="user.isAdmin"
class="absolute left-16 bottom-0 text-xs font-medium px-3 py-0.5 rounded-full bg-green-900 text-green-300"
>
{{ t('users.admin') }}
</span>
</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
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"
>
<span>{{ info.value }}</span>
</div>
</div>
</template>
</div>
<div class="flex justify-start mt-8">
<HoppButtonPrimary
:icon="user.isAdmin ? IconUserMinus : IconUserCheck"
:label="
user.isAdmin
? t('users.remove_admin_privilege')
: t('users.make_admin')
"
filled
outline
class="mr-4"
@click="
user.isAdmin
? emit('remove-admin', user.uid)
: emit('make-admin', user.uid)
"
/>
<HoppButtonSecondary
:icon="IconTrash"
:label="t('users.delete')"
filled
outline
class="mr-4 bg-red-600 text-white hover:text-gray-100"
@click="
user.isAdmin
? toast.error(t('state.remove_admin_to_delete_user'))
: emit('delete-user', user.uid)
"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { format } from 'date-fns';
import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast';
import IconTrash from '~icons/lucide/trash';
import IconUserCheck from '~icons/lucide/user-check';
import IconUserMinus from '~icons/lucide/user-minus';
import { UserInfoQuery } from '~/helpers/backend/graphql';
const t = useI18n();
const toast = useToast();
const props = defineProps<{
user: UserInfoQuery['infra']['userInfo'];
}>();
const emit = defineEmits<{
(event: 'delete-user', userID: string): void;
(event: 'make-admin', userID: string): void;
(event: 'remove-admin', userID: string): void;
}>();
// Get Proper Date Formats
const getCreatedDateAndTime = (date: string) =>
format(new Date(date), 'd-MM-yyyy hh:mm a');
// User Info
const { uid, displayName, email, createdOn } = props.user;
const userInfo = {
uid: {
condition: uid,
label: t('users.uid'),
value: uid,
},
displayName: {
condition: true,
label: t('users.name'),
value: displayName ?? t('users.unnamed'),
},
email: {
condition: email,
label: t('users.email'),
value: email,
},
createdOn: {
condition: createdOn,
label: t('users.created_on'),
value: getCreatedDateAndTime(createdOn),
},
};
</script>

View File

@@ -0,0 +1,191 @@
<template>
<div>
<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="ml-3 mt-5 text-lg">
{{ t('users.no_shared_requests') }}
</div>
<div v-else class="mt-10">
<HoppSmartTable :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">Actions</th>
</tr>
</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>
<td class="py-4 px-7 w-96">
{{ sharedRequestURL(request.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="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"
/>
<UiAutoResetIcon
:title="t('shared_requests.copy')"
:icon="{ default: IconCopy, temporary: IconCheck }"
@click="copySharedRequest(request.id)"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('shared_requests.delete')"
:icon="IconTrash"
color="red"
class="px-3"
@click="deleteSharedRequest(request.id)"
/>
</td>
</tr>
</template>
</HoppSmartTable>
<!-- Pagination -->
<div
v-if="hasNextPage && sharedRequests.length >= sharedRequestsPerPage"
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="fetchNextSharedRequests"
>
<span class="mr-2">{{ t('shared_requests.show_more') }}</span>
<icon-lucide-chevron-down />
</div>
</div>
</div>
<HoppSmartConfirmModal
:show="confirmDeletion"
:title="t('shared_requests.confirm_request_deletion')"
@hide-modal="confirmDeletion = false"
@resolve="deleteSharedRequestMutation(deleteSharedRequestID)"
/>
</template>
<script setup lang="ts">
import { format } from 'date-fns';
import { ref } from 'vue';
import { useMutation } from '@urql/vue';
import {
SharedRequestsDocument,
RevokeShortcodeByAdminDocument,
} from '../../helpers/backend/graphql';
import { usePagedQuery } from '~/composables/usePagedQuery';
import { useToast } from '~/composables/toast';
import { useI18n } from '~/composables/i18n';
import { copyToClipboard } from '~/helpers/utils/clipboard';
import IconTrash from '~icons/lucide/trash';
import IconCopy from '~icons/lucide/copy';
import IconCheck from '~icons/lucide/check';
import IconExternalLink from '~icons/lucide/external-link';
const t = useI18n();
const toast = useToast();
const props = defineProps<{
email: string;
}>();
// Get Desired Date Formats
const getCreatedDate = (date: string) => format(new Date(date), 'dd-MM-yyyy');
const getCreatedTime = (date: string) => format(new Date(date), 'hh:mm a');
//Fetch Shared Requests
const sharedRequestsPerPage = 30;
const {
fetching,
error,
goToNextPage: fetchNextSharedRequests,
refetch,
list: sharedRequests,
hasNextPage,
} = usePagedQuery(
SharedRequestsDocument,
(x) => x.infra.allShortcodes,
(x) => x.id,
sharedRequestsPerPage,
{ cursor: undefined, take: sharedRequestsPerPage, email: props.email }
);
// Return request endpoint from the request object
const sharedRequestURL = (request: string) => {
const parsedRequest = JSON.parse(request);
return parsedRequest.endpoint;
};
// Shortcode Base URL
const shortcodeBaseURL =
import.meta.env.VITE_SHORTCODE_BASE_URL ?? 'https://hopp.sh';
// Copy Shared Request to Clipboard
const copySharedRequest = (requestID: string) => {
copyToClipboard(`${shortcodeBaseURL}/r/${requestID}`);
toast.success(`${t('state.copied_to_clipboard')}`);
};
// Shared Request Deletion
const confirmDeletion = ref(false);
const deleteSharedRequestID = ref<string | null>(null);
const sharedRequestDeletion = useMutation(RevokeShortcodeByAdminDocument);
const deleteSharedRequest = (id: string) => {
confirmDeletion.value = true;
deleteSharedRequestID.value = id;
};
const deleteSharedRequestMutation = async (id: string | null) => {
if (!id) {
confirmDeletion.value = false;
toast.error(`${t('state.delete_request_failure')}`);
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')}`);
}
});
confirmDeletion.value = false;
deleteSharedRequestID.value = null;
};
</script>

View File

@@ -1,6 +1,6 @@
import { TypedDocumentNode, useClientHandle } from '@urql/vue';
import { DocumentNode } from 'graphql';
import { onMounted, ref } from 'vue';
import { DocumentNode } from 'graphql';
import { TypedDocumentNode, useClientHandle } from '@urql/vue';
export function usePagedQuery<
Result,
@@ -13,7 +13,6 @@ export function usePagedQuery<
itemsPerPage: number,
variables: Vars
) {
//Fetch All Users
const { client } = useClientHandle();
const fetching = ref(true);
const error = ref(false);
@@ -23,16 +22,19 @@ export function usePagedQuery<
const fetchNextPage = async () => {
fetching.value = true;
try {
const result = await client
.query(query, {
...variables,
take: itemsPerPage,
cursor:
list.value.length > 0 ? getCursor(list.value.at(-1)) : undefined,
})
.toPromise();
try {
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();
const resultList = getList(result.data!);
if (resultList.length < itemsPerPage) {
@@ -43,8 +45,9 @@ export function usePagedQuery<
currentPage.value++;
} catch (e) {
error.value = true;
} finally {
fetching.value = false;
}
fetching.value = false;
};
onMounted(async () => {

View File

@@ -0,0 +1,3 @@
mutation RevokeShortcodeByAdmin($codeID: ID!) {
revokeShortcodeByAdmin(code: $codeID)
}

View File

@@ -0,0 +1,13 @@
query SharedRequests($cursor: ID, $take: Int, $email: String) {
infra {
allShortcodes(cursor: $cursor, take: $take, userEmail: $email) {
id
request
properties
createdOn
creator {
email
}
}
}
}

View File

@@ -0,0 +1,18 @@
/**
* Copies a given string to the clipboard using
* the legacy exec method
*
* @param content The content to be copied
*/
export function copyToClipboard(content: string) {
if (navigator.clipboard) {
navigator.clipboard.writeText(content);
} else {
const dummy = document.createElement('textarea');
document.body.appendChild(dummy);
dummy.value = content;
dummy.select();
document.execCommand('copy');
document.body.removeChild(dummy);
}
}

View File

@@ -24,7 +24,7 @@
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-3">{{ t('teams.name') }}</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>

View File

@@ -1,123 +1,42 @@
<template>
<div v-if="fetching" class="flex justify-center"><HoppSmartSpinner /></div>
<div v-else class="flex flex-col space-y-4">
<div>
<div class="flex gap-x-3">
<button
class="p-2 mb-2 rounded-3xl bg-divider"
@click="router.push('/users')"
>
<icon-lucide-arrow-left class="text-xl" />
</button>
</div>
<div class="rounded-md">
<div class="grid gap-6 mt-4">
<div v-if="user.photoURL" class="relative h-20 w-20">
<img class="object-cover rounded-3xl mb-3" :src="user.photoURL" />
<span
v-if="user.isAdmin"
class="absolute left-16 bottom-0 text-xs font-medium px-3 py-0.5 rounded-full bg-green-900 text-green-300"
>
{{ t('users.admin') }}
</span>
</div>
<div v-else class="bg-primaryDark w-16 p-3 rounded-2xl mb-3 relative">
<icon-lucide-user class="text-4xl" />
<span
v-if="user.isAdmin"
class="absolute left-16 bottom-0 text-xs font-medium px-3 py-0.5 rounded-full bg-green-900 text-green-300"
>
{{ t('users.admin') }}
</span>
</div>
<div v-if="user.uid">
<label class="text-secondaryDark" for="username">{{
t('users.uid')
}}</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"
>
{{ user.uid }}
</div>
</div>
<div>
<label class="text-secondaryDark" for="username">{{
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"
>
<span v-if="user.displayName">
{{ user.displayName }}
</span>
<span v-else> {{ t('users.unnamed') }} </span>
</div>
</div>
<div v-if="user.email">
<label class="text-secondaryDark" for="username">{{
t('users.email')
}}</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"
>
{{ user.email }}
</div>
</div>
<div v-if="user.createdOn">
<label class="text-secondaryDark" for="username">{{
t('users.created_on')
}}</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"
>
{{ getCreatedDateAndTime(user.createdOn) }}
</div>
</div>
</div>
<div class="flex justify-start mt-8">
<span v-if="!user.isAdmin">
<HoppButtonPrimary
class="mr-4"
filled
outline
:label="t('users.make_admin')"
@click="makeUserAdmin(user.uid)"
/>
</span>
<span v-else>
<HoppButtonPrimary
class="mr-4"
filled
outline
:icon="IconUserMinus"
:label="t('users.remove_admin_privilege')"
@click="makeAdminToUser(user.uid)"
/>
</span>
<HoppButtonSecondary
v-if="!user.isAdmin"
class="mr-4 bg-red-600 text-white hover:text-gray-100"
filled
outline
:label="t('users.delete')"
:icon="IconTrash"
@click="deleteUser(user.uid)"
/>
<HoppButtonSecondary
v-if="user.isAdmin"
class="mr-4 bg-red-600 text-white hover:text-gray-100"
filled
outline
:icon="IconTrash"
:label="t('users.delete')"
@click="toast.error(t('state.remove_admin_to_delete_user'))"
/>
<div class="flex items-center space-x-3">
<h1 class="text-lg text-accentContrast">
{{ user.displayName }}
</h1>
<span>/</span>
<h2 class="text-lg text-accentContrast">
{{ currentTabName }}
</h2>
</div>
</div>
<div class="pb-8">
<HoppSmartTabs v-model="selectedOptionTab" render-inactive-tabs>
<HoppSmartTab :id="'details'" :label="t('users.details')">
<UsersDetails
:user="user"
@delete-user="deleteUser"
@make-admin="makeUserAdmin"
@remove-admin="makeAdminToUser"
class="py-8 px-4"
/>
</HoppSmartTab>
<HoppSmartTab :id="'requests'" :label="t('shared_requests.title')">
<UsersSharedRequests :email="user.email" class="py-8 px-4 mt-10" />
</HoppSmartTab>
</HoppSmartTabs>
</div>
<HoppSmartConfirmModal
:show="confirmDeletion"
:title="t('users.confirm_user_deletion')"
@@ -140,29 +59,37 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { onMounted, ref, computed } from 'vue';
import { useMutation } from '@urql/vue';
import {
MakeUserAdminDocument,
UserInfoDocument,
RemoveUserByAdminDocument,
RemoveUserAsAdminDocument,
} from '../../helpers/backend/graphql';
} from '~/helpers/backend/graphql';
import { useClientHandle } from '@urql/vue';
import { format } from 'date-fns';
import { useRoute, useRouter } from 'vue-router';
import { useToast } from '~/composables/toast';
import IconTrash from '~icons/lucide/trash';
import IconUserMinus from '~icons/lucide/user-minus';
import { useI18n } from '~/composables/i18n';
const t = useI18n();
const toast = useToast();
// Get Proper Date Formats
const getCreatedDateAndTime = (date: string) =>
format(new Date(date), 'd-MM-yyyy hh:mm a');
// Tabs
type OptionTabs = 'details' | 'requests';
const selectedOptionTab = ref<OptionTabs>('details');
const currentTabName = computed(() => {
switch (selectedOptionTab.value) {
case 'details':
return t('users.details');
case 'requests':
return t('shared_requests.title');
default:
return '';
}
});
// Get User Info
const user = ref();