mirror of
https://github.com/unraid/api.git
synced 2026-01-06 08:39:54 -06:00
refactor: swap out dropdown with reka components (#1245)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced a new `DropdownMenu` component in user profiles with dynamic content rendering. - Added a new `Popover` component with interactive Storybook demos, improving component discoverability. - Added a new `DropdownMenuArrow` component to enhance dropdown visuals. - Implemented new CSS custom properties for charts, enhancing styling capabilities in light and dark themes. - Enhanced dropdown functionality by encapsulating dropdown logic in a new `UpcDropdownMenu` component. - Added a new `Select` component for improved user interaction within the `Sheet` component. - Introduced a new `SheetWithSelect` story to showcase selection functionality within the `Sheet` component. - Updated the `Sidebar` component to improve modal behavior and content positioning. - Enhanced `UserProfile` components with a new feedback function for better status indication. - **Style** - Refined layouts by replacing fixed widths with flexible, responsive designs. - Updated global styling with a refreshed chart color palette and expanded dark mode support. - **Refactor** - Migrated components to use a unified UI library, streamlining interactions and boosting consistency. - Improved type safety in `BrandLoading` component by utilizing a new type for variants and sizes. - Updated component imports and organization to enhance maintainability. - **Bug Fixes** - Removed unused promotional code and components, simplifying the codebase and improving performance. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: mdatelle <mike@datelle.net> Co-authored-by: Zack Spear <hi@zackspear.com> Co-authored-by: Eli Bosley <ekbosley@gmail.com>
This commit is contained in:
@@ -20,7 +20,6 @@ import type { ComposerTranslation } from 'vue-i18n';
|
||||
import { useAccountStore } from '~/store/account';
|
||||
import { useCallbackActionsStore } from '~/store/callbackActions';
|
||||
import { useInstallKeyStore } from '~/store/installKey';
|
||||
// import { usePromoStore } from '~/store/promo';
|
||||
import { useServerStore } from '~/store/server';
|
||||
import { useUpdateOsActionsStore } from '~/store/updateOsActions';
|
||||
|
||||
@@ -36,7 +35,6 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const accountStore = useAccountStore();
|
||||
const callbackActionsStore = useCallbackActionsStore();
|
||||
const installKeyStore = useInstallKeyStore();
|
||||
// const promoStore = usePromoStore();
|
||||
const serverStore = useServerStore();
|
||||
const updateOsActionStore = useUpdateOsActionsStore();
|
||||
|
||||
@@ -67,8 +65,6 @@ const {
|
||||
*/
|
||||
const isSettingsPage = ref<boolean>(document.location.pathname === '/Settings/ManagementAccess');
|
||||
|
||||
// const showPromoCta = computed(() => callbackStatus.value === 'success' && !connectPluginInstalled.value);
|
||||
|
||||
const heading = computed(() => {
|
||||
if (updateOsStatus.value === 'confirming') {
|
||||
return callbackTypeDowngrade.value
|
||||
@@ -143,11 +139,6 @@ const cancelUpdateOs = () => {
|
||||
callbackActionsStore.setCallbackStatus('ready');
|
||||
};
|
||||
|
||||
// const promoClick = () => {
|
||||
// promoStore.openOnNextLoad();
|
||||
// close();
|
||||
// };
|
||||
|
||||
const keyInstallStatusCopy = computed((): { text: string } => {
|
||||
let txt1 = props.t('Installing');
|
||||
let txt2 = props.t('Installed');
|
||||
@@ -334,12 +325,6 @@ const showUpdateEligibility = computed(() => {
|
||||
:error="accountActionStatus === 'failed'"
|
||||
:text="accountActionStatusCopy.text"
|
||||
/>
|
||||
|
||||
<!-- <UpcCallbackFeedbackStatus
|
||||
v-if="showPromoCta"
|
||||
:icon="InformationCircleIcon"
|
||||
:text="t('Enhance your experience with Unraid Connect')"
|
||||
/> -->
|
||||
</div>
|
||||
|
||||
<template v-if="updateOsStatus === 'confirming' && !stateDataError">
|
||||
@@ -388,12 +373,6 @@ const showUpdateEligibility = computed(() => {
|
||||
:text="t('Configure Connect Features')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- <BrandButton
|
||||
v-if="showPromoCta"
|
||||
:text="t('Learn More')"
|
||||
@click="promoClick"
|
||||
/> -->
|
||||
</template>
|
||||
|
||||
<template v-if="updateOsStatus === 'confirming' && !stateDataError">
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
import { TransitionRoot } from '@headlessui/vue';
|
||||
|
||||
import type { ComposerTranslation } from 'vue-i18n';
|
||||
|
||||
import { useDropdownStore } from '~/store/dropdown';
|
||||
import { useServerStore } from '~/store/server';
|
||||
|
||||
defineProps<{ t: ComposerTranslation }>();
|
||||
|
||||
const dropdownStore = useDropdownStore();
|
||||
|
||||
const { dropdownVisible } = storeToRefs(dropdownStore);
|
||||
const { state } = storeToRefs(useServerStore());
|
||||
|
||||
const showLaunchpad = computed(() => state.value === 'ENOKEYFILE');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TransitionRoot
|
||||
:show="dropdownVisible"
|
||||
enter="transition-all duration-200"
|
||||
enter-from="opacity-0 translate-y-[16px]"
|
||||
enter-to="opacity-100"
|
||||
leave="transition-all duration-150"
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0 translate-y-[16px]"
|
||||
>
|
||||
<UpcDropdownWrapper
|
||||
class="DropdownWrapper_blip text-foreground absolute z-30 top-full right-0 transition-all"
|
||||
>
|
||||
<UpcDropdownLaunchpad v-if="showLaunchpad" :t="t" />
|
||||
<UpcDropdownContent v-else :t="t" />
|
||||
</UpcDropdownWrapper>
|
||||
</TransitionRoot>
|
||||
</template>
|
||||
@@ -15,6 +15,7 @@ const { username } = storeToRefs(useServerStore());
|
||||
|
||||
const unraidApiStore = useUnraidApiStore();
|
||||
const { unraidApiStatus, unraidApiRestartAction } = storeToRefs(unraidApiStore);
|
||||
const brandLoading = () => h(BrandLoading, { size: 'custom' });
|
||||
|
||||
interface StatusOutput {
|
||||
icon: typeof BrandLoading | typeof ExclamationTriangleIcon | typeof CheckCircleIcon;
|
||||
@@ -25,16 +26,16 @@ interface StatusOutput {
|
||||
const status = computed((): StatusOutput | undefined => {
|
||||
if (unraidApiStatus.value === 'connecting') {
|
||||
return {
|
||||
icon: BrandLoading,
|
||||
iconClasses: 'w-16px',
|
||||
icon: brandLoading,
|
||||
iconClasses: 'w-4',
|
||||
text: props.t('Loading…'),
|
||||
textClasses: 'italic',
|
||||
};
|
||||
}
|
||||
if (unraidApiStatus.value === 'restarting') {
|
||||
return {
|
||||
icon: BrandLoading,
|
||||
iconClasses: 'w-16px',
|
||||
icon: brandLoading,
|
||||
iconClasses: 'w-4',
|
||||
text: props.t('Restarting unraid-api…'),
|
||||
textClasses: 'italic',
|
||||
};
|
||||
@@ -55,14 +56,16 @@ const status = computed((): StatusOutput | undefined => {
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const statusItemClasses = "text-14px flex flex-row justify-start items-center gap-8px mt-8px px-8px";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li v-if="username" class="flex flex-row justify-start items-center gap-8px mt-8px px-8px">
|
||||
<li v-if="username" :class="statusItemClasses">
|
||||
<UserCircleIcon class="w-16px h-16px" aria-hidden="true" />
|
||||
{{ username }}
|
||||
</li>
|
||||
<li v-if="status" class="flex flex-row justify-start items-center gap-8px mt-8px px-8px">
|
||||
<li v-if="status" :class="statusItemClasses">
|
||||
<component :is="status.icon" :class="status.iconClasses" aria-hidden="true" />
|
||||
{{ status.text }}
|
||||
</li>
|
||||
|
||||
@@ -189,7 +189,7 @@ const unraidConnectWelcome = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-y-8px min-w-300px max-w-350px">
|
||||
<div class="flex flex-col grow gap-y-8px">
|
||||
<header
|
||||
v-if="connectPluginInstalled"
|
||||
class="flex flex-col items-start justify-between mt-8px mx-8px"
|
||||
|
||||
@@ -22,7 +22,7 @@ const showExpireTime = computed(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-y-24px w-full min-w-300px md:min-w-[500px] max-w-xl p-16px">
|
||||
<div class="flex flex-col grow gap-y-24px p-16px">
|
||||
<header>
|
||||
<h2 class="text-24px text-center font-semibold" v-html="t(stateData.heading)" />
|
||||
<div
|
||||
|
||||
23
web/components/UserProfile/DropdownMenu.vue
Normal file
23
web/components/UserProfile/DropdownMenu.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { DropdownMenu, DropdownMenuArrow, DropdownMenuContent, DropdownMenuTrigger } from '@unraid/ui';
|
||||
|
||||
import type { ComposerTranslation } from 'vue-i18n';
|
||||
|
||||
defineProps<{ t: ComposerTranslation }>();
|
||||
|
||||
const open = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenu v-model:open="open">
|
||||
<DropdownMenuTrigger>
|
||||
<slot name="trigger" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent :side-offset="4" :align="'end'" :side="'bottom'" class="w-[350px]">
|
||||
<UpcDropdownContent :t="t" />
|
||||
<DropdownMenuArrow :rounded="true" class="fill-popover" :height="10" :width="16" />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
@@ -1,13 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
import {
|
||||
Bars3Icon,
|
||||
Bars3BottomRightIcon,
|
||||
BellAlertIcon,
|
||||
ExclamationTriangleIcon,
|
||||
InformationCircleIcon,
|
||||
ShieldExclamationIcon,
|
||||
} from '@heroicons/vue/24/solid';
|
||||
|
||||
import type { ComposerTranslation } from 'vue-i18n';
|
||||
|
||||
import { useDropdownStore } from '~/store/dropdown';
|
||||
@@ -15,7 +16,7 @@ import { useErrorsStore } from '~/store/errors';
|
||||
import { useServerStore } from '~/store/server';
|
||||
import { useUpdateOsStore } from '~/store/updateOs';
|
||||
|
||||
const props = defineProps<{ t: ComposerTranslation; }>();
|
||||
const props = defineProps<{ t: ComposerTranslation }>();
|
||||
|
||||
const dropdownStore = useDropdownStore();
|
||||
const { dropdownVisible } = storeToRefs(dropdownStore);
|
||||
@@ -26,14 +27,22 @@ const { available: osUpdateAvailable } = storeToRefs(useUpdateOsStore());
|
||||
const showErrorIcon = computed(() => errors.value.length || stateData.value.error);
|
||||
|
||||
const text = computed((): string => {
|
||||
if ((stateData.value.error) && state.value !== 'EEXPIRED') { return props.t('Fix Error'); }
|
||||
if (stateData.value.error && state.value !== 'EEXPIRED') {
|
||||
return props.t('Fix Error');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const title = computed((): string => {
|
||||
if (state.value === 'ENOKEYFILE') { return props.t('Get Started'); }
|
||||
if (state.value === 'EEXPIRED') { return props.t('Trial Expired, see options below'); }
|
||||
if (showErrorIcon.value) { return props.t('Learn more about the error'); }
|
||||
if (state.value === 'ENOKEYFILE') {
|
||||
return props.t('Get Started');
|
||||
}
|
||||
if (state.value === 'EEXPIRED') {
|
||||
return props.t('Trial Expired, see options below');
|
||||
}
|
||||
if (showErrorIcon.value) {
|
||||
return props.t('Learn more about the error');
|
||||
}
|
||||
return dropdownVisible.value ? props.t('Close Dropdown') : props.t('Open Dropdown');
|
||||
});
|
||||
</script>
|
||||
@@ -45,19 +54,32 @@ const title = computed((): string => {
|
||||
@click="dropdownStore.dropdownToggle()"
|
||||
>
|
||||
<template v-if="errors.length && errors[0].level">
|
||||
<InformationCircleIcon v-if="errors[0].level === 'info'" class="text-unraid-red fill-current relative w-24px h-24px" />
|
||||
<ExclamationTriangleIcon v-if="errors[0].level === 'warning'" class="text-unraid-red fill-current relative w-24px h-24px" />
|
||||
<ShieldExclamationIcon v-if="errors[0].level === 'error'" class="text-unraid-red fill-current relative w-24px h-24px" />
|
||||
<InformationCircleIcon
|
||||
v-if="errors[0].level === 'info'"
|
||||
class="text-unraid-red fill-current relative w-24px h-24px"
|
||||
/>
|
||||
<ExclamationTriangleIcon
|
||||
v-if="errors[0].level === 'warning'"
|
||||
class="text-unraid-red fill-current relative w-24px h-24px"
|
||||
/>
|
||||
<ShieldExclamationIcon
|
||||
v-if="errors[0].level === 'error'"
|
||||
class="text-unraid-red fill-current relative w-24px h-24px"
|
||||
/>
|
||||
</template>
|
||||
<span v-if="text" class="relative leading-none ">
|
||||
<span v-if="text" class="relative leading-none">
|
||||
<span>{{ text }}</span>
|
||||
<span class="absolute bottom-[-3px] inset-x-0 h-2px w-full bg-gradient-to-r from-unraid-red to-orange rounded opacity-0 group-hover:opacity-100 group-focus:opacity-100 transition-opacity" />
|
||||
<span
|
||||
class="absolute bottom-[-3px] inset-x-0 h-2px w-full bg-gradient-to-r from-unraid-red to-orange rounded opacity-0 group-hover:opacity-100 group-focus:opacity-100 transition-opacity"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<BellAlertIcon v-if="osUpdateAvailable && !rebootType" class="hover:animate-pulse fill-current relative w-16px h-16px" />
|
||||
<BellAlertIcon
|
||||
v-if="osUpdateAvailable && !rebootType"
|
||||
class="hover:animate-pulse fill-current relative w-16px h-16px"
|
||||
/>
|
||||
|
||||
<Bars3Icon v-if="!dropdownVisible" class="w-20px" />
|
||||
<Bars3BottomRightIcon v-else class="w-20px" />
|
||||
<Bars3Icon class="w-20px" />
|
||||
|
||||
<BrandAvatar />
|
||||
</button>
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* @todo future idea – turn this into a carousel. each feature could have a short video if we ever them
|
||||
*/
|
||||
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid';
|
||||
import { Switch, SwitchGroup, SwitchLabel } from '@headlessui/vue';
|
||||
import { CONNECT_DOCS } from '~/helpers/urls';
|
||||
|
||||
import type { UserProfilePromoFeature } from '~/types/userProfile';
|
||||
import type { ComposerTranslation } from 'vue-i18n';
|
||||
|
||||
import useInstallPlugin from '~/composables/installPlugin';
|
||||
import { usePromoStore } from '~/store/promo';
|
||||
|
||||
export interface Props {
|
||||
open?: boolean;
|
||||
t: ComposerTranslation;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
open: false,
|
||||
});
|
||||
|
||||
const promoStore = usePromoStore();
|
||||
|
||||
/**
|
||||
* These are translated in the component below. So if you add a new feature, make sure to add it to the translation file.
|
||||
*/
|
||||
const features = ref<UserProfilePromoFeature[]>([
|
||||
{
|
||||
title: 'Dynamic Remote Access',
|
||||
copy: 'Toggle on/off server accessibility with dynamic remote access. Automatically turn on UPnP and open a random WAN port on your router at the click of a button and close off access in seconds.',
|
||||
},
|
||||
{
|
||||
title: 'Manage Your Server Within Connect',
|
||||
copy: 'Servers equipped with a myunraid.net certificate can be managed directly from within the Connect web UI. Manage multiple servers from your phone, tablet, laptop, or PC in the same browser window.',
|
||||
},
|
||||
{
|
||||
title: 'Deep Linking',
|
||||
copy: 'The Connect dashboard links to relevant sections of the webgui, allowing quick access to those settings and server sections.',
|
||||
},
|
||||
{
|
||||
title: 'Online Flash Backup',
|
||||
copy: 'Never ever be left without a backup of your config. If you need to change flash drives, generate a backup from Connect and be up and running in minutes.',
|
||||
},
|
||||
{
|
||||
title: 'Real-time Monitoring',
|
||||
copy: "Get an overview of your server's state, storage space, apps and VMs status, and more.",
|
||||
},
|
||||
{
|
||||
title: 'Customizable Dashboard Tiles',
|
||||
copy: "Set custom server tiles how you like and automatically display your server's banner image on your Connect Dashboard.",
|
||||
},
|
||||
{
|
||||
title: 'License Management',
|
||||
copy: 'Manage your license keys at any time via the My Keys section.',
|
||||
},
|
||||
{
|
||||
title: 'Plus more on the way',
|
||||
copy: 'All you need is an active internet connection, an Unraid.net account, and the Connect plugin. Get started by installing the plugin.',
|
||||
},
|
||||
]);
|
||||
|
||||
const staging = ref(false);
|
||||
|
||||
const connectPluginUrl = computed((): string => {
|
||||
const url = new URL(
|
||||
`https://sfo2.digitaloceanspaces.com/unraid-dl/unraid-api/dynamix.unraid.net${staging.value ? '.staging.plg' : '.plg'}`
|
||||
);
|
||||
return url.toString();
|
||||
});
|
||||
|
||||
const { install } = useInstallPlugin();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:t="t"
|
||||
:title="t('Introducing Unraid Connect')"
|
||||
:description="t('Enhance your Unraid experience')"
|
||||
:open="open"
|
||||
:show-close-x="true"
|
||||
max-width="max-w-800px"
|
||||
@close="promoStore.promoHide()"
|
||||
>
|
||||
<template #headerTitle>
|
||||
<span><UpcBeta class="relative -top-1" /></span>
|
||||
</template>
|
||||
|
||||
<template #main>
|
||||
<div class="text-center relative w-full">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 justify-center p-16px md:py-24px gap-16px">
|
||||
<UpcPromoFeature
|
||||
v-for="(feature, index) in features"
|
||||
:key="index"
|
||||
:title="t(feature.title)"
|
||||
:copy="t(feature.copy)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="w-full max-w-xs flex flex-col items-center gap-y-16px mx-auto">
|
||||
<SwitchGroup v-if="import.meta.env.DEV" as="div" class="flex items-center justify-center">
|
||||
<Switch
|
||||
v-model="staging"
|
||||
:class="[
|
||||
staging ? 'bg-indigo-600' : 'bg-gray-200',
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
:class="[
|
||||
staging ? 't-x-5' : 't-x-0',
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
]"
|
||||
/>
|
||||
</Switch>
|
||||
<SwitchLabel as="span" class="ml-3 text-12px">
|
||||
<span class="font-semibold">Install Staging</span>
|
||||
</SwitchLabel>
|
||||
</SwitchGroup>
|
||||
<button
|
||||
class="text-white text-14px text-center w-full flex flex-row items-center justify-center gap-x-8px px-8px py-8px cursor-pointer rounded-md bg-gradient-to-r from-unraid-red to-orange hover:from-unraid-red/60 hover:to-orange/60 focus:from-unraid-red/60 focus:to-orange/60"
|
||||
@click="install({ pluginUrl: connectPluginUrl, modalTitle: t('Installing Connect') })"
|
||||
>
|
||||
{{ staging ? 'Install Connect Staging' : t('Install Connect') }}
|
||||
</button>
|
||||
<div>
|
||||
<a
|
||||
:href="CONNECT_DOCS.toString()"
|
||||
class="text-12px tracking-wide inline-flex flex-row items-center justify-start gap-8px mx-8px opacity-60 hover:opacity-100 focus:opacity-100 underline transition"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:title="t('Checkout the Connect Documentation')"
|
||||
>
|
||||
{{ t('Learn More') }}
|
||||
<ArrowTopRightOnSquareIcon class="w-16px" />
|
||||
</a>
|
||||
<button
|
||||
class="text-12px tracking-wide inline-block mx-8px opacity-60 hover:opacity-100 focus:opacity-100 underline transition"
|
||||
:title="t('Close')"
|
||||
@click="promoStore.promoHide()"
|
||||
>
|
||||
{{ t('No thanks') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="postcss">
|
||||
/* Import unraid-ui globals first */
|
||||
@import '@unraid/ui/styles';
|
||||
@import '~/assets/main.css';
|
||||
</style>
|
||||
@@ -1,39 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
export interface Props {
|
||||
center?: boolean;
|
||||
copy?: string;
|
||||
icon?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-left relative flex overflow-hidden">
|
||||
<span v-if="!center" class="flex-shrink-0">
|
||||
<slot />
|
||||
</span>
|
||||
<div class="inline-flex flex-col" :class="{ 'text-center': center }">
|
||||
<h3
|
||||
class="text-16px font-semibold"
|
||||
:class="{
|
||||
'mt-0 mb-4px': copy,
|
||||
'my-0': !copy,
|
||||
'flex flex-row justify-center items-center': center
|
||||
}"
|
||||
>
|
||||
<span v-if="center" class="flex-shrink-0 mr-8px">
|
||||
<slot />
|
||||
</span>
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p
|
||||
v-if="copy"
|
||||
class="text-14px opacity-75 py-0"
|
||||
:class="{'px-8px': center}"
|
||||
v-html="copy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user