feat: implement improved flow for server panel edit installation (#5711)
* feat: implement improved flow for server panel edit installation * feat: installation form finalized * feat: error state for InstallingBanner * feat: action button refactor + save banner text fix * fix: lint * fix: content card alignment * feat: better copy * fix: lint * fix: hide shift click + fix NeoForge chip * fix: lint
This commit is contained in:
@@ -138,7 +138,7 @@ onUnmounted(() => {
|
||||
class="@container flex flex-col gap-4 rounded-[20px] bg-bg-raised p-6 shadow-md"
|
||||
:class="{ 'opacity-50': disabled }"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-4">
|
||||
<AutoLink :to="projectLink" class="shrink-0">
|
||||
<Avatar :src="project.icon_url" :alt="project.title" size="5rem" no-shadow raised />
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<InlineBackupCreator
|
||||
ref="backupCreator"
|
||||
:backup-name="backupTip ? `Before bulk update (${backupTip})` : 'Before bulk update'"
|
||||
:shift-click-hint-override="formatMessage(messages.shiftClickHint)"
|
||||
@update:buttons-disabled="buttonsDisabled = $event"
|
||||
/>
|
||||
</div>
|
||||
@@ -68,6 +69,11 @@ const messages = defineMessages({
|
||||
id: 'content.confirm-bulk-update.update-button',
|
||||
defaultMessage: 'Update {count, plural, one {# project} other {# projects}}',
|
||||
},
|
||||
shiftClickHint: {
|
||||
id: 'content.confirm-bulk-update.shift-click-hint',
|
||||
defaultMessage:
|
||||
'Hold Shift while clicking "Update all" to skip this confirmation in the future.',
|
||||
},
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
/>
|
||||
</div>
|
||||
<span v-if="!props.hideShiftClickHint" class="text-secondary">
|
||||
{{ formatMessage(messages.shiftClickHint) }}
|
||||
{{ props.shiftClickHintOverride ?? formatMessage(messages.shiftClickHint) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -61,6 +61,7 @@ import { useInlineBackup } from '../../composables/use-inline-backup'
|
||||
const props = defineProps<{
|
||||
backupName: string
|
||||
hideShiftClickHint?: boolean
|
||||
shiftClickHintOverride?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
<InlineBackupCreator
|
||||
ref="backupCreator"
|
||||
backup-name="Before version change"
|
||||
hide-shift-click-hint
|
||||
@update:buttons-disabled="buttonsDisabled = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.header)" :closable="!loading" no-padding>
|
||||
<div class="flex max-w-[500px] flex-col gap-6 p-6">
|
||||
<Admonition
|
||||
:type="variant === 'loader-change' ? 'critical' : 'warning'"
|
||||
:header="
|
||||
variant === 'loader-change'
|
||||
? formatMessage(messages.loaderChangeTitle)
|
||||
: formatMessage(messages.gameVersionWarningTitle)
|
||||
"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<span>
|
||||
{{
|
||||
variant === 'loader-change'
|
||||
? formatMessage(messages.loaderChangeBody)
|
||||
: formatMessage(messages.gameVersionWarningBody)
|
||||
}}
|
||||
</span>
|
||||
<div v-if="variant === 'loader-change'">
|
||||
<ButtonStyled color="red">
|
||||
<button :disabled="loading" @click="handleResetServer">
|
||||
<TrashIcon class="size-5" />
|
||||
{{ formatMessage(commonMessages.resetServerButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</Admonition>
|
||||
|
||||
<InlineBackupCreator
|
||||
ref="backupCreator"
|
||||
:backup-name="
|
||||
variant === 'loader-change' ? 'Before loader change' : 'Before version change'
|
||||
"
|
||||
hide-shift-click-hint
|
||||
@update:buttons-disabled="buttonsDisabled = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled>
|
||||
<button :disabled="loading" @click="handleCancel">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<template v-if="variant === 'game-version-change'">
|
||||
<ButtonStyled>
|
||||
<button :disabled="buttonsDisabled || loading" @click="handleDisableConflicts">
|
||||
<SpinnerIcon
|
||||
v-if="loading && loadingAction === 'disable-conflicts'"
|
||||
class="size-5 animate-spin"
|
||||
/>
|
||||
<PowerOffIcon v-else class="size-5" />
|
||||
{{ formatMessage(messages.disableConflictsButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange">
|
||||
<button :disabled="buttonsDisabled || loading" @click="handleAutoFix">
|
||||
<SpinnerIcon
|
||||
v-if="loading && loadingAction === 'auto-fix'"
|
||||
class="size-5 animate-spin"
|
||||
/>
|
||||
<HammerIcon v-else class="size-5" />
|
||||
{{ formatMessage(messages.autoFixButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ButtonStyled color="red">
|
||||
<button :disabled="buttonsDisabled || loading" @click="handleConfirmLoaderChange">
|
||||
<SpinnerIcon v-if="loading" class="size-5 animate-spin" />
|
||||
<CircleAlertIcon v-else class="size-5" />
|
||||
{{ formatMessage(messages.changeLoaderButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CircleAlertIcon,
|
||||
HammerIcon,
|
||||
PowerOffIcon,
|
||||
SpinnerIcon,
|
||||
TrashIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import InlineBackupCreator from '../../content-tab/components/modals/InlineBackupCreator.vue'
|
||||
|
||||
defineProps<{
|
||||
variant: 'loader-change' | 'game-version-change'
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'confirm-loader-change': []
|
||||
'auto-fix': []
|
||||
'disable-conflicts': []
|
||||
'reset-server': []
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const buttonsDisabled = ref(false)
|
||||
const loadingAction = ref<'auto-fix' | 'disable-conflicts' | null>(null)
|
||||
|
||||
function show(e?: MouseEvent) {
|
||||
loadingAction.value = null
|
||||
modal.value?.show(e)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
hide()
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
function handleConfirmLoaderChange() {
|
||||
emit('confirm-loader-change')
|
||||
}
|
||||
|
||||
function handleAutoFix() {
|
||||
loadingAction.value = 'auto-fix'
|
||||
emit('auto-fix')
|
||||
}
|
||||
|
||||
function handleDisableConflicts() {
|
||||
loadingAction.value = 'disable-conflicts'
|
||||
emit('disable-conflicts')
|
||||
}
|
||||
|
||||
function handleResetServer() {
|
||||
hide()
|
||||
emit('reset-server')
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'installation-settings.incompatible-content.header',
|
||||
defaultMessage: 'Incompatible content installed',
|
||||
},
|
||||
loaderChangeTitle: {
|
||||
id: 'installation-settings.incompatible-content.loader-change-title',
|
||||
defaultMessage: 'Changing loaders is destructive',
|
||||
},
|
||||
loaderChangeBody: {
|
||||
id: 'installation-settings.incompatible-content.loader-change-body',
|
||||
defaultMessage:
|
||||
'When changing the loader, all installed content will be disabled. We recommend resetting your server instead.',
|
||||
},
|
||||
gameVersionWarningTitle: {
|
||||
id: 'installation-settings.incompatible-content.game-version-warning-title',
|
||||
defaultMessage: 'Incompatibility warning',
|
||||
},
|
||||
gameVersionWarningBody: {
|
||||
id: 'installation-settings.incompatible-content.game-version-warning-body',
|
||||
defaultMessage:
|
||||
'When changing the game version, we can either disable incompatible installed content or attempt to resolve the incompatibilities.',
|
||||
},
|
||||
changeLoaderButton: {
|
||||
id: 'installation-settings.incompatible-content.change-loader-button',
|
||||
defaultMessage: 'Change loader',
|
||||
},
|
||||
autoFixButton: {
|
||||
id: 'installation-settings.incompatible-content.auto-fix-button',
|
||||
defaultMessage: 'Auto-fix',
|
||||
},
|
||||
disableConflictsButton: {
|
||||
id: 'installation-settings.incompatible-content.disable-conflicts-button',
|
||||
defaultMessage: 'Disable conflicts',
|
||||
},
|
||||
})
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -1 +1 @@
|
||||
export { useInstallationForm } from './use-installation-form'
|
||||
export { type IncompatibleContentVariant, useInstallationForm } from './use-installation-form'
|
||||
|
||||
@@ -6,13 +6,19 @@ import { formatLoaderLabel } from '#ui/utils/loaders'
|
||||
|
||||
import type { ContentUpdaterModal } from '../../content-tab'
|
||||
import type ContentDiffModal from '../components/ContentDiffModal.vue'
|
||||
import type IncompatibleContentModal from '../components/IncompatibleContentModal.vue'
|
||||
import type { InstallationSettingsContext } from '../providers/installation-settings'
|
||||
import type { ContentDiffPreview } from '../types'
|
||||
|
||||
export type IncompatibleContentVariant = 'loader-change' | 'game-version-change'
|
||||
|
||||
export function useInstallationForm(
|
||||
ctx: InstallationSettingsContext,
|
||||
updaterModalRef: Ref<InstanceType<typeof ContentUpdaterModal> | null | undefined>,
|
||||
contentDiffModalRef?: Ref<InstanceType<typeof ContentDiffModal> | null | undefined>,
|
||||
incompatibleContentModalRef?: Ref<
|
||||
InstanceType<typeof IncompatibleContentModal> | null | undefined
|
||||
>,
|
||||
) {
|
||||
const isEditing = ref(false)
|
||||
const selectedPlatform = ctx.editingPlatformRef ?? ref(ctx.currentPlatform.value)
|
||||
@@ -22,6 +28,7 @@ export function useInstallationForm(
|
||||
const isSaving = ref(false)
|
||||
const isVerifying = ref(false)
|
||||
const pendingPreview = ref<ContentDiffPreview | null>(null)
|
||||
const incompatibleContentVariant = ref<IncompatibleContentVariant | null>(null)
|
||||
let abortController: AbortController | null = null
|
||||
|
||||
const gameVersionOptions = computed(() =>
|
||||
@@ -75,9 +82,26 @@ export function useInstallationForm(
|
||||
async function save() {
|
||||
isSaving.value = true
|
||||
try {
|
||||
const platformChanged = selectedPlatform.value !== ctx.currentPlatform.value
|
||||
const isModded = ctx.currentPlatform.value !== 'vanilla'
|
||||
const gameVersionChanged = selectedGameVersion.value !== ctx.currentGameVersion.value
|
||||
|
||||
if (platformChanged && ctx.disableAllContent) {
|
||||
isSaving.value = false
|
||||
incompatibleContentVariant.value = 'loader-change'
|
||||
await nextTick()
|
||||
incompatibleContentModalRef?.value?.show()
|
||||
return
|
||||
}
|
||||
|
||||
if (isModded && gameVersionChanged && ctx.disableIncompatibleContent) {
|
||||
isSaving.value = false
|
||||
incompatibleContentVariant.value = 'game-version-change'
|
||||
await nextTick()
|
||||
incompatibleContentModalRef?.value?.show()
|
||||
return
|
||||
}
|
||||
|
||||
if (ctx.previewSave && isModded && gameVersionChanged) {
|
||||
isVerifying.value = true
|
||||
abortController = new AbortController()
|
||||
@@ -127,6 +151,111 @@ export function useInstallationForm(
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmLoaderChange() {
|
||||
try {
|
||||
if (ctx.disableAllContent) {
|
||||
await ctx.disableAllContent()
|
||||
}
|
||||
incompatibleContentVariant.value = null
|
||||
await performSave()
|
||||
} catch {
|
||||
incompatibleContentVariant.value = null
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmAutoFix() {
|
||||
try {
|
||||
if (ctx.previewSave) {
|
||||
isVerifying.value = true
|
||||
abortController = new AbortController()
|
||||
const loaderVersionId =
|
||||
selectedPlatform.value !== 'vanilla'
|
||||
? (loaderVersionEntries.value[selectedLoaderVersion.value]?.id ?? null)
|
||||
: null
|
||||
|
||||
let preview: ContentDiffPreview | null
|
||||
try {
|
||||
preview = await ctx.previewSave(
|
||||
selectedPlatform.value,
|
||||
selectedGameVersion.value,
|
||||
loaderVersionId,
|
||||
abortController.signal,
|
||||
)
|
||||
} finally {
|
||||
isVerifying.value = false
|
||||
abortController = null
|
||||
}
|
||||
|
||||
if (preview && (preview.diffs.length > 0 || preview.hasUnknownContent)) {
|
||||
pendingPreview.value = preview
|
||||
incompatibleContentVariant.value = null
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
contentDiffModalRef?.value?.show()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
incompatibleContentVariant.value = null
|
||||
await performSave()
|
||||
} catch {
|
||||
incompatibleContentVariant.value = null
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDisableConflicts() {
|
||||
try {
|
||||
if (ctx.disableIncompatibleContent && ctx.previewSave) {
|
||||
isVerifying.value = true
|
||||
abortController = new AbortController()
|
||||
const loaderVersionId =
|
||||
selectedPlatform.value !== 'vanilla'
|
||||
? (loaderVersionEntries.value[selectedLoaderVersion.value]?.id ?? null)
|
||||
: null
|
||||
|
||||
let preview: ContentDiffPreview | null
|
||||
try {
|
||||
preview = await ctx.previewSave(
|
||||
selectedPlatform.value,
|
||||
selectedGameVersion.value,
|
||||
loaderVersionId,
|
||||
abortController.signal,
|
||||
)
|
||||
} finally {
|
||||
isVerifying.value = false
|
||||
abortController = null
|
||||
}
|
||||
|
||||
if (preview) {
|
||||
await ctx.disableIncompatibleContent(preview.diffs)
|
||||
}
|
||||
}
|
||||
|
||||
incompatibleContentVariant.value = null
|
||||
if (ctx.saveWithoutAutoFix) {
|
||||
const loaderVersionId =
|
||||
selectedPlatform.value !== 'vanilla'
|
||||
? (loaderVersionEntries.value[selectedLoaderVersion.value]?.id ?? null)
|
||||
: null
|
||||
await ctx.saveWithoutAutoFix(
|
||||
selectedPlatform.value,
|
||||
selectedGameVersion.value,
|
||||
loaderVersionId,
|
||||
)
|
||||
if (ctx.afterSave) await ctx.afterSave()
|
||||
isEditing.value = false
|
||||
isSaving.value = false
|
||||
} else {
|
||||
await performSave()
|
||||
}
|
||||
} catch {
|
||||
incompatibleContentVariant.value = null
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmSave() {
|
||||
pendingPreview.value = null
|
||||
try {
|
||||
@@ -138,6 +267,7 @@ export function useInstallationForm(
|
||||
|
||||
function cancelPreview() {
|
||||
pendingPreview.value = null
|
||||
incompatibleContentVariant.value = null
|
||||
isSaving.value = false
|
||||
}
|
||||
|
||||
@@ -262,7 +392,11 @@ export function useInstallationForm(
|
||||
hasChanges,
|
||||
save,
|
||||
pendingPreview,
|
||||
incompatibleContentVariant,
|
||||
confirmSave,
|
||||
confirmLoaderChange,
|
||||
confirmAutoFix,
|
||||
confirmDisableConflicts,
|
||||
cancelPreview,
|
||||
cancelEditing,
|
||||
updatingModpack,
|
||||
|
||||
@@ -24,6 +24,7 @@ import Combobox from '#ui/components/base/Combobox.vue'
|
||||
import ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
import { formatLoaderLabel } from '#ui/utils/loaders'
|
||||
|
||||
import ConfirmModpackUpdateModal from '../content-tab/components/modals/ConfirmModpackUpdateModal.vue'
|
||||
import ConfirmReinstallModal from '../content-tab/components/modals/ConfirmReinstallModal.vue'
|
||||
@@ -31,6 +32,7 @@ import ConfirmRepairModal from '../content-tab/components/modals/ConfirmRepairMo
|
||||
import ConfirmUnlinkModal from '../content-tab/components/modals/ConfirmUnlinkModal.vue'
|
||||
import ContentUpdaterModal from '../content-tab/components/modals/ContentUpdaterModal.vue'
|
||||
import ContentDiffModal from './components/ContentDiffModal.vue'
|
||||
import IncompatibleContentModal from './components/IncompatibleContentModal.vue'
|
||||
import { useInstallationForm } from './composables'
|
||||
import { injectInstallationSettings } from './providers/installation-settings'
|
||||
|
||||
@@ -44,11 +46,17 @@ const unlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
const contentUpdaterModal = ref<InstanceType<typeof ContentUpdaterModal> | null>()
|
||||
|
||||
const contentDiffModal = ref<InstanceType<typeof ContentDiffModal>>()
|
||||
const incompatibleContentModal = ref<InstanceType<typeof IncompatibleContentModal>>()
|
||||
const modpackUpdateModal = ref<InstanceType<typeof ConfirmModpackUpdateModal>>()
|
||||
const pendingUpdateVersion = ref<Labrinth.Versions.v2.Version | null>(null)
|
||||
const isUpdateDowngrade = ref(false)
|
||||
|
||||
const form = useInstallationForm(ctx, contentUpdaterModal, contentDiffModal)
|
||||
const form = useInstallationForm(
|
||||
ctx,
|
||||
contentUpdaterModal,
|
||||
contentDiffModal,
|
||||
incompatibleContentModal,
|
||||
)
|
||||
|
||||
function handleBeforeUnload(e: BeforeUnloadEvent) {
|
||||
if (form.isSaving.value) {
|
||||
@@ -133,6 +141,16 @@ function handleUnlink() {
|
||||
ctx.unlinkModpack()
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
'reset-server': []
|
||||
}>()
|
||||
|
||||
function handleIncompatibleResetServer() {
|
||||
form.cancelPreview()
|
||||
form.cancelEditing()
|
||||
emit('reset-server')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
cancelEditing: () => form.cancelEditing(),
|
||||
})
|
||||
@@ -477,6 +495,8 @@ const messages = defineMessages({
|
||||
<Chips
|
||||
v-model="form.selectedPlatform.value"
|
||||
:items="ctx.availablePlatforms"
|
||||
:format-label="formatLoaderLabel"
|
||||
:capitalize="false"
|
||||
:disabled-items="disabledPlatforms"
|
||||
:disabled-tooltip="formatMessage(messages.platformLockTooltip)"
|
||||
:aria-label="formatMessage(messages.selectPlatformAriaLabel)"
|
||||
@@ -708,8 +728,20 @@ const messages = defineMessages({
|
||||
@unlink="handleUnlink"
|
||||
/>
|
||||
|
||||
<IncompatibleContentModal
|
||||
v-if="form.incompatibleContentVariant.value"
|
||||
ref="incompatibleContentModal"
|
||||
:variant="form.incompatibleContentVariant.value"
|
||||
:loading="form.isVerifying.value || form.isSaving.value"
|
||||
@confirm-loader-change="form.confirmLoaderChange()"
|
||||
@auto-fix="form.confirmAutoFix()"
|
||||
@disable-conflicts="form.confirmDisableConflicts()"
|
||||
@reset-server="handleIncompatibleResetServer"
|
||||
@cancel="form.cancelPreview()"
|
||||
/>
|
||||
|
||||
<ContentDiffModal
|
||||
v-if="form.pendingPreview.value"
|
||||
v-if="form.pendingPreview.value && !form.incompatibleContentVariant.value"
|
||||
ref="contentDiffModal"
|
||||
:header="formatMessage(messages.confirmVersionChangeHeader)"
|
||||
:description="
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ComputedRef, Ref } from 'vue'
|
||||
import { createContext } from '#ui/providers/create-context'
|
||||
|
||||
import type {
|
||||
ContentDiffItem,
|
||||
ContentDiffPreview,
|
||||
GameVersionOption,
|
||||
InstallationInfoRow,
|
||||
@@ -61,6 +62,26 @@ export interface InstallationSettingsContext {
|
||||
|
||||
lockPlatform?: boolean
|
||||
hideLoaderVersion?: boolean
|
||||
|
||||
/** Bulk-disable all addons on the server (used before switching loaders). */
|
||||
disableAllContent?: () => Promise<void>
|
||||
|
||||
/**
|
||||
* Disable only the incompatible addons identified in a content diff preview.
|
||||
* Used when the user chooses "Disable conflicts" instead of "Auto-fix".
|
||||
*/
|
||||
disableIncompatibleContent?: (diffs: ContentDiffItem[]) => Promise<void>
|
||||
|
||||
/**
|
||||
* Save the installation settings without auto-resolving content.
|
||||
* Uses installContent with soft_override instead of applyGameVersionUpdate.
|
||||
*/
|
||||
saveWithoutAutoFix?: (
|
||||
platform: string,
|
||||
gameVersion: string,
|
||||
loaderVersionId: string | null,
|
||||
) => Promise<void>
|
||||
|
||||
previewSave?: (
|
||||
platform: string,
|
||||
gameVersion: string,
|
||||
|
||||
Reference in New Issue
Block a user