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),