feat: moderation additions for shared instances (#7285)

* feat: moderation additions for shared instances

* fix: lint

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-08-25 21:18:51 +00:00
committed by GitHub
co-authored by Prospector
parent 7701aef048
commit c28ec83060
7 changed files with 796 additions and 8 deletions
@@ -65,11 +65,17 @@ export interface SharedInstanceReportDetails {
other_instances_loaded?: boolean
}
const props = defineProps<{
details: SharedInstanceReportDetails
banPending?: boolean
loadVersionContent?: (instanceId: string, version: number) => Promise<ContentItem[]>
}>()
const props = withDefaults(
defineProps<{
details: SharedInstanceReportDetails
banPending?: boolean
loadVersionContent?: (instanceId: string, version: number) => Promise<ContentItem[]>
contextType?: 'report' | 'moderation'
}>(),
{
contextType: 'report',
},
)
const emit = defineEmits<{
banOwner: [owner: SharedInstanceReportUser]
@@ -223,7 +229,7 @@ function formattedLoader(version: SharedInstanceReportVersion) {
<span
class="border-blue/60 rounded-full border border-solid bg-highlight-blue px-2 py-0.5 text-xs font-semibold text-blue"
>
Report context
{{ contextType === 'report' ? 'Report context' : 'Moderation context' }}
</span>
</div>
@@ -310,7 +316,10 @@ function formattedLoader(version: SharedInstanceReportVersion) {
<div class="flex flex-wrap items-center gap-2">
<span class="font-semibold text-contrast">Version {{ version.version }}</span>
<span
v-if="version.version === details.reported_version?.version"
v-if="
contextType === 'report' &&
version.version === details.reported_version?.version
"
class="bg-orange-highlight rounded-full px-2 py-0.5 text-xs font-semibold text-orange"
>
Reported
@@ -333,7 +342,11 @@ function formattedLoader(version: SharedInstanceReportVersion) {
</div>
</div>
<span v-else class="text-sm text-secondary">
No version was attached to this report.
{{
contextType === 'report'
? 'No version was attached to this report.'
: 'No version history is available.'
}}
</span>
</div>
</div>
@@ -0,0 +1,170 @@
import type { AbstractModrinthClient, Labrinth, SharedInstances } from '@modrinth/api-client'
import type { ContentItem } from '@modrinth/ui'
type SharedInstanceVersionDependency = Labrinth.Versions.v2.Dependency & {
project_id?: string
version_id?: string
}
export function createSharedInstanceContentLoader(client: AbstractModrinthClient) {
const instanceVersions = new Map<string, SharedInstances.Instances.v1.InstanceVersion>()
function cacheVersion(instanceId: string, version: SharedInstances.Instances.v1.InstanceVersion) {
instanceVersions.set(`${instanceId}:${version.version}`, version)
}
async function getVersion(
instanceId: string,
versionNumber: number,
): Promise<SharedInstances.Instances.v1.InstanceVersion> {
const cacheKey = `${instanceId}:${versionNumber}`
const cachedVersion = instanceVersions.get(cacheKey)
if (cachedVersion) return cachedVersion
const version = await client.sharedinstances.instances_v1.getVersion(instanceId, versionNumber)
instanceVersions.set(cacheKey, version)
return version
}
async function loadVersionContent(
instanceId: string,
versionNumber: number,
): Promise<ContentItem[]> {
const instanceVersion = await getVersion(instanceId, versionNumber)
const modpackVersionId = instanceVersion.modpack_id
const directVersionIds = (instanceVersion.modrinth_ids ?? []).filter(
(versionId) => versionId !== modpackVersionId,
)
const modpackVersion = modpackVersionId
? await client.labrinth.versions_v2.getVersion(modpackVersionId)
: null
const modpackDependencies = (modpackVersion?.dependencies ??
[]) as SharedInstanceVersionDependency[]
const dependencyVersionIds = modpackDependencies.flatMap((dependency) =>
dependency.version_id ? [dependency.version_id] : [],
)
const uniqueVersionIds = [...new Set([...directVersionIds, ...dependencyVersionIds])]
const versions = uniqueVersionIds.length
? await client.labrinth.versions_v2.getVersions(uniqueVersionIds)
: []
const dependencyProjectIds = modpackDependencies.flatMap((dependency) =>
dependency.project_id ? [dependency.project_id] : [],
)
const projectIds = [
...new Set([
...versions.map((version) => version.project_id),
...dependencyProjectIds,
...(modpackVersion ? [modpackVersion.project_id] : []),
]),
]
const projects = projectIds.length
? await client.labrinth.projects_v2.getMultiple(projectIds)
: []
const versionsById = new Map(versions.map((version) => [version.id, version]))
const projectsById = new Map(projects.map((project) => [project.id, project]))
const modpackProject = modpackVersion ? projectsById.get(modpackVersion.project_id) : undefined
const directContent: ContentItem[] = [...new Set(directVersionIds)].flatMap((versionId) => {
const version = versionsById.get(versionId)
if (!version) return []
const project = projectsById.get(version.project_id)
return [sharedInstanceContentItem(version, project)]
})
const modpackContent: ContentItem[] = modpackDependencies.map((dependency) => {
const version = dependency.version_id ? versionsById.get(dependency.version_id) : undefined
const project = dependency.project_id
? projectsById.get(dependency.project_id)
: version
? projectsById.get(version.project_id)
: undefined
const primaryFile = version
? (version.files.find((file) => file.primary) ?? version.files[0])
: undefined
const fileName =
primaryFile?.filename ??
dependency.file_name ??
project?.title ??
version?.name ??
'Unknown'
const item = sharedInstanceContentItem(
version,
project,
fileName,
dependency.project_id ?? fileName,
!project && !version,
)
return modpackProject
? {
...item,
source: { project: modpackProject },
}
: item
})
const externalContent: ContentItem[] = instanceVersion.external_files.map((file, index) => ({
id: `external:${file.file_type}:${file.file_name}:${index}`,
file_name: file.file_name,
size: file.file_size,
project_type: file.file_type,
has_update: false,
update_version_id: null,
source_kind: 'shared_instance',
external: true,
external_url: file.url,
project: {
id: file.file_name,
slug: file.file_name,
title: file.file_name,
icon_url: undefined,
},
}))
return [...externalContent, ...modpackContent, ...directContent]
}
return { cacheVersion, getVersion, loadVersionContent }
}
function sharedInstanceContentItem(
version: Labrinth.Versions.v2.Version | undefined,
project: Labrinth.Projects.v2.Project | undefined,
fallbackFileName?: string,
fallbackProjectId = version?.project_id ?? fallbackFileName ?? 'unknown',
external = false,
): ContentItem {
const primaryFile = version
? (version.files.find((file) => file.primary) ?? version.files[0])
: undefined
const fileName =
primaryFile?.filename ?? fallbackFileName ?? project?.title ?? version?.name ?? 'Unknown'
return {
id: version?.id ?? project?.id ?? fileName,
file_name: fileName,
size: primaryFile?.size,
project_type: project?.project_type ?? 'mod',
has_update: false,
update_version_id: null,
source_kind: 'shared_instance',
external,
project: {
id: project?.id ?? fallbackProjectId,
slug: project?.slug ?? fallbackProjectId,
title: project?.title ?? version?.name ?? fileName,
icon_url: project?.icon_url ?? undefined,
},
...(version
? {
version: {
id: version.id,
version_number: version.version_number,
file_name: fileName,
date_published: version.date_published,
},
}
: {}),
}
}
@@ -0,0 +1,566 @@
<template>
<NewModal
ref="contextModal"
:header="
selectedInstance ? `${selectedInstance.name} moderation context` : 'Shared instance context'
"
no-padding
scrollable
max-width="90rem"
width="calc(100vw - 2rem)"
max-content-height="85vh"
>
<div
v-if="selectedContextPending"
class="flex min-h-64 items-center justify-center gap-2 text-secondary"
>
<LoaderCircleIcon class="size-6 animate-spin" />
Loading moderation context
</div>
<div v-else-if="selectedContextError" class="p-6">
<Admonition
type="critical"
header="Failed to load moderation context"
:body="selectedContextErrorMessage"
show-actions-underneath
>
<template #actions>
<Button :disabled="selectedContextFetching" @click="refetchSelectedContext()">
<LoaderCircleIcon v-if="selectedContextFetching" class="animate-spin" />
Try again
</Button>
</template>
</Admonition>
</div>
<SharedInstanceReportContext
v-else-if="selectedContext"
:key="selectedContext.id"
:details="selectedContext"
:ban-pending="banOwnerPending"
:load-version-content="sharedInstanceContent.loadVersionContent"
context-type="moderation"
class="!border-t-0"
@ban-owner="banSharedInstanceOwner"
@content-error="showSharedInstanceContentError"
/>
</NewModal>
<div class="normal-page no-sidebar !pb-6">
<div class="normal-page__content flex flex-col gap-4">
<header
class="flex flex-col gap-3 border-0 border-b border-solid border-divider pb-4 sm:flex-row sm:items-center sm:justify-between"
>
<div class="flex min-w-0 items-center gap-3">
<Avatar
:src="user?.avatar_url"
:alt="user?.username ?? userId"
:tint-by="user?.id ?? userId"
size="48px"
circle
/>
<div class="min-w-0">
<h1 class="m-0 truncate text-2xl font-extrabold text-contrast">
{{ user?.username ?? userId }}'s shared instances
</h1>
<p class="m-0 text-secondary">All shared instances this user owns or belongs to.</p>
</div>
</div>
<ButtonLink :to="`/user/${user?.id ?? userId}`" target="_blank" class="w-fit">
<UserIcon />
User profile
<ExternalIcon class="size-4" />
</ButtonLink>
</header>
<div
v-if="isPending"
class="flex min-h-48 items-center justify-center gap-2 rounded-2xl border border-solid border-surface-4 bg-surface-3 text-secondary"
>
<LoaderCircleIcon class="size-6 animate-spin" />
Loading shared instances…
</div>
<Admonition
v-else-if="error"
type="critical"
header="Failed to load shared instances"
:body="errorMessage"
show-actions-underneath
>
<template #actions>
<Button :disabled="isFetching" @click="retry">
<LoaderCircleIcon v-if="isFetching" class="animate-spin" />
Try again
</Button>
</template>
</Admonition>
<template v-else>
<Admonition
v-if="sharedInstances?.unavailableCount"
type="warning"
header="Some shared instances are unavailable"
:body="`${sharedInstances.unavailableCount} ${sharedInstances.unavailableCount === 1 ? 'instance could' : 'instances could'} not be loaded.`"
/>
<div
v-if="!sharedInstances?.instances.length"
class="flex min-h-48 flex-col items-center justify-center gap-2 rounded-2xl border border-solid border-surface-4 bg-surface-3 p-6 text-center"
>
<BoxesIcon class="size-10 text-secondary" />
<h2 class="m-0 text-lg font-semibold text-contrast">No shared instances</h2>
<p class="m-0 text-secondary">
This user does not own or belong to any shared instances.
</p>
</div>
<div v-else class="grid gap-3 md:grid-cols-2">
<article
v-for="instance in sharedInstances.instances"
:key="instance.id"
class="flex min-w-0 flex-col gap-4 rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4"
>
<div class="flex min-w-0 items-center gap-3">
<Avatar
:src="instance.iconUrl"
:alt="instance.name"
:tint-by="instance.id"
size="48px"
no-shadow
/>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-2">
<h2 class="m-0 truncate text-lg font-semibold text-contrast">
{{ instance.name }}
</h2>
<span
v-if="instance.quarantine"
class="bg-orange-highlight inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold text-orange"
>
<LockIcon class="size-3.5" />
Quarantined
</span>
</div>
<span class="text-sm text-secondary">{{ relationLabel(instance.membership) }}</span>
</div>
</div>
<div class="grid gap-2 text-sm text-primary sm:grid-cols-2">
<div class="flex min-w-0 items-center gap-2">
<VersionIcon class="size-5 shrink-0 text-secondary" />
<span class="truncate">
<template v-if="instance.latestVersion">
Version {{ instance.latestVersion.version }} ·
{{ formattedVersion(instance.latestVersion) }}
</template>
<template v-else>Version unavailable</template>
</span>
</div>
<div class="flex items-center gap-2">
<UsersIcon class="size-5 shrink-0 text-secondary" />
{{ instance.memberCount }}
{{ instance.memberCount === 1 ? 'member' : 'members' }}
</div>
<div class="flex items-center gap-2">
<HistoryIcon class="size-5 shrink-0 text-secondary" />
<span
v-if="instance.membership?.last_played"
v-tooltip="formatDateTime(instance.membership.last_played)"
>
Last played {{ formatRelativeTime(instance.membership.last_played) }}
</span>
<span v-else>Never played</span>
</div>
<div class="flex items-center gap-2">
<CalendarIcon class="size-5 shrink-0 text-secondary" />
<span
v-if="instance.membership?.joined_at"
v-tooltip="formatDateTime(instance.membership.joined_at)"
>
Joined {{ formatRelativeTime(instance.membership.joined_at) }}
</span>
<span v-else>Join date unavailable</span>
</div>
</div>
<div class="mt-auto flex flex-col gap-1.5">
<span class="text-xs font-semibold uppercase tracking-wide text-secondary">
Instance ID
</span>
<CopyCode :text="instance.id" />
</div>
<Button
type="colored"
color="orange"
class="w-full"
@click="openInstanceContext(instance)"
>
<ScaleIcon />
View context
</Button>
</article>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import type { SharedInstances } from '@modrinth/api-client'
import {
BoxesIcon,
CalendarIcon,
ExternalIcon,
HistoryIcon,
LoaderCircleIcon,
LockIcon,
ScaleIcon,
UserIcon,
UsersIcon,
VersionIcon,
} from '@modrinth/assets'
import {
Admonition,
Avatar,
Button,
ButtonLink,
CopyCode,
injectModrinthClient,
injectNotificationManager,
NewModal,
useFormatDateTime,
useRelativeTime,
} from '@modrinth/ui'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, onServerPrefetch, ref } from 'vue'
import SharedInstanceReportContext, {
type SharedInstanceOwnerInstance,
type SharedInstanceReportDetails,
type SharedInstanceReportUser,
} from '~/components/ui/moderation/SharedInstanceReportContext.vue'
import { createSharedInstanceContentLoader } from '~/helpers/shared-instance-content'
type InstanceMembership = SharedInstances.Instances.v1.InstanceUser
type InstanceVersion = SharedInstances.Instances.v1.InstanceVersion
type UserSharedInstance = {
id: string
name: string
iconUrl: string | null
quarantine: boolean
memberCount: number
membership?: InstanceMembership
latestVersion: InstanceVersion | null
}
const route = useRoute()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { addNotification } = injectNotificationManager()
const userId = computed(() => String(route.params.user ?? ''))
const canLoadUser = computed(() => userId.value.length > 0)
const selectedInstance = ref<UserSharedInstance | null>(null)
const contextModal = ref<InstanceType<typeof NewModal> | null>(null)
const sharedInstanceContent = createSharedInstanceContentLoader(client)
const formatRelativeTime = useRelativeTime({ style: 'narrow' })
const formatDateTime = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
const {
data: user,
error: userError,
isPending: userPending,
isFetching: userFetching,
refetch: refetchUser,
suspense: userSuspense,
} = useQuery({
queryKey: computed(() => ['user', 'v3', userId.value] as const),
queryFn: () => client.labrinth.users_v3.get(userId.value),
enabled: canLoadUser,
})
const {
data: sharedInstances,
error: sharedInstancesError,
isPending: sharedInstancesPending,
isFetching: sharedInstancesFetching,
refetch: refetchSharedInstances,
suspense: sharedInstancesSuspense,
} = useQuery({
queryKey: computed(() => ['shared-instances', 'user', userId.value] as const),
queryFn: loadSharedInstances,
enabled: canLoadUser,
})
const selectedInstanceId = computed(() => selectedInstance.value?.id ?? null)
const {
data: selectedContext,
error: selectedContextError,
isPending: selectedContextPending,
isFetching: selectedContextFetching,
refetch: refetchSelectedContext,
} = useQuery({
queryKey: computed(() => sharedInstanceContextQueryKey(selectedInstanceId.value)),
queryFn: () => loadSharedInstanceContext(selectedInstanceId.value),
enabled: computed(() => selectedInstanceId.value !== null),
staleTime: 30_000,
})
const banOwnerMutation = useMutation({
mutationFn: (owner: SharedInstanceReportUser) =>
client.sharedinstances.moderation_v1.blacklistUsers({ user_ids: [owner.id] }),
onSuccess: async (_data, owner) => {
const instanceId = selectedInstanceId.value
if (instanceId) {
queryClient.setQueryData<SharedInstanceReportDetails>(
sharedInstanceContextQueryKey(instanceId),
(details) =>
details
? {
...details,
quarantine: true,
other_instances: details.other_instances.map((instance) => ({
...instance,
quarantine: true,
})),
}
: details,
)
}
addNotification({
type: 'success',
title: 'Owner banned from shared instances',
text: `${owner.username} has been banned and all of their shared instances have been quarantined.`,
})
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['shared-instances', 'user', userId.value] }),
queryClient.invalidateQueries({
queryKey: ['shared-instance-blacklist', 'v1', owner.id],
}),
])
},
onError: (requestError, owner) => {
addNotification({
type: 'error',
title: 'Failed to ban shared instance owner',
text: getErrorMessage(requestError, `Could not ban ${owner.username} from shared instances.`),
})
},
})
const isPending = computed(() => userPending.value || sharedInstancesPending.value)
const isFetching = computed(() => userFetching.value || sharedInstancesFetching.value)
const error = computed(() => userError.value ?? sharedInstancesError.value)
const errorMessage = computed(() =>
getErrorMessage(error.value, 'The shared instances service could not be reached.'),
)
const selectedContextErrorMessage = computed(() =>
getErrorMessage(selectedContextError.value, 'The moderation context could not be loaded.'),
)
const banOwnerPending = computed(() => banOwnerMutation.isPending.value)
useHead({
title: computed(() => `${user.value?.username ?? userId.value}'s shared instances - Modrinth`),
})
onServerPrefetch(() => Promise.all([userSuspense(), sharedInstancesSuspense()]))
async function loadSharedInstances(): Promise<{
instances: UserSharedInstance[]
unavailableCount: number
}> {
const currentUserId = userId.value
const instanceIds = [
...new Set(await client.sharedinstances.instances_v1.getForUser(currentUserId)),
]
const results = await Promise.allSettled(
instanceIds.map(async (instanceId): Promise<UserSharedInstance> => {
const [instance, instanceUsers, latestVersion] = await Promise.all([
client.sharedinstances.instances_v1.get(instanceId),
client.sharedinstances.instances_v1.getUsers(instanceId),
client.sharedinstances.instances_v1.getLatestVersion(instanceId).catch(() => null),
])
return {
id: instanceId,
name: instance.name,
iconUrl: instance.icon,
quarantine: instance.quarantine,
memberCount: instanceUsers.users.length,
membership: instanceUsers.users.find((membership) => membership.id === currentUserId),
latestVersion,
}
}),
)
const instances = results
.flatMap((result) => (result.status === 'fulfilled' ? [result.value] : []))
.sort((first, second) => first.name.localeCompare(second.name))
const unavailableCount = results.length - instances.length
if (instanceIds.length > 0 && instances.length === 0) {
throw new Error("None of this user's shared instances could be loaded.")
}
return { instances, unavailableCount }
}
function sharedInstanceContextQueryKey(instanceId: string | null) {
return ['shared-instance', 'moderation-context', 'v1', instanceId] as const
}
function openInstanceContext(instance: UserSharedInstance) {
selectedInstance.value = instance
contextModal.value?.show()
}
async function loadSharedInstanceContext(
instanceId: string | null,
): Promise<SharedInstanceReportDetails> {
if (!instanceId) throw new Error('No shared instance was selected.')
const [instance, instanceUsers, latestVersion] = await Promise.all([
client.sharedinstances.instances_v1.get(instanceId),
client.sharedinstances.instances_v1.getUsers(instanceId),
client.sharedinstances.instances_v1.getLatestVersion(instanceId),
])
sharedInstanceContent.cacheVersion(instanceId, latestVersion)
const memberIds = [...new Set(instanceUsers.users.map((membership) => membership.id))]
const members = memberIds.length ? await client.labrinth.users_v2.getMultiple(memberIds) : []
const membersById = new Map(members.map((member) => [member.id, member]))
const ownerMembership = instanceUsers.users.find((membership) => membership.join_type === 'owner')
if (!ownerMembership) throw new Error('The shared instance has no owner.')
const toContextUser = (membership: InstanceMembership): SharedInstanceReportUser => {
const member = membersById.get(membership.id)
return {
id: membership.id,
username: member?.username ?? membership.id,
avatar_url: member?.avatar_url,
joined_at: membership.joined_at,
last_played: membership.last_played,
join_type: membership.join_type,
}
}
const versionNumbers = Array.from(
{ length: latestVersion.version + 1 },
(_value, index) => latestVersion.version - index,
)
const [versionDetails, otherInstancesResult] = await Promise.all([
Promise.all(
versionNumbers.map(async (versionNumber) => {
try {
const version = await sharedInstanceContent.getVersion(instanceId, versionNumber)
return {
version: version.version,
game_version: version.game_version,
loader: version.loader,
loader_version: version.loader_version,
}
} catch {
return { version: versionNumber }
}
}),
),
loadOtherSharedInstances(ownerMembership.id, instanceId)
.then((instances) => ({ instances, loaded: true }))
.catch(() => ({ instances: [], loaded: false })),
])
return {
id: instanceId,
name: instance.name,
icon_url: instance.icon,
quarantine: instance.quarantine,
owner: toContextUser(ownerMembership),
members: instanceUsers.users
.filter((membership) => membership.id !== ownerMembership.id)
.map(toContextUser),
reported_version: versionDetails[0],
previous_versions: versionDetails.slice(1),
other_instances: otherInstancesResult.instances,
other_instances_loaded: otherInstancesResult.loaded,
}
}
async function loadOtherSharedInstances(
ownerId: string,
selectedInstanceId: string,
): Promise<SharedInstanceOwnerInstance[]> {
const instanceIds = await client.sharedinstances.instances_v1.getForUser(ownerId)
const otherInstanceIds = [...new Set(instanceIds)].filter(
(instanceId) => instanceId !== selectedInstanceId,
)
const results = await Promise.allSettled(
otherInstanceIds.map(async (instanceId): Promise<SharedInstanceOwnerInstance> => {
const [instance, instanceUsers, latestVersion] = await Promise.all([
client.sharedinstances.instances_v1.get(instanceId),
client.sharedinstances.instances_v1.getUsers(instanceId),
client.sharedinstances.instances_v1.getLatestVersion(instanceId),
])
sharedInstanceContent.cacheVersion(instanceId, latestVersion)
return {
id: instanceId,
name: instance.name,
icon_url: instance.icon,
latest_version: latestVersion.version,
member_count: instanceUsers.users.length,
quarantine: instance.quarantine,
}
}),
)
return results
.flatMap((result) => (result.status === 'fulfilled' ? [result.value] : []))
.sort((first, second) => first.name.localeCompare(second.name))
}
function banSharedInstanceOwner(owner: SharedInstanceReportUser) {
banOwnerMutation.mutate(owner)
}
function showSharedInstanceContentError(requestError: unknown) {
addNotification({
type: 'error',
title: 'Failed to load version content',
text: getErrorMessage(
requestError,
'The content for this shared instance version could not be loaded.',
),
})
}
function relationLabel(membership?: InstanceMembership): string {
if (!membership) return 'Membership unavailable'
if (membership.join_type === 'owner') return 'Owner'
if (membership.join_type === 'link') return 'Joined via share link'
return 'Joined via direct invite'
}
function formattedVersion(version: InstanceVersion): string {
const loader = version.loader
? version.loader.charAt(0).toUpperCase() + version.loader.slice(1)
: 'Vanilla'
const formattedLoader = version.loader_version ? `${loader} ${version.loader_version}` : loader
return `Minecraft ${version.game_version} · ${formattedLoader}`
}
function getErrorMessage(requestError: unknown, fallback: string): string {
if (typeof requestError === 'string') return requestError
if (!requestError || typeof requestError !== 'object') return fallback
const typedError = requestError as {
message?: string
data?: { description?: string }
}
return typedError.data?.description ?? typedError.message ?? fallback
}
function retry() {
void Promise.all([refetchUser(), refetchSharedInstances()])
}
</script>
@@ -106,6 +106,18 @@
{{ formatMessage(messages.acceptInvite) }}
</Button>
<ButtonLink
v-if="isStaff && moderationUserId"
:to="`/admin/shared-instances/${moderationUserId}`"
target="_blank"
type="colored"
color="orange"
>
<ScaleIcon />
View shared instances
<ExternalIcon class="size-4" />
</ButtonLink>
<div class="flex w-full flex-col gap-2.5">
<span class="pl-3 font-medium text-primary">{{
formatMessage(messages.knowThisUser)
@@ -124,14 +136,17 @@
<script setup lang="ts">
import {
CircleAlertIcon,
ExternalIcon,
InviteBackgroundIllustration,
LoaderCircleIcon,
ScaleIcon,
UserPlusIcon,
} from '@modrinth/assets'
import {
Admonition,
Avatar,
Button,
ButtonLink,
defineMessages,
injectModrinthClient,
useVIntl,
@@ -189,6 +204,7 @@ const messages = defineMessages({
})
const route = useRoute()
const auth = await useAuth()
const client = injectModrinthClient()
const inviteId = computed(() => String(route.params.inviteId))
const openInAppModal = useTemplateRef('openInAppModal')
@@ -232,6 +248,11 @@ const inviterAvatar = computed(() => {
if (!inviter.value) return null
return inviter.value.type === 'user' ? inviter.value.avatar : inviter.value.icon
})
const moderationUserId = computed(() => (inviter.value?.type === 'user' ? inviter.value.id : null))
const isStaff = computed(() => {
const role = auth.value.user?.role
return role === 'admin' || role === 'moderator'
})
function acceptInvite() {
if (!invite.value) return
@@ -89,6 +89,7 @@ import {
AffiliateIcon,
BadgeCheckIcon,
BanIcon,
BoxesIcon,
BoxIcon,
CalendarIcon,
ChartIcon,
@@ -140,6 +141,10 @@ const messages = defineMessages({
id: 'profile.button.info',
defaultMessage: 'View user details',
},
sharedInstancesButton: {
id: 'profile.button.shared-instances',
defaultMessage: 'View shared instances',
},
officialAccount: {
id: 'profile.official-account',
defaultMessage: 'Official Modrinth account',
@@ -215,6 +220,7 @@ const emit = defineEmits<{
openBilling: []
toggleAffiliate: []
openInfo: []
openSharedInstances: []
openAnalytics: []
editUser: []
}>()
@@ -293,6 +299,14 @@ const moreActions = computed<OverflowMenuOption[]>(() => [
tone: 'orange',
shown: props.showStaffActions && props.isStaff,
},
{
id: 'open-shared-instances',
label: formatMessage(messages.sharedInstancesButton),
icon: BoxesIcon,
action: () => emit('openSharedInstances'),
tone: 'orange',
shown: props.showStaffActions && props.isStaff,
},
{
id: 'open-analytics',
label: formatMessage(messages.analyticsButton),
@@ -189,6 +189,7 @@
@open-billing="openPath(`/admin/billing/${user.id}`)"
@toggle-affiliate="toggleAffiliate"
@open-info="openUserDetails"
@open-shared-instances="openPath(`/admin/shared-instances/${user.id}`)"
@open-analytics="
openPath(`/dashboard/analytics?user=${encodeURIComponent(user.username)}`)
"
+3
View File
@@ -3113,6 +3113,9 @@
"profile.button.set-affiliate": {
"defaultMessage": "Set as affiliate"
},
"profile.button.shared-instances": {
"defaultMessage": "View shared instances"
},
"profile.button.unblock": {
"defaultMessage": "Unblock"
},