feat: introducing shared requests to admin dashboard (#3537)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
This commit is contained in:
committed by
GitHub
parent
6fa722df7b
commit
d9c75ed79e
@@ -19,6 +19,25 @@
|
|||||||
"owner": "OWNER",
|
"owner": "OWNER",
|
||||||
"viewer": "VIEWER"
|
"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": {
|
"state": {
|
||||||
"add_user_failure": "Failed to add user to the team!!",
|
"add_user_failure": "Failed to add user to the team!!",
|
||||||
"add_user_success": "User is now a member of the team!!",
|
"add_user_success": "User is now a member of the team!!",
|
||||||
@@ -31,8 +50,11 @@
|
|||||||
"continue_github": "Continue with Github",
|
"continue_github": "Continue with Github",
|
||||||
"continue_google": "Continue with Google",
|
"continue_google": "Continue with Google",
|
||||||
"continue_microsoft": "Continue with Microsoft",
|
"continue_microsoft": "Continue with Microsoft",
|
||||||
|
"copied_to_clipboard": "Copied to clipboard",
|
||||||
"create_team_failure": "Failed to create team!!",
|
"create_team_failure": "Failed to create team!!",
|
||||||
"create_team_success": "Team created successfully!!",
|
"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_failure": "Team deletion failed!!",
|
||||||
"delete_team_success": "Team deleted successfully!!",
|
"delete_team_success": "Team deleted successfully!!",
|
||||||
"delete_user_failure": "User deletion failed!!",
|
"delete_user_failure": "User deletion failed!!",
|
||||||
@@ -128,10 +150,12 @@
|
|||||||
"date": "Date",
|
"date": "Date",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"delete_user": "Delete User",
|
"delete_user": "Delete User",
|
||||||
|
"details": "Details",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"email_address": "Email Address",
|
"email_address": "Email Address",
|
||||||
"id": "User ID",
|
"id": "User ID",
|
||||||
"invalid_user": "Invalid User",
|
"invalid_user": "Invalid User",
|
||||||
|
"invite_load_list_error": "Unable to Load Invited Users List",
|
||||||
"invite_user": "Invite User",
|
"invite_user": "Invite User",
|
||||||
"invited_on": "Invited On",
|
"invited_on": "Invited On",
|
||||||
"invited_users": "Invited Users",
|
"invited_users": "Invited Users",
|
||||||
@@ -139,9 +163,9 @@
|
|||||||
"load_info_error": "Unable to load user info",
|
"load_info_error": "Unable to load user info",
|
||||||
"load_list_error": "Unable to Load Users List",
|
"load_list_error": "Unable to Load Users List",
|
||||||
"make_admin": "Make admin",
|
"make_admin": "Make admin",
|
||||||
"invite_load_list_error": "Unable to Load Invited Users List",
|
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"no_invite": "No invited users found",
|
"no_invite": "No invited users found",
|
||||||
|
"no_shared_requests": "No shared requests created by the user",
|
||||||
"no_users": "No users found",
|
"no_users": "No users found",
|
||||||
"not_found": "User not found",
|
"not_found": "User not found",
|
||||||
"remove_admin_privilege": "Remove Admin Privilege",
|
"remove_admin_privilege": "Remove Admin Privilege",
|
||||||
|
|||||||
10
packages/hoppscotch-sh-admin/src/components.d.ts
vendored
10
packages/hoppscotch-sh-admin/src/components.d.ts
vendored
@@ -19,25 +19,22 @@ declare module '@vue/runtime-core' {
|
|||||||
HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary']
|
HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary']
|
||||||
HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary']
|
HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary']
|
||||||
HoppSmartAnchor: typeof import('@hoppscotch/ui')['HoppSmartAnchor']
|
HoppSmartAnchor: typeof import('@hoppscotch/ui')['HoppSmartAnchor']
|
||||||
HoppSmartAutoComplete: typeof import('@hoppscotch/ui')['HoppSmartAutoComplete']
|
|
||||||
HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal']
|
HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal']
|
||||||
HoppSmartInput: typeof import('@hoppscotch/ui')['HoppSmartInput']
|
HoppSmartInput: typeof import('@hoppscotch/ui')['HoppSmartInput']
|
||||||
HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem']
|
HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem']
|
||||||
HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal']
|
|
||||||
HoppSmartPicture: typeof import('@hoppscotch/ui')['HoppSmartPicture']
|
HoppSmartPicture: typeof import('@hoppscotch/ui')['HoppSmartPicture']
|
||||||
HoppSmartPlaceholder: typeof import('@hoppscotch/ui')['HoppSmartPlaceholder']
|
|
||||||
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner']
|
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner']
|
||||||
HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab']
|
HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab']
|
||||||
HoppSmartTable: typeof import('@hoppscotch/ui')['HoppSmartTable']
|
HoppSmartTable: typeof import('@hoppscotch/ui')['HoppSmartTable']
|
||||||
|
HoppSmartTabs: typeof import('@hoppscotch/ui')['HoppSmartTabs']
|
||||||
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
|
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
|
||||||
IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default']
|
IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default']
|
||||||
IconLucideHelpCircle: typeof import('~icons/lucide/help-circle')['default']
|
|
||||||
IconLucideInbox: typeof import('~icons/lucide/inbox')['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']
|
SmartAnchor: typeof import('./../../hoppscotch-ui/src/components/smart/Anchor.vue')['default']
|
||||||
SmartAutoComplete: typeof import('./../../hoppscotch-ui/src/components/smart/AutoComplete.vue')['default']
|
SmartAutoComplete: typeof import('./../../hoppscotch-ui/src/components/smart/AutoComplete.vue')['default']
|
||||||
SmartCheckbox: typeof import('./../../hoppscotch-ui/src/components/smart/Checkbox.vue')['default']
|
SmartCheckbox: typeof import('./../../hoppscotch-ui/src/components/smart/Checkbox.vue')['default']
|
||||||
SmartConfirmModal: typeof import('./../../hoppscotch-ui/src/components/smart/ConfirmModal.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']
|
SmartExpand: typeof import('./../../hoppscotch-ui/src/components/smart/Expand.vue')['default']
|
||||||
SmartFileChip: typeof import('./../../hoppscotch-ui/src/components/smart/FileChip.vue')['default']
|
SmartFileChip: typeof import('./../../hoppscotch-ui/src/components/smart/FileChip.vue')['default']
|
||||||
SmartInput: typeof import('./../../hoppscotch-ui/src/components/smart/Input.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']
|
TeamsMembers: typeof import('./components/teams/Members.vue')['default']
|
||||||
TeamsPendingInvites: typeof import('./components/teams/PendingInvites.vue')['default']
|
TeamsPendingInvites: typeof import('./components/teams/PendingInvites.vue')['default']
|
||||||
Tippy: typeof import('vue-tippy')['Tippy']
|
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']
|
UsersInviteModal: typeof import('./components/users/InviteModal.vue')['default']
|
||||||
|
UsersSharedRequests: typeof import('./components/users/SharedRequests.vue')['default']
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
127
packages/hoppscotch-sh-admin/src/components/users/Details.vue
Normal file
127
packages/hoppscotch-sh-admin/src/components/users/Details.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { TypedDocumentNode, useClientHandle } from '@urql/vue';
|
|
||||||
import { DocumentNode } from 'graphql';
|
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { DocumentNode } from 'graphql';
|
||||||
|
import { TypedDocumentNode, useClientHandle } from '@urql/vue';
|
||||||
|
|
||||||
export function usePagedQuery<
|
export function usePagedQuery<
|
||||||
Result,
|
Result,
|
||||||
@@ -13,7 +13,6 @@ export function usePagedQuery<
|
|||||||
itemsPerPage: number,
|
itemsPerPage: number,
|
||||||
variables: Vars
|
variables: Vars
|
||||||
) {
|
) {
|
||||||
//Fetch All Users
|
|
||||||
const { client } = useClientHandle();
|
const { client } = useClientHandle();
|
||||||
const fetching = ref(true);
|
const fetching = ref(true);
|
||||||
const error = ref(false);
|
const error = ref(false);
|
||||||
@@ -23,16 +22,19 @@ export function usePagedQuery<
|
|||||||
|
|
||||||
const fetchNextPage = async () => {
|
const fetchNextPage = async () => {
|
||||||
fetching.value = true;
|
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!);
|
const resultList = getList(result.data!);
|
||||||
|
|
||||||
if (resultList.length < itemsPerPage) {
|
if (resultList.length < itemsPerPage) {
|
||||||
@@ -43,8 +45,9 @@ export function usePagedQuery<
|
|||||||
currentPage.value++;
|
currentPage.value++;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = true;
|
error.value = true;
|
||||||
|
} finally {
|
||||||
|
fetching.value = false;
|
||||||
}
|
}
|
||||||
fetching.value = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
mutation RevokeShortcodeByAdmin($codeID: ID!) {
|
||||||
|
revokeShortcodeByAdmin(code: $codeID)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
packages/hoppscotch-sh-admin/src/helpers/utils/clipboard.ts
Normal file
18
packages/hoppscotch-sh-admin/src/helpers/utils/clipboard.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
class="text-secondary border-b border-dividerDark text-sm text-left bg-primaryLight"
|
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.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>
|
<th class="px-6 py-2">{{ t('teams.members') }}</th>
|
||||||
<!-- Empty Heading for the Action Button -->
|
<!-- Empty Heading for the Action Button -->
|
||||||
<th class="px-6 py-2"></th>
|
<th class="px-6 py-2"></th>
|
||||||
|
|||||||
@@ -1,123 +1,42 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="fetching" class="flex justify-center"><HoppSmartSpinner /></div>
|
<div v-if="fetching" class="flex justify-center"><HoppSmartSpinner /></div>
|
||||||
<div v-else class="flex flex-col space-y-4">
|
<div v-else class="flex flex-col space-y-4">
|
||||||
<div>
|
<div class="flex gap-x-3">
|
||||||
<button
|
<button
|
||||||
class="p-2 mb-2 rounded-3xl bg-divider"
|
class="p-2 mb-2 rounded-3xl bg-divider"
|
||||||
@click="router.push('/users')"
|
@click="router.push('/users')"
|
||||||
>
|
>
|
||||||
<icon-lucide-arrow-left class="text-xl" />
|
<icon-lucide-arrow-left class="text-xl" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-md">
|
<div class="flex items-center space-x-3">
|
||||||
<div class="grid gap-6 mt-4">
|
<h1 class="text-lg text-accentContrast">
|
||||||
<div v-if="user.photoURL" class="relative h-20 w-20">
|
{{ user.displayName }}
|
||||||
<img class="object-cover rounded-3xl mb-3" :src="user.photoURL" />
|
</h1>
|
||||||
<span
|
<span>/</span>
|
||||||
v-if="user.isAdmin"
|
<h2 class="text-lg text-accentContrast">
|
||||||
class="absolute left-16 bottom-0 text-xs font-medium px-3 py-0.5 rounded-full bg-green-900 text-green-300"
|
{{ currentTabName }}
|
||||||
>
|
</h2>
|
||||||
{{ 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>
|
</div>
|
||||||
</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
|
<HoppSmartConfirmModal
|
||||||
:show="confirmDeletion"
|
:show="confirmDeletion"
|
||||||
:title="t('users.confirm_user_deletion')"
|
:title="t('users.confirm_user_deletion')"
|
||||||
@@ -140,29 +59,37 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref, computed } from 'vue';
|
||||||
import { useMutation } from '@urql/vue';
|
import { useMutation } from '@urql/vue';
|
||||||
import {
|
import {
|
||||||
MakeUserAdminDocument,
|
MakeUserAdminDocument,
|
||||||
UserInfoDocument,
|
UserInfoDocument,
|
||||||
RemoveUserByAdminDocument,
|
RemoveUserByAdminDocument,
|
||||||
RemoveUserAsAdminDocument,
|
RemoveUserAsAdminDocument,
|
||||||
} from '../../helpers/backend/graphql';
|
} from '~/helpers/backend/graphql';
|
||||||
import { useClientHandle } from '@urql/vue';
|
import { useClientHandle } from '@urql/vue';
|
||||||
import { format } from 'date-fns';
|
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useToast } from '~/composables/toast';
|
import { useToast } from '~/composables/toast';
|
||||||
import IconTrash from '~icons/lucide/trash';
|
|
||||||
import IconUserMinus from '~icons/lucide/user-minus';
|
|
||||||
import { useI18n } from '~/composables/i18n';
|
import { useI18n } from '~/composables/i18n';
|
||||||
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
// Get Proper Date Formats
|
// Tabs
|
||||||
const getCreatedDateAndTime = (date: string) =>
|
type OptionTabs = 'details' | 'requests';
|
||||||
format(new Date(date), 'd-MM-yyyy hh:mm a');
|
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
|
// Get User Info
|
||||||
const user = ref();
|
const user = ref();
|
||||||
|
|||||||
Reference in New Issue
Block a user