feat: throw 401 errors when a user doesn't have permissions (#5984)

* feat: throw 401 errors when a user doesn't have permissions

* remove pointless message

* prepr
This commit is contained in:
Prospector
2026-05-04 02:46:40 -07:00
committed by GitHub
parent b53887997c
commit 7d6f77bebf
9 changed files with 236 additions and 98 deletions

View File

@@ -7,7 +7,18 @@
<Logo404 /> <Logo404 />
</div> </div>
<div class="error-box" :class="{ 'has-bot': !is404 }"> <div class="error-box" :class="{ 'has-bot': !is404 }">
<img v-if="!is404" :src="SadRinthbot" alt="Sad Modrinth bot" class="error-box__sad-bot" /> <img
v-if="is401"
:src="AnnoyedRinthbot"
alt="Annoyed Modrinth bot"
class="error-box__sad-bot"
/>
<img
v-else-if="!is404"
:src="SadRinthbot"
alt="Sad Modrinth bot"
class="error-box__sad-bot"
/>
<div v-if="!is404" class="error-box__top-glow" /> <div v-if="!is404" class="error-box__top-glow" />
<div class="error-box__body"> <div class="error-box__body">
<h1 class="error-box__title">{{ formatMessage(errorMessages.title) }}</h1> <h1 class="error-box__title">{{ formatMessage(errorMessages.title) }}</h1>
@@ -15,6 +26,33 @@
{{ formatMessage(errorMessages.subtitle) }} {{ formatMessage(errorMessages.subtitle) }}
</p> </p>
</div> </div>
<div v-if="is401" class="flex flex-col gap-4">
<template v-if="auth.user">
<p class="m-0">
{{ formatMessage(unauthorizedMessages.signedInAsLabel) }}
</p>
<div
class="flex items-center gap-2 rounded-2xl border border-solid border-surface-5 bg-surface-4 p-4"
>
<Avatar :src="auth.user.avatar_url" size="32px" />
<span class="font-medium text-contrast">{{ auth.user.username }}</span>
<ButtonStyled color="red" type="transparent">
<button type="button" class="ml-auto" @click="logout">
{{ formatMessage(commonMessages.signOutButton) }}
</button>
</ButtonStyled>
</div>
</template>
<template v-else>
<ButtonStyled color="brand">
<nuxt-link class="button-like w-fit" :to="signInRoute">
<LogInIcon />
{{ formatMessage(commonMessages.signInButton) }}
</nuxt-link>
</ButtonStyled>
</template>
</div>
<div class="error-box__body"> <div class="error-box__body">
<p v-if="errorMessages.list_title" class="error-box__list-title"> <p v-if="errorMessages.list_title" class="error-box__list-title">
{{ formatMessage(errorMessages.list_title) }} {{ formatMessage(errorMessages.list_title) }}
@@ -51,9 +89,13 @@
</template> </template>
<script setup> <script setup>
import { SadRinthbot } from '@modrinth/assets' import { AnnoyedRinthbot, LogInIcon, SadRinthbot } from '@modrinth/assets'
import { import {
Avatar,
ButtonStyled,
commonMessages,
defineMessage, defineMessage,
defineMessages,
IntlFormatted, IntlFormatted,
LoadingBar, LoadingBar,
normalizeChildren, normalizeChildren,
@@ -65,6 +107,8 @@ import {
} from '@modrinth/ui' } from '@modrinth/ui'
import Logo404 from '~/assets/images/404.svg' import Logo404 from '~/assets/images/404.svg'
import { getSignInRouteObj } from '~/composables/auth.js'
import { logout } from '~/composables/user.js'
import { createModrinthClient } from './helpers/api.ts' import { createModrinthClient } from './helpers/api.ts'
import { FrontendNotificationManager } from './providers/frontend-notifications.ts' import { FrontendNotificationManager } from './providers/frontend-notifications.ts'
@@ -103,6 +147,17 @@ const props = defineProps({
}) })
const is404 = computed(() => props.error.statusCode === 404) const is404 = computed(() => props.error.statusCode === 404)
const is401 = computed(() => props.error.statusCode === 401)
const unauthorizedMessages = defineMessages({
signedInAsLabel: {
id: 'error.generic.401.signed-in-as',
defaultMessage: "You're currently signed in as:",
},
})
const signInRoute = computed(() => getSignInRouteObj(route))
const errorMessages = computed( const errorMessages = computed(
() => () =>
routeMessages.find((x) => x.match(route))?.messages[props.error.statusCode] ?? routeMessages.find((x) => x.match(route))?.messages[props.error.statusCode] ??
@@ -112,10 +167,6 @@ const errorMessages = computed(
const route = useRoute() const route = useRoute()
watch(route, () => {
console.log(route)
})
const messages = { const messages = {
404: { 404: {
title: defineMessage({ title: defineMessage({
@@ -138,6 +189,12 @@ const messages = {
'This page has been blocked for legal reasons, such as government censorship or ongoing legal proceedings.', 'This page has been blocked for legal reasons, such as government censorship or ongoing legal proceedings.',
}), }),
}, },
401: {
title: defineMessage({
id: 'error.generic.401.title',
defaultMessage: `You don't have access to this page`,
}),
},
default: { default: {
title: defineMessage({ title: defineMessage({
id: 'error.generic.default.title', id: 'error.generic.default.title',
@@ -345,7 +402,7 @@ const routeMessages = [
margin: 0; margin: 0;
} }
a { a:not(.button-like) {
color: var(--color-brand); color: var(--color-brand);
font-weight: 600; font-weight: 600;
@@ -387,20 +444,24 @@ const routeMessages = [
} }
&__title { &__title {
font-size: 2rem; font-size: 1.5rem;
font-weight: 900; font-weight: 600;
margin: 0; margin: 0;
} }
&__subtitle { &__subtitle {
font-size: 1.25rem; font-size: 1rem;
font-weight: 600; font-weight: 400;
} }
&__body { &__body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.75rem;
&:empty {
display: none;
}
} }
&__list-title { &__list-title {

View File

@@ -311,7 +311,7 @@
{ {
id: 'review-projects', id: 'review-projects',
color: 'orange', color: 'orange',
link: '/moderation/', link: '/moderation',
}, },
{ {
id: 'tech-review', id: 'tech-review',

View File

@@ -1244,6 +1244,12 @@
"error.collection.404.title": { "error.collection.404.title": {
"message": "Collection not found" "message": "Collection not found"
}, },
"error.generic.401.signed-in-as": {
"message": "You're currently signed in as:"
},
"error.generic.401.title": {
"message": "You don't have access to this page"
},
"error.generic.404.subtitle": { "error.generic.404.subtitle": {
"message": "The page you were looking for doesn't seem to exist." "message": "The page you were looking for doesn't seem to exist."
}, },

View File

@@ -0,0 +1,13 @@
import { isStaff } from '@modrinth/utils'
export default defineNuxtRouteMiddleware(async () => {
const auth = await useAuth()
if (!auth.value.user || !isStaff(auth.value.user)) {
throw createError({
fatal: true,
statusCode: 401,
statusMessage: 'Unauthorized',
})
}
})

View File

@@ -3,47 +3,49 @@
<Teleport v-if="flags.projectBackground" to="#fixed-background-teleport"> <Teleport v-if="flags.projectBackground" to="#fixed-background-teleport">
<ProjectBackgroundGradient :project="project" /> <ProjectBackgroundGradient :project="project" />
</Teleport> </Teleport>
<div v-if="route.name.startsWith('type-id-settings')" class="normal-page no-sidebar"> <template v-if="isSettings">
<div class="normal-page__header"> <div v-if="canAccessSettings" class="normal-page no-sidebar">
<div <div class="normal-page__header">
class="mb-4 flex flex-wrap items-center gap-x-2 gap-y-3 border-0 border-b-[1px] border-solid border-divider pb-4 text-lg font-semibold" <div
> class="mb-4 flex flex-wrap items-center gap-x-2 gap-y-3 border-0 border-b-[1px] border-solid border-divider pb-4 text-lg font-semibold"
<nuxt-link
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}`"
class="flex items-center gap-2 hover:underline hover:brightness-[--hover-brightness]"
> >
<Avatar :src="project.icon_url" size="32px" /> <nuxt-link
{{ project.title }} :to="`/${project.project_type}/${project.slug ? project.slug : project.id}`"
</nuxt-link> class="flex items-center gap-2 hover:underline hover:brightness-[--hover-brightness]"
<ChevronRightIcon /> >
<span class="flex grow font-extrabold text-contrast">{{ <Avatar :src="project.icon_url" size="32px" />
formatMessage(messages.settingsTitle) {{ project.title }}
}}</span> </nuxt-link>
<div class="flex gap-2"> <ChevronRightIcon />
<ButtonStyled> <span class="flex grow font-extrabold text-contrast">{{
<nuxt-link to="/dashboard/projects" formatMessage(messages.settingsTitle)
><ListIcon /> {{ formatMessage(messages.visitProjectsDashboard) }} }}</span>
</nuxt-link> <div class="flex gap-2">
</ButtonStyled> <ButtonStyled>
<nuxt-link to="/dashboard/projects"
><ListIcon /> {{ formatMessage(messages.visitProjectsDashboard) }}
</nuxt-link>
</ButtonStyled>
</div>
</div> </div>
<ProjectMemberHeader
v-if="currentMember && false"
:project="project"
:versions="versions"
:current-member="currentMember"
:is-settings="isSettings"
:set-processing="setProcessing"
:all-members="allMembers"
:update-members="invalidateProject"
:auth="auth"
:tags="tags"
/>
</div>
<div class="normal-page__content">
<NuxtPage />
</div> </div>
<ProjectMemberHeader
v-if="currentMember && false"
:project="project"
:versions="versions"
:current-member="currentMember"
:is-settings="route.name.startsWith('type-id-settings')"
:set-processing="setProcessing"
:all-members="allMembers"
:update-members="invalidateProject"
:auth="auth"
:tags="tags"
/>
</div> </div>
<div class="normal-page__content"> </template>
<NuxtPage />
</div>
</div>
<div v-else> <div v-else>
<NewModal <NewModal
@@ -811,7 +813,7 @@
:project="project" :project="project"
:versions="versions" :versions="versions"
:current-member="currentMember" :current-member="currentMember"
:is-settings="route.name.startsWith('type-id-settings')" :is-settings="isSettings"
:route-name="route.name" :route-name="route.name"
:set-processing="setProcessing" :set-processing="setProcessing"
:collapsed="collapsedChecklist" :collapsed="collapsedChecklist"
@@ -1826,6 +1828,8 @@ const { data: organizationRaw } = useQuery({
// Return null when the project no longer belongs to an organization. // Return null when the project no longer belongs to an organization.
const organization = computed(() => (projectRaw.value?.organization ? organizationRaw.value : null)) const organization = computed(() => (projectRaw.value?.organization ? organizationRaw.value : null))
const isSettings = computed(() => route.name.startsWith('type-id-settings'))
// Transform versionsV3 to be same shape as versionsV2 for compatibility in project pages // Transform versionsV3 to be same shape as versionsV2 for compatibility in project pages
const versionsRaw = computed(() => { const versionsRaw = computed(() => {
return (versionsV3.value ?? []).map((v) => { return (versionsV3.value ?? []).map((v) => {
@@ -2262,11 +2266,27 @@ const currentMember = computed(() => {
return val return val
}) })
const canAccessSettings = computed(() => !!currentMember.value?.accepted)
const hasEditDetailsPermission = computed(() => { const hasEditDetailsPermission = computed(() => {
const EDIT_DETAILS = 1 << 2 const EDIT_DETAILS = 1 << 2
return (currentMember.value?.permissions & EDIT_DETAILS) === EDIT_DETAILS return (currentMember.value?.permissions & EDIT_DETAILS) === EDIT_DETAILS
}) })
watch(
[isSettings, currentMember],
() => {
if (isSettings.value && !canAccessSettings.value) {
showError({
fatal: true,
statusCode: 401,
statusMessage: 'Unauthorized',
})
}
},
{ flush: 'sync', immediate: true },
)
const projectTypeDisplay = computed(() => { const projectTypeDisplay = computed(() => {
if (!project.value) return '' if (!project.value) return ''
return formatProjectType( return formatProjectType(

View File

@@ -1,5 +1,5 @@
<template> <template>
<div> <div v-if="canAccess">
<section class="universal-card"> <section class="universal-card">
<h2>Project status</h2> <h2>Project status</h2>
<Badge :type="project.status" /> <Badge :type="project.status" />
@@ -107,7 +107,7 @@ import {
injectProjectPageContext, injectProjectPageContext,
} from '@modrinth/ui' } from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query' import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed } from 'vue' import { computed, watch } from 'vue'
import ConversationThread from '~/components/ui/thread/ConversationThread.vue' import ConversationThread from '~/components/ui/thread/ConversationThread.vue'
import { import {
@@ -122,6 +122,22 @@ import {
const { addNotification } = injectNotificationManager() const { addNotification } = injectNotificationManager()
const { projectV2: project, currentMember, invalidate } = injectProjectPageContext() const { projectV2: project, currentMember, invalidate } = injectProjectPageContext()
const canAccess = computed(() => !!currentMember.value)
watch(
[currentMember, project],
() => {
if (project.value && !canAccess.value) {
showError({
fatal: true,
statusCode: 401,
statusMessage: 'Unauthorized',
})
}
},
{ flush: 'sync', immediate: true },
)
const auth = await useAuth() const auth = await useAuth()
const client = injectModrinthClient() const client = injectModrinthClient()
const queryClient = useQueryClient() const queryClient = useQueryClient()

View File

@@ -1,4 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
definePageMeta({
middleware: ['auth', 'staff'],
})
useSeoMeta({ useSeoMeta({
robots: 'noindex', robots: 'noindex',
}) })

View File

@@ -19,7 +19,7 @@ import { FolderIcon, ReportIcon, ShieldCheckIcon } from '@modrinth/assets'
import { Chips, defineMessages, NavTabs, useVIntl } from '@modrinth/ui' import { Chips, defineMessages, NavTabs, useVIntl } from '@modrinth/ui'
definePageMeta({ definePageMeta({
middleware: 'auth', middleware: ['auth', 'staff'],
}) })
useSeoMeta({ useSeoMeta({

View File

@@ -9,54 +9,56 @@
> >
<ModalCreation ref="modal_creation" :organization-id="organization.id" /> <ModalCreation ref="modal_creation" :organization-id="organization.id" />
<template v-if="routeHasSettings"> <template v-if="routeHasSettings">
<div class="normal-page__sidebar"> <template v-if="canAccessSettings">
<div <div class="normal-page__sidebar">
class="bg-surface mb-4 flex flex-col rounded-xl border border-solid border-surface-4 p-4" <div
> class="bg-surface mb-4 flex flex-col rounded-xl border border-solid border-surface-4 p-4"
<div class="flex items-center gap-4"> >
<Avatar size="sm" :src="organization.icon_url" /> <div class="flex items-center gap-4">
<div class="flex flex-col justify-center gap-1"> <Avatar size="sm" :src="organization.icon_url" />
<h2 class="m-0 text-base"> <div class="flex flex-col justify-center gap-1">
<nuxt-link :to="`/organization/${organization.slug}/settings`"> <h2 class="m-0 text-base">
{{ organization.name }} <nuxt-link :to="`/organization/${organization.slug}/settings`">
</nuxt-link> {{ organization.name }}
</h2> </nuxt-link>
<span> </h2>
{{ formatCompactNumber(acceptedMembers?.length || 0) }} <span>
member<template v-if="acceptedMembers?.length !== 1">s</template> {{ formatCompactNumber(acceptedMembers?.length || 0) }}
</span> member<template v-if="acceptedMembers?.length !== 1">s</template>
</span>
</div>
</div> </div>
</div> </div>
</div>
<NavStack <NavStack
:items="[ :items="[
{ {
link: `/organization/${organization.slug}/settings`, link: `/organization/${organization.slug}/settings`,
label: 'Overview', label: 'Overview',
icon: SettingsIcon, icon: SettingsIcon,
}, },
{ {
link: `/organization/${organization.slug}/settings/members`, link: `/organization/${organization.slug}/settings/members`,
label: 'Members', label: 'Members',
icon: UsersIcon, icon: UsersIcon,
}, },
{ {
link: `/organization/${organization.slug}/settings/projects`, link: `/organization/${organization.slug}/settings/projects`,
label: 'Projects', label: 'Projects',
icon: BoxIcon, icon: BoxIcon,
}, },
{ {
link: `/organization/${organization.slug}/settings/analytics`, link: `/organization/${organization.slug}/settings/analytics`,
label: 'Analytics', label: 'Analytics',
icon: ChartIcon, icon: ChartIcon,
}, },
]" ]"
/> />
</div> </div>
<div class="normal-page__content"> <div class="normal-page__content">
<NuxtPage /> <NuxtPage />
</div> </div>
</template>
</template> </template>
<template v-else> <template v-else>
<div class="normal-page__header py-4"> <div class="normal-page__header py-4">
@@ -527,6 +529,22 @@ const { currentMember } = organizationContext
provideOrganizationContext(organizationContext) provideOrganizationContext(organizationContext)
const canAccessSettings = computed(() => !!currentMember.value?.accepted)
watch(
[routeHasSettings, currentMember],
() => {
if (routeHasSettings.value && !canAccessSettings.value) {
showError({
fatal: true,
statusCode: 401,
statusMessage: 'Unauthorized',
})
}
},
{ flush: 'sync', immediate: true },
)
watch( watch(
organization, organization,
(org) => { (org) => {