Compare commits

...
Author SHA1 Message Date
aecsocket e9044e5794 prepr 2026-07-23 09:29:38 +01:00
aecsocket de0f596ed5 feat: allow bulk deleting attribution groups for moderators 2026-07-23 09:10:33 +01:00
14 changed files with 316 additions and 46 deletions
@@ -235,10 +235,7 @@ async function clearAllGroups() {
const groups = await client.labrinth.attribution_internal.listProjectAttribution(
props.project_id,
)
for (const group of groups) {
await client.labrinth.attribution_internal.deleteGroup(group.id)
}
await client.labrinth.attribution_internal.deleteGroups(groups.map((group) => group.id))
await queryClient.invalidateQueries({ queryKey: ['project-attribution', props.project_id] })
} catch (error) {
@@ -3905,6 +3905,21 @@
"project.settings.permissions.completed.title": {
"message": "Permissions completed!"
},
"project.settings.permissions.delete-all-groups": {
"message": "Delete all groups"
},
"project.settings.permissions.delete-all-groups-confirmation.description": {
"message": "This will permanently delete all {count, plural, one {# attribution group} other {# attribution groups}} for this project and all files inside them. This action cannot be undone."
},
"project.settings.permissions.delete-all-groups-confirmation.title": {
"message": "Delete all attribution groups?"
},
"project.settings.permissions.delete-all-groups-error": {
"message": "Could not delete all attribution groups"
},
"project.settings.permissions.delete-all-groups-success": {
"message": "All attribution groups were deleted."
},
"project.settings.permissions.empty-state.description": {
"message": "None of your project's versions contain external content, so you don't need to worry about obtaining permissions."
},
@@ -9,6 +9,8 @@ import {
RightArrowIcon,
ScaleIcon,
SearchIcon,
SpinnerIcon,
TrashIcon,
UnfoldVerticalIcon,
XCircleIcon,
} from '@modrinth/assets'
@@ -18,20 +20,22 @@ import {
Combobox,
type ComboboxOption,
commonMessages,
ConfirmModal,
createAttributionGroupTitle,
defineMessage,
defineMessages,
EmptyState,
ExternalProjectPermissionsCard,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
IntlFormatted,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { isStaff } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import { computed, ref, watch } from 'vue'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { setupAttributionModerationProvider } from '~/providers/setup/attribution-moderation'
@@ -47,6 +51,10 @@ const isModerator = computed(() => {
const { formatMessage } = useVIntl()
const { projectV2: project } = injectProjectPageContext()
const { labrinth } = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const queryClient = useQueryClient()
const deleteAllGroupsModalRef =
useTemplateRef<InstanceType<typeof ConfirmModal>>('deleteAllGroupsModalRef')
type SortType = 'status' | 'most_files' | 'recently_edited' | 'rejected'
@@ -369,6 +377,46 @@ const messages = defineMessages({
defaultMessage:
'{count, plural, =0 {No attributions need approval} one {# attribution needs approval} other {# attributions need approval}}',
},
deleteAllGroups: {
id: 'project.settings.permissions.delete-all-groups',
defaultMessage: 'Delete all groups',
},
deleteAllGroupsConfirmationTitle: {
id: 'project.settings.permissions.delete-all-groups-confirmation.title',
defaultMessage: 'Delete all attribution groups?',
},
deleteAllGroupsConfirmationDescription: {
id: 'project.settings.permissions.delete-all-groups-confirmation.description',
defaultMessage:
'This will permanently delete all {count, plural, one {# attribution group} other {# attribution groups}} for this project and all files inside them. This action cannot be undone.',
},
deleteAllGroupsSuccess: {
id: 'project.settings.permissions.delete-all-groups-success',
defaultMessage: 'All attribution groups were deleted.',
},
deleteAllGroupsError: {
id: 'project.settings.permissions.delete-all-groups-error',
defaultMessage: 'Could not delete all attribution groups',
},
})
const deleteAllGroupsMutation = useMutation({
mutationFn: () => labrinth.attribution_internal.deleteAllGroups(project.value.id),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['project-attribution', project.value.id] })
addNotification({
type: 'success',
title: formatMessage(messages.deleteAllGroups),
text: formatMessage(messages.deleteAllGroupsSuccess),
})
},
onError: (error: Error) => {
addNotification({
type: 'error',
title: formatMessage(messages.deleteAllGroupsError),
text: error.message,
})
},
})
function defaultCardCollapsed(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
@@ -412,12 +460,26 @@ function toggleAllCardsCollapsed() {
cardCollapsedById.value = next
}
function showDeleteAllGroupsConfirmation() {
deleteAllGroupsModalRef.value?.show()
}
function dismissInfoBanner() {
flags.value.dismissedExternalProjectsInfo = true
saveFeatureFlags()
}
</script>
<template>
<ConfirmModal
v-if="isModerator"
ref="deleteAllGroupsModalRef"
:title="formatMessage(messages.deleteAllGroupsConfirmationTitle)"
:description="
formatMessage(messages.deleteAllGroupsConfirmationDescription, { count: totalGroups })
"
:proceed-label="formatMessage(messages.deleteAllGroups)"
@proceed="deleteAllGroupsMutation.mutate()"
/>
<Admonition
v-if="!flags.dismissedExternalProjectsInfo && !isModerator"
type="info"
@@ -500,7 +562,7 @@ function dismissInfoBanner() {
/>
</template>
<div class="flex flex-col gap-2">
<div class="grid grid-cols-[1fr_auto_auto] gap-2">
<div class="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_auto_auto_auto]">
<StyledInput
v-model="searchQuery"
type="text"
@@ -554,6 +616,21 @@ function dismissInfoBanner() {
{{ expandCollapseAllLabel }}
</button>
</ButtonStyled>
<ButtonStyled v-if="isModerator" color="red" type="outlined">
<button
type="button"
class="!h-[40px]"
:disabled="deleteAllGroupsMutation.isPending.value"
@click="showDeleteAllGroupsConfirmation"
>
<SpinnerIcon
v-if="deleteAllGroupsMutation.isPending.value"
class="size-5 flex-shrink-0 animate-spin"
/>
<TrashIcon v-else class="size-5 flex-shrink-0" />
{{ formatMessage(messages.deleteAllGroups) }}
</button>
</ButtonStyled>
</div>
</div>
<div v-if="isModerator" class="mt-4 flex flex-wrap items-center gap-3">
@@ -1,14 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\tDELETE FROM project_attribution_files\n\t\tWHERE group_id = $1\n\t\t",
"query": "\n\t\tDELETE FROM project_attribution_files\n\t\tWHERE group_id = ANY($1)\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
"Int8Array"
]
},
"nullable": []
},
"hash": "b69317d234e934aa6bdf8be4d35bc7475308bde8b7f34a4ef3c7f3ef351f46a8"
"hash": "14779c6105c15c6a117332caf850c7939af5d70562eb309680047da3c76f74fd"
}
@@ -1,14 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\tDELETE FROM project_attribution_groups\n\t\tWHERE id = $1\n\t\t",
"query": "\n\t\tDELETE FROM project_attribution_groups\n\t\tWHERE id = ANY($1)\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
"Int8Array"
]
},
"nullable": []
},
"hash": "827534d191739cd2ea502142a65f62a052715c7441952ee4ee182afde07c44d7"
"hash": "41ec69f790366c741cea01aa7e1bfac7912b4ed17b7d19b5ba10221fa93bc3f8"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\tSELECT id AS \"id: DBAttributionGroupId\"\n\t\tFROM project_attribution_groups\n\t\tWHERE project_id = $1\n\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: DBAttributionGroupId",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false
]
},
"hash": "78f66d65d665210fd8334d2e1e8a793ce8128f7d6b53b3b408b030bfee4b3841"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\tSELECT DISTINCT f.version_id AS \"version_id: DBVersionId\"\n\t\tFROM project_attribution_files paf\n\t\tINNER JOIN project_attribution_groups pag ON pag.id = paf.group_id\n\t\tINNER JOIN override_file_sources ofs ON ofs.sha1 = paf.sha1\n\t\tINNER JOIN files f ON f.id = ofs.file_id\n\t\tINNER JOIN versions v ON v.id = f.version_id\n\t\tWHERE paf.group_id = $1\n\t\t\tAND pag.project_id = v.mod_id\n\t\t",
"query": "\n\t\tSELECT DISTINCT f.version_id AS \"version_id: DBVersionId\"\n\t\tFROM project_attribution_files paf\n\t\tINNER JOIN project_attribution_groups pag ON pag.id = paf.group_id\n\t\tINNER JOIN override_file_sources ofs ON ofs.sha1 = paf.sha1\n\t\tINNER JOIN files f ON f.id = ofs.file_id\n\t\tINNER JOIN versions v ON v.id = f.version_id\n\t\tWHERE paf.group_id = ANY($1)\n\t\t\tAND pag.project_id = v.mod_id\n\t\t",
"describe": {
"columns": [
{
@@ -11,12 +11,12 @@
],
"parameters": {
"Left": [
"Int8"
"Int8Array"
]
},
"nullable": [
false
]
},
"hash": "00ef039799c162e21a8fa5ba7aec2a9044a3028f1cb34e8c9c565a670956d938"
"hash": "bfc67441eb246ac6b155db36919a7a62e19e0dd0c87916e761175ec909add15b"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET email_verified = TRUE WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "cb626a36deffd73e67de2dc4789bd875675779a173fb2cd41ba365c1acca96f9"
}
@@ -14,7 +14,7 @@ use crate::database::models::{
};
use crate::database::redis::RedisPool;
use crate::file_hosting::FileHost;
use crate::models::ids::{FileId, ProjectId, VersionId};
use crate::models::ids::{AttributionGroupId, FileId, ProjectId, VersionId};
use crate::models::pats::Scopes;
use crate::models::projects::{
AttributionModerationStatusKind, AttributionResolution,
@@ -31,7 +31,8 @@ use crate::util::error::Context;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(list)
.service(update_group)
.service(delete_group)
.service(delete_groups)
.service(delete_all_groups)
.service(scan)
.service(force_scan_file)
.service(assign)
@@ -706,19 +707,25 @@ pub async fn update_group(
Ok(())
}
/// Delete an attribution group and all files inside it.
#[derive(Deserialize, utoipa::ToSchema)]
struct DeleteGroupsBody {
groups: Vec<AttributionGroupId>,
}
/// Delete attribution groups and all files inside them.
#[utoipa::path(
context_path = "/attribution",
tag = "attribution",
request_body = DeleteGroupsBody,
responses((status = NO_CONTENT))
)]
#[delete("/group/{group_id}")]
pub async fn delete_group(
#[delete("/group")]
pub async fn delete_groups(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
path: web::Path<i64>,
web::Json(body): web::Json<DeleteGroupsBody>,
) -> Result<(), ApiError> {
check_is_moderator_from_headers(
&req,
@@ -729,11 +736,79 @@ pub async fn delete_group(
)
.await?;
let group_ids = body
.groups
.into_iter()
.map(|id| DBAttributionGroupId::from(id).0)
.collect::<Vec<_>>();
delete_attribution_groups(pool.as_ref(), redis.as_ref(), group_ids).await
}
#[derive(Deserialize, utoipa::ToSchema)]
struct DeleteAllGroupsBody {
project_id: ProjectId,
}
/// Delete all attribution groups and files for a project.
#[utoipa::path(
context_path = "/attribution",
tag = "attribution",
request_body = DeleteAllGroupsBody,
responses((status = NO_CONTENT))
)]
#[delete("/all-groups")]
pub async fn delete_all_groups(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
web::Json(body): web::Json<DeleteAllGroupsBody>,
) -> Result<(), ApiError> {
check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_READ,
)
.await?;
let project_id = DBProjectId::from(body.project_id).0;
let group_ids = sqlx::query_scalar!(
r#"
SELECT id AS "id: DBAttributionGroupId"
FROM project_attribution_groups
WHERE project_id = $1
"#,
project_id,
)
.fetch_all(pool.as_ref())
.await
.wrap_internal_err("failed to fetch project attribution groups")?
.into_iter()
.map(|id| id.0)
.collect::<Vec<_>>();
delete_attribution_groups(pool.as_ref(), redis.as_ref(), group_ids).await
}
async fn delete_attribution_groups(
pool: &PgPool,
redis: &RedisPool,
mut group_ids: Vec<i64>,
) -> Result<(), ApiError> {
group_ids.sort_unstable();
group_ids.dedup();
if group_ids.is_empty() {
return Ok(());
}
let mut txn = pool.begin().await.wrap_internal_err(
"failed to begin attribution group deletion transaction",
)?;
let group_id = path.into_inner();
let version_ids = sqlx::query_scalar!(
r#"
SELECT DISTINCT f.version_id AS "version_id: DBVersionId"
@@ -742,10 +817,10 @@ pub async fn delete_group(
INNER JOIN override_file_sources ofs ON ofs.sha1 = paf.sha1
INNER JOIN files f ON f.id = ofs.file_id
INNER JOIN versions v ON v.id = f.version_id
WHERE paf.group_id = $1
WHERE paf.group_id = ANY($1)
AND pag.project_id = v.mod_id
"#,
group_id,
&group_ids,
)
.fetch_all(&mut txn)
.await
@@ -754,9 +829,9 @@ pub async fn delete_group(
sqlx::query!(
"
DELETE FROM project_attribution_files
WHERE group_id = $1
WHERE group_id = ANY($1)
",
group_id,
&group_ids,
)
.execute(&mut txn)
.await
@@ -765,15 +840,15 @@ pub async fn delete_group(
let result = sqlx::query!(
"
DELETE FROM project_attribution_groups
WHERE id = $1
WHERE id = ANY($1)
",
group_id,
&group_ids,
)
.execute(&mut txn)
.await
.wrap_internal_err("failed to delete attribution group")?;
.wrap_internal_err("failed to delete attribution groups")?;
if result.rows_affected() == 0 {
if result.rows_affected() != group_ids.len() as u64 {
return Err(ApiError::NotFound);
}
@@ -781,7 +856,7 @@ pub async fn delete_group(
"failed to commit attribution group deletion transaction",
)?;
DBVersion::clear_cache_ids(&version_ids, redis.as_ref())
DBVersion::clear_cache_ids(&version_ids, redis)
.await
.wrap_internal_err("failed to clear version attribution cache")?;
+2 -1
View File
@@ -142,7 +142,8 @@ pub fn config(cfg: &mut web::ServiceConfig) {
attribution::scan,
attribution::list,
attribution::update_group,
attribution::delete_group,
attribution::delete_groups,
attribution::delete_all_groups,
attribution::assign,
attribution::split,
billing::products,
@@ -65,17 +65,34 @@ export class LabrinthAttributionInternalModule extends AbstractModule {
}
/**
* Delete an attribution group and all files inside it.
* DELETE /_internal/attribution/group/{group_id}
* Delete attribution groups and all files inside them.
* DELETE /_internal/attribution/group
*
* @param groupId - The base62 attribution group id (as returned from listProjectAttribution).
* @param groupIds - The base62 attribution group ids (as returned from listProjectAttribution).
*/
public async deleteGroup(groupId: string): Promise<void> {
const numericId = decodeBase62Id(groupId)
return this.client.request<void>(`/attribution/group/${numericId}`, {
public async deleteGroups(groupIds: string[]): Promise<void> {
const body: Labrinth.Attribution.Internal.DeleteGroupsRequest = { groups: groupIds }
return this.client.request<void>('/attribution/group', {
api: 'labrinth',
version: 'internal',
method: 'DELETE',
body,
})
}
/**
* Delete all attribution groups and files for a project.
* DELETE /_internal/attribution/all-groups
*/
public async deleteAllGroups(projectId: string): Promise<void> {
const body: Labrinth.Attribution.Internal.DeleteAllGroupsRequest = {
project_id: projectId,
}
return this.client.request<void>('/attribution/all-groups', {
api: 'labrinth',
version: 'internal',
method: 'DELETE',
body,
})
}
@@ -397,6 +397,14 @@ export namespace Labrinth {
attribution: AttributionResolution
}
export type DeleteGroupsRequest = {
groups: string[]
}
export type DeleteAllGroupsRequest = {
project_id: string
}
export type AssignRequest = {
sha1: string
target_group_id: number
@@ -18,7 +18,7 @@ import { renderString } from '@modrinth/utils'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { ButtonStyled, Collapsible, OverflowMenu } from '#ui/components'
import { ButtonStyled, Collapsible, ConfirmModal, OverflowMenu } from '#ui/components'
import type { OverflowMenuOption } from '#ui/components/base'
import { commonMessages } from '#ui/utils'
@@ -79,6 +79,7 @@ const addToGlobalModalRef =
useTemplateRef<typeof AddToGlobalPermissionsDatabaseModal>('addToGlobalModalRef')
const addToExistingModalRef =
useTemplateRef<typeof AddToExistingExternalProjectModal>('addToExistingModalRef')
const deleteGroupModalRef = useTemplateRef<InstanceType<typeof ConfirmModal>>('deleteGroupModalRef')
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
@@ -138,7 +139,20 @@ const messages = defineMessages({
},
removeGroup: {
id: 'external-files.permissions-card.remove-group',
defaultMessage: 'Remove group',
defaultMessage: 'Delete group',
},
removeGroupConfirmationTitle: {
id: 'external-files.permissions-card.remove-group-confirmation.title',
defaultMessage: 'Delete {title}?',
},
removeGroupConfirmationDescription: {
id: 'external-files.permissions-card.remove-group-confirmation.description',
defaultMessage:
'This will permanently delete this attribution group and all files inside it. This action cannot be undone.',
},
removeGroupShiftHint: {
id: 'external-files.permissions-card.remove-group-shift-hint',
defaultMessage: 'Hold Shift while clicking to skip confirmation.',
},
moderationReasonLabel: {
id: 'external-files.permissions-card.moderation-reason',
@@ -254,7 +268,7 @@ const splitFileMutation = useMutation({
})
const deleteGroupMutation = useMutation({
mutationFn: () => client.labrinth.attribution_internal.deleteGroup(props.group.id),
mutationFn: () => client.labrinth.attribution_internal.deleteGroups([props.group.id]),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['project-attribution', props.projectId] })
emit('updated')
@@ -265,7 +279,7 @@ const deleteGroupMutation = useMutation({
title: formatMessage(
defineMessage({
id: 'external-files.permissions-card.remove-group-error.title',
defaultMessage: 'Could not remove group',
defaultMessage: 'Could not delete group',
}),
),
text: error.message,
@@ -305,10 +319,19 @@ function handleConfirmAddFiles(sha1s: string[]) {
assignFilesMutation.mutate(sha1s)
}
function handleDeleteGroup() {
function deleteGroup() {
deleteGroupMutation.mutate()
}
function handleDeleteGroup(event: MouseEvent) {
if (event.shiftKey) {
deleteGroup()
return
}
deleteGroupModalRef.value?.show()
}
async function handleAddFilesToGroup(event: MouseEvent) {
try {
const groups = await queryClient.ensureQueryData({
@@ -825,6 +848,18 @@ const visibleQuickReplies = computed<OverflowMenuOption[]>(() => {
</div>
</div>
</Collapsible>
<ConfirmModal
v-if="isModerator"
ref="deleteGroupModalRef"
:title="formatMessage(messages.removeGroupConfirmationTitle, { title })"
:description="formatMessage(messages.removeGroupConfirmationDescription)"
:proceed-label="formatMessage(messages.removeGroup)"
@proceed="deleteGroup"
>
<p class="m-0 text-xs text-secondary">
{{ formatMessage(messages.removeGroupShiftHint) }}
</p>
</ConfirmModal>
<AddFilesToAttributionGroupModal
ref="addFilesModalRef"
:group-id="group.id"
+11 -2
View File
@@ -1170,10 +1170,19 @@
"defaultMessage": "You have obtained special permission to redistribute this work in your modpack."
},
"external-files.permissions-card.remove-group": {
"defaultMessage": "Remove group"
"defaultMessage": "Delete group"
},
"external-files.permissions-card.remove-group-confirmation.description": {
"defaultMessage": "This will permanently delete this attribution group and all files inside it. This action cannot be undone."
},
"external-files.permissions-card.remove-group-confirmation.title": {
"defaultMessage": "Delete {title}?"
},
"external-files.permissions-card.remove-group-error.title": {
"defaultMessage": "Could not remove group"
"defaultMessage": "Could not delete group"
},
"external-files.permissions-card.remove-group-shift-hint": {
"defaultMessage": "Hold Shift while clicking to skip confirmation."
},
"external-files.permissions-card.split-file": {
"defaultMessage": "Remove from group"