diff --git a/apps/frontend/src/components/ui/moderation/ModpackScanModal.vue b/apps/frontend/src/components/ui/moderation/ModpackScanModal.vue index 7298a5590c..22f5fa7f64 100644 --- a/apps/frontend/src/components/ui/moderation/ModpackScanModal.vue +++ b/apps/frontend/src/components/ui/moderation/ModpackScanModal.vue @@ -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) { diff --git a/apps/frontend/src/locales/en-US/index.json b/apps/frontend/src/locales/en-US/index.json index cee25fcc2b..e02e1b57f3 100644 --- a/apps/frontend/src/locales/en-US/index.json +++ b/apps/frontend/src/locales/en-US/index.json @@ -3899,6 +3899,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." }, diff --git a/apps/frontend/src/pages/[type]/[project]/settings/permissions.vue b/apps/frontend/src/pages/[type]/[project]/settings/permissions.vue index c79c12a06b..16997ddf54 100644 --- a/apps/frontend/src/pages/[type]/[project]/settings/permissions.vue +++ b/apps/frontend/src/pages/[type]/[project]/settings/permissions.vue @@ -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>('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() }
-
+
+ + +
diff --git a/apps/labrinth/.sqlx/query-b69317d234e934aa6bdf8be4d35bc7475308bde8b7f34a4ef3c7f3ef351f46a8.json b/apps/labrinth/.sqlx/query-14779c6105c15c6a117332caf850c7939af5d70562eb309680047da3c76f74fd.json similarity index 61% rename from apps/labrinth/.sqlx/query-b69317d234e934aa6bdf8be4d35bc7475308bde8b7f34a4ef3c7f3ef351f46a8.json rename to apps/labrinth/.sqlx/query-14779c6105c15c6a117332caf850c7939af5d70562eb309680047da3c76f74fd.json index cba2e4ff28..9050ecc081 100644 --- a/apps/labrinth/.sqlx/query-b69317d234e934aa6bdf8be4d35bc7475308bde8b7f34a4ef3c7f3ef351f46a8.json +++ b/apps/labrinth/.sqlx/query-14779c6105c15c6a117332caf850c7939af5d70562eb309680047da3c76f74fd.json @@ -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" } diff --git a/apps/labrinth/.sqlx/query-827534d191739cd2ea502142a65f62a052715c7441952ee4ee182afde07c44d7.json b/apps/labrinth/.sqlx/query-41ec69f790366c741cea01aa7e1bfac7912b4ed17b7d19b5ba10221fa93bc3f8.json similarity index 62% rename from apps/labrinth/.sqlx/query-827534d191739cd2ea502142a65f62a052715c7441952ee4ee182afde07c44d7.json rename to apps/labrinth/.sqlx/query-41ec69f790366c741cea01aa7e1bfac7912b4ed17b7d19b5ba10221fa93bc3f8.json index f58787791b..658407a43b 100644 --- a/apps/labrinth/.sqlx/query-827534d191739cd2ea502142a65f62a052715c7441952ee4ee182afde07c44d7.json +++ b/apps/labrinth/.sqlx/query-41ec69f790366c741cea01aa7e1bfac7912b4ed17b7d19b5ba10221fa93bc3f8.json @@ -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" } diff --git a/apps/labrinth/.sqlx/query-78f66d65d665210fd8334d2e1e8a793ce8128f7d6b53b3b408b030bfee4b3841.json b/apps/labrinth/.sqlx/query-78f66d65d665210fd8334d2e1e8a793ce8128f7d6b53b3b408b030bfee4b3841.json new file mode 100644 index 0000000000..81d9eceb0d --- /dev/null +++ b/apps/labrinth/.sqlx/query-78f66d65d665210fd8334d2e1e8a793ce8128f7d6b53b3b408b030bfee4b3841.json @@ -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" +} diff --git a/apps/labrinth/.sqlx/query-00ef039799c162e21a8fa5ba7aec2a9044a3028f1cb34e8c9c565a670956d938.json b/apps/labrinth/.sqlx/query-bfc67441eb246ac6b155db36919a7a62e19e0dd0c87916e761175ec909add15b.json similarity index 74% rename from apps/labrinth/.sqlx/query-00ef039799c162e21a8fa5ba7aec2a9044a3028f1cb34e8c9c565a670956d938.json rename to apps/labrinth/.sqlx/query-bfc67441eb246ac6b155db36919a7a62e19e0dd0c87916e761175ec909add15b.json index 473c606b00..658d3860d9 100644 --- a/apps/labrinth/.sqlx/query-00ef039799c162e21a8fa5ba7aec2a9044a3028f1cb34e8c9c565a670956d938.json +++ b/apps/labrinth/.sqlx/query-bfc67441eb246ac6b155db36919a7a62e19e0dd0c87916e761175ec909add15b.json @@ -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" } diff --git a/apps/labrinth/src/routes/internal/attribution.rs b/apps/labrinth/src/routes/internal/attribution.rs index cdd4b0e440..b12e7e931d 100644 --- a/apps/labrinth/src/routes/internal/attribution.rs +++ b/apps/labrinth/src/routes/internal/attribution.rs @@ -13,7 +13,7 @@ use crate::database::models::{ }, }; 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 xredis::RedisPool; 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, +} + +/// 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, redis: web::Data, session_queue: web::Data, - path: web::Path, + web::Json(body): web::Json, ) -> 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::>(); + + 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, + redis: web::Data, + session_queue: web::Data, + web::Json(body): web::Json, +) -> 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::>(); + + 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, +) -> 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")?; diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs index c8af6becc8..872593aaf1 100644 --- a/apps/labrinth/src/routes/internal/mod.rs +++ b/apps/labrinth/src/routes/internal/mod.rs @@ -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, diff --git a/packages/api-client/src/modules/labrinth/attribution/internal.ts b/packages/api-client/src/modules/labrinth/attribution/internal.ts index d431d2144c..c876f7aecd 100644 --- a/packages/api-client/src/modules/labrinth/attribution/internal.ts +++ b/packages/api-client/src/modules/labrinth/attribution/internal.ts @@ -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 { - const numericId = decodeBase62Id(groupId) - return this.client.request(`/attribution/group/${numericId}`, { + public async deleteGroups(groupIds: string[]): Promise { + const body: Labrinth.Attribution.Internal.DeleteGroupsRequest = { groups: groupIds } + return this.client.request('/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 { + const body: Labrinth.Attribution.Internal.DeleteAllGroupsRequest = { + project_id: projectId, + } + return this.client.request('/attribution/all-groups', { + api: 'labrinth', + version: 'internal', + method: 'DELETE', + body, }) } diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts index 9789cc6df0..c3c7155c43 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -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 diff --git a/packages/ui/src/components/external_files/ExternalProjectPermissionsCard.vue b/packages/ui/src/components/external_files/ExternalProjectPermissionsCard.vue index aa413d115e..bb26531235 100644 --- a/packages/ui/src/components/external_files/ExternalProjectPermissionsCard.vue +++ b/packages/ui/src/components/external_files/ExternalProjectPermissionsCard.vue @@ -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('addToGlobalModalRef') const addToExistingModalRef = useTemplateRef('addToExistingModalRef') +const deleteGroupModalRef = useTemplateRef>('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(() => {
+ +

+ {{ formatMessage(messages.removeGroupShiftHint) }} +

+