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..e4122b141fe 100644 --- a/apps/frontend/src/pages/settings/pats.vue +++ b/apps/frontend/src/pages/settings/pats.vue @@ -42,17 +42,27 @@ :key="category.name" class="flex flex-col gap-1.5" > -

+

{{ category.name }}

- + v-tooltip="impliedStaffTooltip(scope)" + class="w-fit" + > + +
@@ -247,12 +257,12 @@

- {{ scope }} + {{ scope.text }}
@@ -292,11 +302,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 +441,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 +473,7 @@ const displayPats = computed(() => { }) const scopeCategories = computed(() => { - return [ + const categories = [ { name: formatMessage(scopeCategoryMessages.categoryUserAccount), scopes: scopeList.filter((s) => s.startsWith('USER_')), @@ -507,7 +524,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() { @@ -543,8 +580,38 @@ 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 scopesToDefinitions(BigInt(pat.scopes || 0)) + 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), + })) +} + +function scopeTextClass(accent) { + if (accent === 'moderation') { + return 'text-orange' + } + if (accent === 'admin') { + return 'text-red' + } + return undefined } async function createPat() {