feat: new modpack permissions system (#6005)

* Begin external projects moderator database frontend

* add copy link button

* begin project page permissions settings

* MEL database backend routes

* include filename in external files

* wip: when uploading a version file, fetch its overrides as a list

* wip: override license checks

* improve FileHost ref counting

* file host read capability

* scan files when inserting version file

* add dependency sha1 field

* clean up version files

* wip: attributions

* update s3 file host

* attribution scanning basic works

* works

* insert attribution info after resolving

* add routes

* remove dep sha1 stuff

* prepr

* wip: override file sources

* add files_missing_attributions to versions

* return extended version info + attributed at/by

* hook up frontend to backend (mostly)

* expose version date published

* withholding version visibility

* frontend work

* prepr

* use api-client for img upload

* moar frontend

* prepr

* Add schema to attribution resolution and Flame project results

* sqlx prepare

* changes

* remove feature flag, fix optional proof images

* fix schema

* fmt

* fix deletion and file fetch

* prepare

* fix admonition

* update frontend stuff to new schema

* prepr

* attribution on dependencies

* fixes

* sqlx prepare

* fixes

* routes

* fix routes

* Version grandfathering

* prepare

* wip: bulk routes

* pushing what i've got rn

* include link in NoPermission

* change hash insert to bulk route

* query flame even if entry in MEL

* delete file with weird name

* Prioritise putting override files in existing groups even with ExternalLicense

* fix how hex bytes are handled in route

* feat: coolbot moderation changes (#6215)

* Update moderator checklist

* move permissions stage order

* Updated nagContext.versions to v3, added nag for permissions

* Update permissions.vue default messages

* prepr

---------

Co-authored-by: coolbot100s <76798835+coolbot100s@users.noreply.github.com>

* QA

* prepr

* should group by project

* return attribution resolution correctly

* updated by moderator info

* Track what moderator reviewed an attribution moderation status

* default deser FMA field

* new version page

* clean up fetching + add a couple missing features

* qa items

* prepr

* provide moderation package stuff with DI

* format?

* don't redact moderated_at

* move supplementary resources

* Reorganize moderation messages.

* Quick replies for external content permissions.

* prepare

* QA

* allow exempting projects

* Ignore Flame projects which 404

* fix ci

* fix cross project attribution stuff

* Fix permission error

* change what files get cscanned

* add more logging

* QA Jun 22

* fix

* idempotency

* Expose route for rescanning

* update blog link

---------

Co-authored-by: aecsocket <aecsocket@tutanota.com>
Co-authored-by: coolbot100s <76798835+coolbot100s@users.noreply.github.com>
Co-authored-by: aecsocket <43144841+aecsocket@users.noreply.github.com>
This commit is contained in:
Prospector
2026-06-23 21:27:51 +02:00
committed by GitHub
co-authored by coolbot100s aecsocket aecsocket
parent a686a93858
commit e7926083fb
315 changed files with 11106 additions and 2209 deletions
+3 -1
View File
@@ -881,7 +881,9 @@
class="card flex-card"
/>
<ProjectSidebarCompatibility
v-if="projectV3Loaded && !isServerProject"
v-if="
projectV3Loaded && !isServerProject && route.name !== 'type-project-version-version'
"
:project="project"
:tags="tags"
:project-v3="projectV3"
@@ -72,11 +72,9 @@
<h2 id="messages" class="m-0 text-xl font-semibold text-contrast">
{{ formatMessage(messages.threadSectionTitle) }}
</h2>
<div v-if="currentMember?.staffOnly" class="flex items-center gap-2">
<div v-if="isStaff(currentMember?.user)" class="flex items-center gap-2">
<Toggle id="moderator-see-user-ui-toggle" v-model="moderatorSeeUserUi" small />
<label for="moderator-see-user-ui-toggle">
{{ formatMessage(messages.moderatorSeeUserUiToggle) }}
</label>
<label for="moderator-see-user-ui-toggle"> Show member UI </label>
</div>
</div>
<template v-if="userFacingUiVisible">
@@ -151,8 +149,9 @@ import {
Toggle,
useVIntl,
} from '@modrinth/ui'
import { isStaff } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, type Ref, watch } from 'vue'
import { computed, watch } from 'vue'
import ConversationThread from '~/components/ui/thread/ConversationThread.vue'
import { getProjectLink, isApproved, isRejected, isUnderReview } from '~/helpers/projects.js'
@@ -160,7 +159,6 @@ import { getProjectLink, isApproved, isRejected, isUnderReview } from '~/helpers
const { formatMessage } = useVIntl()
const flags = useFeatureFlags()
type ProjectPageMember = Labrinth.Projects.v3.TeamMember & { staffOnly?: boolean }
type ModerationAdmonitionSection =
| {
type: 'paragraph'
@@ -181,10 +179,6 @@ const messages = defineMessages({
id: 'project.moderation.thread.title',
defaultMessage: 'Moderation messages',
},
moderatorSeeUserUiToggle: {
id: 'project.moderation.thread.moderator-see-user-ui-toggle',
defaultMessage: 'Show member UI',
},
threadPrivateDescription: {
id: 'project.moderation.thread.private-description',
defaultMessage:
@@ -213,18 +207,10 @@ const messages = defineMessages({
})
const { addNotification } = injectNotificationManager()
const {
projectV2: project,
currentMember: currentMemberRaw,
invalidate,
allMembers,
} = injectProjectPageContext()
const currentMember = currentMemberRaw as Ref<ProjectPageMember | null>
const { projectV2: project, currentMember, invalidate, allMembers } = injectProjectPageContext()
const canAccess = computed(() => !!currentMember.value)
const userFacingUiVisible = computed(
() => !!currentMember.value && (!currentMember.value.staffOnly || moderatorSeeUserUi.value),
)
const userFacingUiVisible = computed(() => !!currentMember.value && moderatorSeeUserUi.value)
const approvedAdmonitionMessage = computed<MessageDescriptor | null>(() => {
switch (project.value?.status) {
@@ -17,6 +17,7 @@ import {
commonMessages,
commonProjectSettingsMessages,
injectProjectPageContext,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { isStaff } from '@modrinth/utils'
@@ -47,10 +48,8 @@ const navItems = computed(() => {
projectV3.value?.project_types?.some((type) => ['mod', 'modpack'].includes(type)) &&
isStaff(currentMember.value?.user)
const hasPermissionsPage = computed(
() =>
flags.value.modpackPermissionsPage &&
projectV3.value?.project_types?.some((type) => ['modpack'].includes(type)),
const hasPermissionsPage = computed(() =>
projectV3.value?.project_types?.some((type) => ['modpack'].includes(type)),
)
const items = [
@@ -82,16 +81,16 @@ const navItems = computed(() => {
label: formatMessage(commonProjectSettingsMessages.description),
icon: AlignLeftIcon,
},
hasPermissionsPage.value && {
link: `/${base}/settings/permissions`,
label: formatMessage(commonProjectSettingsMessages.permissions),
icon: SignatureIcon,
},
!isServerProject.value && {
link: `/${base}/settings/versions`,
label: formatMessage(commonProjectSettingsMessages.versions),
icon: VersionIcon,
},
hasPermissionsPage.value && {
link: `/${base}/settings/permissions`,
label: formatMessage(commonProjectSettingsMessages.permissions),
icon: SignatureIcon,
},
!isServerProject.value && {
link: `/${base}/settings/license`,
label: formatMessage(commonProjectSettingsMessages.license),
@@ -144,6 +143,16 @@ watch(route, () => {
const scrollY = scroll.y.value
setTimeout(() => window.scrollTo(0, scrollY), 10)
})
const moderatorSeeUserUi = computed<boolean>({
get() {
return flags.value.showModeratorProjectMemberUi
},
set(value: boolean) {
flags.value.showModeratorProjectMemberUi = value
saveFeatureFlags()
},
})
</script>
<template>
@@ -167,6 +176,10 @@ watch(route, () => {
<div class="grid gap-4 lg:grid-cols-[1fr_3fr]">
<div>
<NavStack :items="navItems" />
<div v-if="isStaff(currentMember?.user)" class="mt-4 flex items-center gap-2">
<Toggle id="moderator-see-user-ui-toggle" v-model="moderatorSeeUserUi" small />
<label for="moderator-see-user-ui-toggle"> Show member UI </label>
</div>
</div>
<div class="min-w-0">
<NuxtPage />
@@ -1,51 +1,303 @@
<script setup lang="ts">
import { RightArrowIcon, SearchIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
import type { Labrinth } from '@modrinth/api-client'
import {
ArrowDown10Icon,
ArrowDownWideNarrowIcon,
ClockArrowDownIcon,
FoldVerticalIcon,
PauseIcon,
RightArrowIcon,
ScaleIcon,
SearchIcon,
UnfoldVerticalIcon,
XCircleIcon,
} from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
Combobox,
type ComboboxOption,
commonMessages,
createAttributionGroupTitle,
defineMessage,
defineMessages,
EmptyState,
ExternalProjectPermissionsCard,
injectModrinthClient,
injectProjectPageContext,
IntlFormatted,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import ExternalProjectPermissionsCard from '@modrinth/ui/src/components/external_files/ExternalProjectPermissionsCard.vue'
import { ref } from 'vue'
import { isStaff } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import { computed, ref, watch } from 'vue'
const { formatMessage } = useVIntl()
import { setupAttributionModerationProvider } from '~/providers/setup/attribution-moderation'
setupAttributionModerationProvider()
const auth = await useAuth()
const flags = useFeatureFlags()
if (!flags.value.modpackPermissionsPage) {
throw createError({
fatal: true,
statusCode: 404,
})
const isModerator = computed(() => {
return isStaff(auth.value?.user) && !flags.value.showModeratorProjectMemberUi
})
const { formatMessage } = useVIntl()
const { projectV2: project } = injectProjectPageContext()
const { labrinth } = injectModrinthClient()
type SortType = 'status' | 'most_files' | 'recently_edited' | 'rejected'
const searchQuery = ref('')
const currentSortType = ref<SortType>('status')
const cardCollapsedById = ref<Record<string, boolean>>({})
const {
data: attributionData,
error: attributionError,
isPending: pending,
} = useQuery<Labrinth.Attribution.Internal.AttributionGroup[]>({
queryKey: ['project-attribution', project.value.id],
queryFn: () => labrinth.attribution_internal.listProjectAttribution(project.value.id),
})
const sortRejectedLabel = defineMessage({
id: 'project.settings.permissions.sort.rejected',
defaultMessage: 'Rejected',
})
const sortTypes = computed<ComboboxOption<SortType>[]>(() => {
const options: ComboboxOption<SortType>[] = [
{
value: 'status',
label: formatMessage(
defineMessage({
id: 'project.settings.permissions.sort.status',
defaultMessage: 'Status',
}),
),
},
{
value: 'most_files',
label: formatMessage(
defineMessage({
id: 'project.settings.permissions.sort.most-files',
defaultMessage: 'Most files',
}),
),
},
{
value: 'recently_edited',
label: formatMessage(
defineMessage({
id: 'project.settings.permissions.sort.recently-edited',
defaultMessage: 'Recently edited',
}),
),
},
]
if (hasMixedModerationStatus.value) {
options.push({
value: 'rejected',
label: formatMessage(sortRejectedLabel),
})
}
return options
})
const currentSortLabel = computed(() => {
return sortTypes.value.find((option) => option.value === currentSortType.value)?.label ?? ''
})
function isAttributed(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
return !!group.attribution && !isNoPermission(group)
}
const externalFiles = ref([{}])
const searchQuery = ref('')
const currentSortType = ref('Oldest')
function isNoPermission(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
return group.attribution?.kind === 'no_permission'
}
function isModerationRejected(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
const kind = group.attribution?.moderation_status?.kind
return kind === 'bad_proof' || kind === 'not_allowed'
}
function isNotAllowed(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
return group.attribution?.moderation_status?.kind === 'not_allowed'
}
function isBadProof(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
return group.attribution?.moderation_status?.kind === 'bad_proof'
}
function needsModerationApproval(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
const attribution = group.attribution
if (!attribution || attribution.kind === 'globally_allowed') {
return false
}
return !attribution.moderation_status
}
function rejectedSortRank(group: Labrinth.Attribution.Internal.AttributionGroup): number {
if (isModerationRejected(group)) {
return 0
}
if (group.attribution?.moderation_status?.kind === 'approved') {
return 1
}
return 2
}
const hasMixedModerationStatus = computed(() => {
const groups = attributionData.value ?? []
let hasRejected = false
let hasNonRejected = false
for (const group of groups) {
if (isModerationRejected(group)) {
hasRejected = true
} else {
hasNonRejected = true
}
if (hasRejected && hasNonRejected) {
return true
}
}
return false
})
watch(hasMixedModerationStatus, (mixed) => {
if (!mixed && currentSortType.value === 'rejected') {
currentSortType.value = 'status'
}
})
function statusSortRank(group: Labrinth.Attribution.Internal.AttributionGroup): number {
if (isNoPermission(group)) {
return 0
} else if (!group.attribution) {
return 1
} else if (group.attribution?.kind === 'globally_allowed') {
return 3
} else {
return 2
}
}
function alphabetSortKey(group: Labrinth.Attribution.Internal.AttributionGroup): string {
return createAttributionGroupTitle(group, formatMessage)
}
function compareAlphabetical(
a: Labrinth.Attribution.Internal.AttributionGroup,
b: Labrinth.Attribution.Internal.AttributionGroup,
): number {
return alphabetSortKey(a).localeCompare(alphabetSortKey(b), undefined, { sensitivity: 'base' })
}
function attributedTimestamp(group: Labrinth.Attribution.Internal.AttributionGroup): number {
if (!group.attributed_at) return Number.NEGATIVE_INFINITY
const t = Date.parse(group.attributed_at)
return Number.isNaN(t) ? Number.NEGATIVE_INFINITY : t
}
function groupMatchesPermissionsSearch(
group: Labrinth.Attribution.Internal.AttributionGroup,
queryTrimmed: string,
): boolean {
const query = queryTrimmed.toLowerCase()
if (group.flame_project?.title?.toLowerCase().includes(query)) {
return true
}
return (group.files ?? []).some((file) => file.name.toLowerCase().includes(query))
}
const filteredGroups = computed(() => {
const groups = attributionData.value ?? []
const queryTrimmed = searchQuery.value.trim()
const filtered = queryTrimmed
? groups.filter((group) => groupMatchesPermissionsSearch(group, queryTrimmed))
: [...groups]
const sortMode = currentSortType.value
filtered.sort(compareAlphabetical)
filtered.sort((a, b) => {
if (sortMode === 'status') {
return statusSortRank(a) - statusSortRank(b)
}
if (sortMode === 'most_files') {
const ac = a.files?.length ?? 0
const bc = b.files?.length ?? 0
return bc - ac
}
if (sortMode === 'rejected') {
return rejectedSortRank(a) - rejectedSortRank(b)
}
const at = attributedTimestamp(a)
const bt = attributedTimestamp(b)
return bt - at
})
return filtered
})
const totalGroups = computed(() => attributionData.value?.length ?? 0)
const stats = computed(() => {
const groups = attributionData.value ?? []
let attributed = 0
let pending = 0
let noPermission = 0
for (const group of groups) {
if (isNoPermission(group)) {
noPermission++
} else if (isAttributed(group)) {
attributed++
} else {
pending++
}
}
return { total: groups.length, attributed, pending, noPermission }
})
const pendingApprovalCount = computed(() => {
const groups = attributionData.value ?? []
return groups.filter(needsModerationApproval).length
})
const noPermissionCount = computed(() => {
const groups = attributionData.value ?? []
return groups.filter((group) => group.attribution?.kind === 'no_permission').length
})
const notAllowedCount = computed(() => {
const groups = attributionData.value ?? []
return groups.filter(isNotAllowed).length
})
const badProofCount = computed(() => {
const groups = attributionData.value ?? []
return groups.filter(isBadProof).length
})
const pendingCount = computed(() => {
const groups = attributionData.value ?? []
return groups.filter((group) => !group.attribution || isModerationRejected(group)).length
})
const projectIsApproved = computed(() => project.value.status === 'approved')
const sortTypes: ComboboxOption<string>[] = [
{ value: 'Oldest', label: 'Oldest' },
{ value: 'Newest', label: 'Newest' },
]
const messages = defineMessages({
searchPlaceholder: {
id: 'project.settings.permissions.search-placeholder',
defaultMessage:
'Search {count} {count, plural, one {external project} other {external projects}}...',
defaultMessage: 'Search {count} {count, plural, one {project} other {projects}}...',
},
infoBannerTitle: {
id: 'project.settings.permissions.info-banner.title',
defaultMessage: 'Learn how attributions work',
defaultMessage: 'Learn about distribution permissions',
},
infoBannerDescription: {
id: 'project.settings.permissions.info-banner.description',
defaultMessage: `If you include content that isnt hosted on Modrinth, you need to let us know where its from and verify that you have permission to distribute the files. Check out <link>our guide</link> to learn about how to do this properly!`,
defaultMessage: `If you include content that isnt hosted on Modrinth, you need to let us know where its from and verify that you have permission to distribute the files. Check out <link>our announcement of this new system</link> to learn more!`,
},
learnMore: {
id: 'project.settings.permissions.learn-more',
@@ -57,15 +309,16 @@ const messages = defineMessages({
},
emptyStateDescription: {
id: 'project.settings.permissions.empty-state.description',
defaultMessage: `None of your versions contain external content, so you don't need to worry about obtaining permissions.`,
defaultMessage: `None of your project's versions contain external content, so you don't need to worry about obtaining permissions.`,
},
completedTitle: {
id: 'project.settings.permissions.completed.title',
defaultMessage: `Attributions completed!`,
defaultMessage: `Permissions completed!`,
},
completedDescription: {
id: 'project.settings.permissions.completed.description',
defaultMessage: 'All external content has attributions provided.',
defaultMessage:
'All external content has permission information and attributions have been provided.',
},
failTitle: {
id: 'project.settings.permissions.fail.title',
@@ -73,107 +326,280 @@ const messages = defineMessages({
},
failDescription: {
id: 'project.settings.permissions.fail.description',
defaultMessage: `You don't have permission to redistribute some of the external content you've added. In order to publish on Modrinth, remove the infringing content.`,
defaultMessage: `You may not have permission to redistribute some of the external content in your project. In order to publish on Modrinth, please remove this content or provide proof that you do have permission to use it.`,
},
notAllowedDescription: {
id: 'project.settings.permissions.not-allowed.description',
defaultMessage: `Some of the external content included cannot be distributed on Modrinth because it violates our Content Rules and must be removed.`,
},
badProofTitle: {
id: 'project.settings.permissions.bad-proof.title',
defaultMessage: `Some proofs were rejected`,
},
badProofDescription: {
id: 'project.settings.permissions.bad-proof.description',
defaultMessage: `Modrinth's moderation team has rejected the permission information you provided for some external content. Please review the rejected items below and provide acceptable proof or remove the content.`,
},
attentionNeededTitle: {
id: 'project.settings.permissions.attention-needed.title',
defaultMessage: `Unknown embedded content`,
defaultMessage: `Unknown external content`,
},
attentionNeededDescriptionApproved: {
id: 'project.settings.permissions.attention-needed.description.proj-approved',
defaultMessage: `Please provide proof that you have permission to redistribute all of the following files and any withheld versions will be automatically published.`,
defaultMessage: `Please provide proof that you have permission to redistribute all of the following files. Once completed, withheld versions will be automatically published.`,
},
attentionNeededDescriptionDraft: {
id: 'project.settings.permissions.attention-needed.description.proj-draft',
defaultMessage: `Please provide proof that you have permission to redistribute all of the following files before you can submit your project for review.`,
defaultMessage: `Please provide proof that you have permission to redistribute all of the following files before submitting your project for review.`,
},
noResults: {
id: 'project.settings.permissions.no-results',
defaultMessage: 'No external files match your search.',
},
collapseAll: {
id: 'project.settings.permissions.collapse-all',
defaultMessage: 'Collapse all',
},
expandAll: {
id: 'project.settings.permissions.expand-all',
defaultMessage: 'Expand all',
},
pendingApprovalCount: {
id: 'project.settings.permissions.pending-approval-count',
defaultMessage:
'{count, plural, =0 {No attributions need approval} one {# attribution needs approval} other {# attributions need approval}}',
},
})
function defaultCardCollapsed(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
if (group?.attribution?.kind === 'globally_allowed') {
return true
}
if (!isModerator.value) {
const hasAttribution = !!group.attribution
const rejectedProof = group.attribution?.moderation_status?.kind === 'bad_proof'
return hasAttribution && !rejectedProof
}
return false
}
function getCardCollapsed(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
return cardCollapsedById.value[group.id] ?? defaultCardCollapsed(group)
}
function setCardCollapsed(groupId: string, collapsed: boolean) {
cardCollapsedById.value = { ...cardCollapsedById.value, [groupId]: collapsed }
}
const allCardsCollapsed = computed(() => {
const groups = filteredGroups.value
if (groups.length === 0) {
return true
}
return groups.every((group) => getCardCollapsed(group))
})
const expandCollapseAllLabel = computed(() =>
formatMessage(allCardsCollapsed.value ? messages.expandAll : messages.collapseAll),
)
function toggleAllCardsCollapsed() {
const collapsed = !allCardsCollapsed.value
const next = { ...cardCollapsedById.value }
for (const group of filteredGroups.value) {
next[group.id] = collapsed
}
cardCollapsedById.value = next
}
function dismissInfoBanner() {
flags.value.dismissedExternalProjectsInfo = true
saveFeatureFlags()
}
</script>
<template>
<template v-if="externalFiles.length > 0">
<Admonition
v-if="!flags.dismissedExternalProjectsInfo"
type="info"
class="mb-4"
:header="formatMessage(messages.infoBannerTitle)"
dismissible
@dismiss="dismissInfoBanner"
>
<IntlFormatted :message-id="messages.infoBannerDescription">
<template #link="{ children }">
<a class="text-link" target="_blank"> <component :is="() => children" /> </a>
</template>
</IntlFormatted>
<template #actions>
<div class="flex">
<ButtonStyled color="blue">
<a> {{ formatMessage(messages.learnMore) }} <RightArrowIcon /> </a>
</ButtonStyled>
</div>
<Admonition
v-if="!flags.dismissedExternalProjectsInfo && !isModerator"
type="info"
class="mb-4"
:header="formatMessage(messages.infoBannerTitle)"
dismissible
@dismiss="dismissInfoBanner"
>
<IntlFormatted :message-id="messages.infoBannerDescription">
<template #link="{ children }">
<nuxt-link class="text-link" to="/news/article/modpack-permissions/" target="_blank">
<component :is="() => children" />
</nuxt-link>
</template>
</Admonition>
<Admonition
v-if="true"
type="success"
class="mb-4"
:header="formatMessage(messages.completedTitle)"
:body="formatMessage(messages.completedDescription)"
/>
<Admonition
v-if="true"
type="warning"
class="mb-4"
:header="formatMessage(messages.attentionNeededTitle)"
:body="formatMessage(messages.attentionNeededDescriptionDraft)"
/>
<Admonition
v-if="true"
type="critical"
class="mb-4"
:header="formatMessage(messages.failTitle)"
:body="formatMessage(messages.failDescription)"
/>
<div class="grid grid-cols-[1fr_auto] gap-2">
<StyledInput
v-model="searchQuery"
type="search"
:placeholder="
formatMessage(messages.searchPlaceholder, {
count: externalFiles.length,
})
"
:icon="SearchIcon"
input-class="h-[40px]"
/>
<div>
<Combobox
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
:options="sortTypes"
:placeholder="formatMessage(commonMessages.sortByLabel)"
>
<template #selected>
<span class="flex flex-row gap-2 align-middle font-semibold">
<SortAscIcon
v-if="currentSortType === 'Oldest'"
class="size-5 flex-shrink-0 text-secondary"
/>
<SortDescIcon v-else class="size-5 flex-shrink-0 text-secondary" />
<span class="truncate text-contrast">{{ currentSortType }}</span>
</span>
</template>
</Combobox>
</IntlFormatted>
<template #actions>
<div class="flex">
<ButtonStyled color="blue">
<a> {{ formatMessage(messages.learnMore) }} <RightArrowIcon /> </a>
</ButtonStyled>
</div>
</div>
<div class="mt-4 flex flex-col gap-3">
<ExternalProjectPermissionsCard title="FTB Library" />
</template>
</Admonition>
<template v-if="pending">
<div class="flex flex-col gap-3">
<div
v-for="i in 3"
:key="i"
class="h-[56px] w-full animate-pulse rounded-2xl bg-surface-3"
></div>
</div>
</template>
<template v-else-if="totalGroups > 0">
<template v-if="!isModerator">
<Admonition
v-if="
stats.pending === 0 &&
stats.noPermission === 0 &&
notAllowedCount === 0 &&
badProofCount === 0
"
type="success"
class="mb-4"
:header="formatMessage(messages.completedTitle)"
:body="formatMessage(messages.completedDescription)"
/>
<Admonition
v-else-if="notAllowedCount > 0"
type="critical"
class="mb-4"
:header="formatMessage(messages.failTitle)"
:body="formatMessage(messages.notAllowedDescription)"
/>
<Admonition
v-else-if="stats.noPermission > 0"
type="critical"
class="mb-4"
:header="formatMessage(messages.failTitle)"
:body="formatMessage(messages.failDescription)"
/>
<Admonition
v-else-if="badProofCount > 0"
type="critical"
class="mb-4"
:header="formatMessage(messages.badProofTitle)"
:body="formatMessage(messages.badProofDescription)"
/>
<Admonition
v-else-if="stats.pending > 0"
type="warning"
class="mb-4"
:header="formatMessage(messages.attentionNeededTitle)"
:body="
formatMessage(
projectIsApproved
? messages.attentionNeededDescriptionApproved
: messages.attentionNeededDescriptionDraft,
)
"
/>
</template>
<div class="flex flex-col gap-2">
<div class="grid grid-cols-[1fr_auto_auto] gap-2">
<StyledInput
v-model="searchQuery"
type="text"
autocomplete="off"
clearable
:placeholder="
formatMessage(messages.searchPlaceholder, {
count: totalGroups,
})
"
:icon="SearchIcon"
input-class="h-[40px]"
/>
<div>
<Combobox
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[220px] sm:flex-grow-0 [&>span]:h-[40px]"
:options="sortTypes"
:placeholder="formatMessage(commonMessages.sortByLabel)"
>
<template #selected>
<span class="flex flex-row gap-2 align-middle font-semibold">
<ArrowDownWideNarrowIcon
v-if="currentSortType === 'status'"
class="size-5 flex-shrink-0 text-secondary"
/>
<ArrowDown10Icon
v-else-if="currentSortType === 'most_files'"
class="size-5 flex-shrink-0 text-secondary"
/>
<ClockArrowDownIcon
v-else-if="currentSortType === 'recently_edited'"
class="size-5 flex-shrink-0 text-secondary"
/>
<ArrowDownWideNarrowIcon
v-else-if="currentSortType === 'rejected'"
class="size-5 flex-shrink-0 text-secondary"
/>
<span class="truncate text-contrast">{{ currentSortLabel }}</span>
</span>
</template>
</Combobox>
</div>
<ButtonStyled>
<button type="button" class="!h-[40px]" @click="toggleAllCardsCollapsed">
<UnfoldVerticalIcon
v-if="allCardsCollapsed"
class="size-5 flex-shrink-0 text-secondary"
/>
<FoldVerticalIcon v-else class="size-5 flex-shrink-0 text-secondary" />
{{ expandCollapseAllLabel }}
</button>
</ButtonStyled>
</div>
</div>
<div v-if="isModerator" class="mt-4 flex flex-wrap items-center gap-3">
<template v-if="noPermissionCount > 0">
<span class="flex items-center gap-1 text-red">
<XCircleIcon class="size-4 shrink-0" /> {{ noPermissionCount }} with no permission
</span>
<span class="text-surface-5">•</span>
</template>
<template v-if="pendingCount > 0">
<span class="flex items-center gap-1 text-orange">
<PauseIcon class="size-4 shrink-0" /> {{ pendingCount }} need user action
</span>
<span class="text-surface-5">•</span>
</template>
<span v-if="pendingApprovalCount > 0" class="flex items-center gap-1 text-contrast">
<ScaleIcon class="size-4 shrink-0" /> {{ pendingApprovalCount }} awaiting review
</span>
<span v-else class="flex items-center gap-1 text-secondary">
<ScaleIcon class="size-4 shrink-0" /> None awaiting review
</span>
</div>
<div class="mt-4 flex flex-col gap-3">
<TransitionGroup name="list">
<ExternalProjectPermissionsCard
v-for="group in filteredGroups"
:key="group.id"
:collapsed="getCardCollapsed(group)"
:project-id="project.id"
:group="group"
:is-moderator="isModerator"
@update:collapsed="setCardCollapsed(group.id, $event)"
/>
<EmptyState
v-if="filteredGroups.length === 0"
:heading="formatMessage(messages.noResults)"
type="no-search-result"
/>
</TransitionGroup>
</div>
</template>
<div v-else-if="attributionError">
<p v-if="attributionError" class="my-12 text-center text-red">
{{ attributionError }}
</p>
</div>
<template v-else>
<EmptyState
:heading="formatMessage(messages.emptyStateHeading)"
@@ -182,3 +608,18 @@ function dismissInfoBanner() {
/>
</template>
</template>
<style scoped>
.list-enter-from {
opacity: 0;
transform: translateY(-10px);
}
.list-leave-to {
opacity: 0;
transform: translateY(10px);
}
.list-move {
transition: transform 200ms ease-in-out;
}
</style>
@@ -14,21 +14,22 @@
proceed-label="Delete"
@proceed="deleteVersion()"
/>
<Admonition
v-if="flags.modpackPermissionsPage && withheldVersions.length > 0"
type="warning"
v-if="withheldVersions.length > 0"
type="circle-warning"
class="mb-4"
:header="
formatMessage(messages.withheldVersionsWarningTitle, {
count: withheldVersions.length,
version_name: withheldVersions.length === 1 ? withheldVersions[0] : undefined,
version_name:
withheldVersions.length === 1 ? withheldVersions[0].version_number : undefined,
})
"
:body="
formatMessage(messages.withheldVersionsWarningDescription, {
count: withheldVersions.length,
version_name: withheldVersions.length === 1 ? withheldVersions[0] : undefined,
version_name:
withheldVersions.length === 1 ? withheldVersions[0].version_number : undefined,
})
"
>
@@ -40,7 +41,8 @@
project.slug ? project.slug : project.id
}/settings/permissions`"
>
{{ formatMessage(messages.withheldVersionsWarningResolve) }} <RightArrowIcon />
{{ formatMessage(commonProjectSettingsMessages.withheldVersionsWarningResolve) }}
<RightArrowIcon />
</nuxt-link>
</ButtonStyled>
</div>
@@ -333,6 +335,7 @@ import {
import {
Admonition,
ButtonStyled,
commonProjectSettingsMessages,
ConfirmModal,
defineMessages,
injectModrinthClient,
@@ -458,7 +461,9 @@ async function deleteVersion() {
stopLoading()
}
const withheldVersions = computed(() => ['4.0.0'])
const withheldVersions = computed(() =>
versions.value.filter((x) => x.files_missing_attribution?.length > 0),
)
const messages = defineMessages({
withheldVersionsWarningTitle: {
@@ -471,9 +476,5 @@ const messages = defineMessages({
defaultMessage:
'{count, plural, one {This version is} other {These versions are}} currently withheld and not publicly listed. Please provide proof that you have permission to redistribute certain files included in the modpack {count, plural, one {version} other {versions}}.',
},
withheldVersionsWarningResolve: {
id: 'project.versions.withheld-versions-warning.resolve-button',
defaultMessage: 'Resolve',
},
})
</script>
File diff suppressed because it is too large Load Diff
@@ -185,7 +185,7 @@ function mapExternalProject(
exceptions: project.exceptions,
proof: project.proof,
flame_project_id: project.flame_project_id,
files: project.linked_files,
files: project.linked_files ?? [],
}
}
@@ -149,27 +149,34 @@
<div class="card flex-card">
<h2>Members</h2>
<div class="details-list">
<template v-for="member in acceptedMembers" :key="member?.user.id">
<nuxt-link
class="details-list__item details-list__item--type-large"
:to="`/user/${member?.user?.username}`"
>
<Avatar :src="member?.user.avatar_url" circle />
<div class="rows">
<span class="flex items-center gap-1">
{{ member?.user?.username }}
<CrownIcon
v-if="member?.is_owner"
v-tooltip="'Organization owner'"
class="text-brand-orange"
/>
</span>
<span class="details-list__item__text--style-secondary">
{{ member?.role ? member.role : 'Member' }}
</span>
</div>
</nuxt-link>
</template>
<nuxt-link
v-for="member in acceptedMembers"
:key="`member-${member?.user?.id}`"
class="group flex w-fit items-center gap-2 leading-[1.2] text-primary"
:to="`/user/${member?.user?.username}`"
>
<Avatar
:src="member.user.avatar_url"
:alt="member.user.username"
size="32px"
circle
/>
<div class="flex flex-col">
<span class="flex w-full flex-nowrap items-center gap-1 group-hover:underline">
<span class="min-w-0 overflow-hidden truncate font-normal text-contrast">{{
member.user.username
}}</span>
<CrownIcon
v-if="member.is_owner"
v-tooltip="'Organization owner'"
class="text-brand-orange"
/>
</span>
<span class="text-sm font-normal">
{{ member?.role ? member.role : 'Member' }}
</span>
</div>
</nuxt-link>
</div>
</div>
</div>
+1 -1
View File
@@ -284,11 +284,11 @@ import {
VersionIcon,
XCircleIcon,
} from '@modrinth/assets'
import { defineMessage } from '@modrinth/ui'
import {
AutoLink,
Avatar,
ButtonStyled,
defineMessage,
defineMessages,
formatReportItemType,
injectNotificationManager,
+5 -1
View File
@@ -133,7 +133,7 @@
<span class="flex items-center gap-2">
{{ user.username }}
<BadgeCheckIcon
v-if="isModrinthUser"
v-if="isOfficialAccount"
v-tooltip="formatMessage(messages.officialAccount)"
class="size-5 text-brand"
fill="var(--color-brand-highlight)"
@@ -810,6 +810,10 @@ const sortedOrgs = computed(() =>
)
const isModrinthUser = computed(() => user.value?.id === '2REoufqX')
const isAutoMod = computed(() => user.value?.id === '')
const isOfficialAccount = computed(
() => isModrinthUser.value || isAutoMod.value || user.value?.id === 'GVFjtWTf',
)
const sortedCollections = computed(() => {
const list = collections.value