From af6a0639badb2e2b14657ec45c34cad7e0496199 Mon Sep 17 00:00:00 2001
From: Prospector <6166773+Prospector@users.noreply.github.com>
Date: Tue, 10 Nov 2026 13:02:25 -0800
Subject: [PATCH 1/2] add staff scopes UI
---
apps/frontend/src/composables/auth/scopes.ts | 50 +++++++++++++++-
apps/frontend/src/pages/settings/pats.vue | 60 +++++++++++++++++---
2 files changed, 100 insertions(+), 10 deletions(-)
diff --git a/apps/frontend/src/composables/auth/scopes.ts b/apps/frontend/src/composables/auth/scopes.ts
index dc9747e540d..906f6028346 100644
--- a/apps/frontend/src/composables/auth/scopes.ts
+++ b/apps/frontend/src/composables/auth/scopes.ts
@@ -615,6 +615,30 @@ const scopeDefinitions = [
label: scopeMessages.sessionAccessLabel,
desc: scopeMessages.sessionAccessDescription,
},
+ {
+ id: 'MODERATION_READ',
+ value: BigInt(1) << BigInt(48),
+ label: 'Read moderation data',
+ desc: 'View content and data only visible to moderators',
+ },
+ {
+ id: 'MODERATION_WRITE',
+ value: BigInt(1) << BigInt(49),
+ label: 'Write moderation data',
+ desc: 'Perform moderation actions',
+ },
+ {
+ id: 'ADMIN_READ',
+ value: BigInt(1) << BigInt(50),
+ label: 'Read admin data',
+ desc: 'View content and data only visible to admins',
+ },
+ {
+ id: 'ADMIN_WRITE',
+ value: BigInt(1) << BigInt(51),
+ label: 'Write admin data',
+ desc: 'Perform admin actions',
+ },
]
const Scopes = scopeDefinitions.reduce(
@@ -638,10 +662,26 @@ export const restrictedScopes = [
Scopes.PERFORM_ANALYTICS,
]
+export const moderationScopeList = ['MODERATION_READ', 'MODERATION_WRITE']
+
+export const adminScopeList = ['ADMIN_READ', 'ADMIN_WRITE']
+
+const staffScopeIds = new Set([...moderationScopeList, ...adminScopeList])
+
export const scopeList = Object.entries(Scopes)
- .filter(([_, value]) => !restrictedScopes.includes(value))
+ .filter(([key, value]) => !restrictedScopes.includes(value) && !staffScopeIds.has(key))
.map(([key, _]) => key)
+export const scopeAccent = (scope: string) => {
+ if (moderationScopeList.includes(scope)) {
+ return 'moderation'
+ }
+ if (adminScopeList.includes(scope)) {
+ return 'admin'
+ }
+ return null
+}
+
export const getScopeValue = (scope: string) => {
return Scopes[scope]
}
@@ -707,7 +747,9 @@ export const useScopes = () => {
if (!scopeDefinition) {
throw new Error(`Scope ${scope} not found`)
}
- return formatMessage(scopeDefinition.desc)
+ return typeof scopeDefinition.desc === 'string'
+ ? scopeDefinition.desc
+ : formatMessage(scopeDefinition.desc)
})
}
@@ -720,7 +762,9 @@ export const useScopes = () => {
if (!scopeDefinition) {
throw new Error(`Scope ${scope} not found`)
}
- return formatMessage(scopeDefinition.label)
+ return typeof scopeDefinition.label === 'string'
+ ? scopeDefinition.label
+ : formatMessage(scopeDefinition.label)
})
}
diff --git a/apps/frontend/src/pages/settings/pats.vue b/apps/frontend/src/pages/settings/pats.vue
index 618c8599c1a..b75da69168b 100644
--- a/apps/frontend/src/pages/settings/pats.vue
+++ b/apps/frontend/src/pages/settings/pats.vue
@@ -42,7 +42,10 @@
:key="category.name"
class="flex flex-col gap-1.5"
>
-
+
{{ category.name }}
@@ -50,6 +53,8 @@
v-for="scope in category.scopes"
:key="scope"
:label="scopesToLabels(getScopeValue(scope)).join(', ')"
+ :label-class="category.labelClass"
+ :disabled="category.disabled"
:model-value="hasScope(scopesVal, scope)"
@update:model-value="scopesVal = toggleScope(scopesVal, scope)"
/>
@@ -247,12 +252,12 @@
- {{ scope }}
+ {{ scope.text }}
@@ -292,11 +297,17 @@ import {
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
+import { isAdmin, isStaff } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
+import { useAuth } from '~/composables/auth'
import {
+ adminScopeList,
+ decodeScopes,
getScopeValue,
hasScope,
+ moderationScopeList,
+ scopeAccent,
scopeCategoryMessages,
scopeList,
toggleScope,
@@ -425,6 +436,7 @@ useHead({
})
const data = useNuxtApp()
+const auth = await useAuth()
const { scopesToLabels, scopesToDefinitions } = useScopes()
const patModal = ref()
const minimumPatExpiry = data.$dayjs().add(1, 'day').format('YYYY-MM-DD')
@@ -456,7 +468,7 @@ const displayPats = computed(() => {
})
const scopeCategories = computed(() => {
- return [
+ const categories = [
{
name: formatMessage(scopeCategoryMessages.categoryUserAccount),
scopes: scopeList.filter((s) => s.startsWith('USER_')),
@@ -507,7 +519,27 @@ const scopeCategories = computed(() => {
(s) => s.startsWith('ANALYTICS') || s.startsWith('PERFORM_ANALYTICS'),
),
},
- ].filter((c) => c.scopes.length > 0)
+ ]
+
+ if (isStaff(auth.value.user)) {
+ categories.push(
+ {
+ name: 'Moderation',
+ scopes: moderationScopeList,
+ labelClass: 'text-orange',
+ headingClass: 'text-orange',
+ },
+ {
+ name: 'Admin',
+ scopes: adminScopeList,
+ labelClass: 'text-red',
+ headingClass: 'text-red',
+ disabled: !isAdmin(auth.value.user),
+ },
+ )
+ }
+
+ return categories.filter((c) => c.scopes.length > 0)
})
function openCreatePat() {
@@ -544,7 +576,21 @@ function isPatExpired(pat) {
}
function patScopes(pat) {
- return scopesToDefinitions(BigInt(pat.scopes || 0))
+ return decodeScopes(BigInt(pat.scopes || 0)).map((scope) => ({
+ id: scope,
+ text: scopesToDefinitions(getScopeValue(scope))[0],
+ accent: scopeAccent(scope),
+ }))
+}
+
+function scopeTextClass(accent) {
+ if (accent === 'moderation') {
+ return 'text-orange'
+ }
+ if (accent === 'admin') {
+ return 'text-red'
+ }
+ return undefined
}
async function createPat() {
From 76c2693b7dd55fe76c445d672108c84f2b529334 Mon Sep 17 00:00:00 2001
From: Prospector <6166773+Prospector@users.noreply.github.com>
Date: Wed, 11 Nov 2026 11:49:00 -0800
Subject: [PATCH 2/2] show included scopes
---
apps/frontend/src/pages/settings/pats.vue | 37 ++++++++++++++++++-----
1 file changed, 29 insertions(+), 8 deletions(-)
diff --git a/apps/frontend/src/pages/settings/pats.vue b/apps/frontend/src/pages/settings/pats.vue
index b75da69168b..e4122b141fe 100644
--- a/apps/frontend/src/pages/settings/pats.vue
+++ b/apps/frontend/src/pages/settings/pats.vue
@@ -49,15 +49,20 @@
{{ category.name }}
-
+ v-tooltip="impliedStaffTooltip(scope)"
+ class="w-fit"
+ >
+
+
@@ -575,8 +580,24 @@ function isPatExpired(pat) {
return new Date(pat.expires) <= new Date()
}
+function impliedStaffTooltip(scope) {
+ if (scope === 'MODERATION_READ' && hasScope(scopesVal.value, 'ADMIN_READ')) {
+ return 'Included with Admin read'
+ }
+ if (scope === 'MODERATION_WRITE' && hasScope(scopesVal.value, 'ADMIN_WRITE')) {
+ return 'Included with Admin write'
+ }
+}
+
function patScopes(pat) {
- return decodeScopes(BigInt(pat.scopes || 0)).map((scope) => ({
+ const selected = decodeScopes(BigInt(pat.scopes || 0))
+ if (selected.includes('ADMIN_READ') && !selected.includes('MODERATION_READ')) {
+ selected.push('MODERATION_READ')
+ }
+ if (selected.includes('ADMIN_WRITE') && !selected.includes('MODERATION_WRITE')) {
+ selected.push('MODERATION_WRITE')
+ }
+ return selected.map((scope) => ({
id: scope,
text: scopesToDefinitions(getScopeValue(scope))[0],
accent: scopeAccent(scope),