feat(categories): per-category download permissions (#640 part B)

Adds an `allow_downloads` boolean to `photo_categories` so admins can
have different download policies per category — e.g. preview categories
public, originals client-only. AND's with the event-level `allow_downloads`,
so disabling at either level blocks downloads for that category's photos.

Defaults to true so categories created before migration 135 keep working
without admin intervention.

Credit: 8digit/picpeak@928164b + @751ec75.

### Backend

- **Migration 135**: additive `allow_downloads BOOLEAN NOT NULL DEFAULT true`
  on `photo_categories`, hasColumn-guarded + sane down.
- **`adminCategories.js`**: PUT /:id accepts optional `allow_downloads` patch.
- **`gallery.js`**:
  - `GET /:slug/photos` returns `allow_downloads` per category AND
    `category_allow_downloads` per photo.
  - `GET /:slug/download/:photoId` returns 403 when the photo's category
    disables downloads.
  - `GET /:slug/download-all` LEFT JOINs `photo_categories` and filters
    `whereNull(category_id) OR allow_downloads=true OR allow_downloads IS NULL`.
    The null check covers pre-migration-135 rows during the upgrade window.
  - `POST /:slug/download-selected` same filter pattern.

### Frontend

- **`categories.service.ts`**: `updateCategory()` gains an optional `patch`
  argument carrying `{ allow_downloads }`. PhotoCategory interface gains the
  optional field.
- **`EventCategoryManager.tsx`**: new toggle button next to the delete X.
  Green DownloadCloud icon when downloads are on, plain Download icon when
  off. Click toggles via the new mutation; toast confirms.
- **`PhotoLightbox.tsx`**: `photoAllowsDownload = allowDownloads && currentPhoto?.category_allow_downloads !== false`. Hides the download button +
  blocks the 'D' keyboard shortcut + early-returns from handleDownload.
- **Types**: Photo interface gains `category_allow_downloads`.
- **i18n**: 5 new EN + DE entries for the toggle button toast + tooltip.

No global-category surface change yet — global categories don't currently
have a UI for the toggle. Admins can still flip the column directly via SQL
or via a future global-categories editor.

### Test plan

- [x] Backend syntax + TS check clean
- [x] ESLint: no new warnings
- [ ] Manual: admin → event detail → categories panel → click DownloadCloud
      icon → category flips, toast confirms
- [ ] Manual: gallery (guest) → photo in disabled category → lightbox shows
      no download button, 'D' shortcut is a no-op
- [ ] Manual: download-all on a gallery with one disabled category →
      ZIP excludes that category's photos
- [ ] Manual: download-selected including a disabled-category photo → 404
      (filtered out) and the response carries only the allowed selection
- [ ] Manual: pre-migration-135 category (legacy row with NULL allow_downloads)
      → downloads still work (defaults true via fallback)
This commit is contained in:
Paul Nothaft
2026-06-18 22:29:35 +02:00
parent e4e79a0b3a
commit 820f4835f1
9 changed files with 181 additions and 26 deletions
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, X, Loader2, Image as ImageIcon, Check } from 'lucide-react';
import { Plus, X, Loader2, Image as ImageIcon, Check, Download, DownloadCloud } from 'lucide-react';
import { toast } from 'react-toastify';
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
import { photosService } from '../../services/photos.service';
@@ -79,6 +79,25 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
},
});
// Toggle per-category download permission (#640). The backend AND's this
// with the event-level `allow_downloads`, so disabling at either level
// blocks downloads for this category's photos.
const downloadToggleMutation = useMutation({
mutationFn: ({ category, allow }: { category: PhotoCategory; allow: boolean }) =>
categoriesService.updateCategory(category.id, category.name, { allow_downloads: allow }),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] });
toast.success(
variables.allow
? t('categories.downloadsEnabled', 'Downloads enabled for this category')
: t('categories.downloadsDisabled', 'Downloads disabled for this category')
);
},
onError: (error: any) => {
toast.error(error.response?.data?.error || t('categories.failedToToggleDownloads', 'Failed to update download permission'));
},
});
const handleCreate = () => {
if (newCategoryName.trim()) {
createMutation.mutate(newCategoryName.trim());
@@ -202,18 +221,49 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
</button>
<span className="text-sm text-neutral-700 dark:text-neutral-300 truncate">{category.name}</span>
</div>
<button
onClick={() => handleDelete(category)}
className="p-1 text-neutral-400 dark:text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
title={t('categories.deleteCategoryTitle')}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<X className="w-3 h-3" />
)}
</button>
<div className="flex items-center gap-1">
{/* Per-category downloads toggle (#640). Green DownloadCloud
icon when on, struck-through outline when off. The
event-level `allow_downloads` AND's with this — if the
whole event has downloads off, this toggle is cosmetic. */}
<button
onClick={() => downloadToggleMutation.mutate({
category,
allow: category.allow_downloads === false,
})}
className={`p-1 transition-colors ${
category.allow_downloads === false
? 'text-neutral-400 dark:text-neutral-500 hover:text-green-600 dark:hover:text-green-400'
: 'text-green-600 dark:text-green-400 hover:text-neutral-400'
}`}
title={
category.allow_downloads === false
? t('categories.enableDownloadsTitle', 'Click to enable downloads for this category')
: t('categories.disableDownloadsTitle', 'Click to disable downloads for this category')
}
disabled={downloadToggleMutation.isPending}
>
{downloadToggleMutation.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : category.allow_downloads === false ? (
<Download className="w-3 h-3" />
) : (
<DownloadCloud className="w-3 h-3" />
)}
</button>
<button
onClick={() => handleDelete(category)}
className="p-1 text-neutral-400 dark:text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
title={t('categories.deleteCategoryTitle')}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<X className="w-3 h-3" />
)}
</button>
</div>
</div>
);
})}
@@ -106,6 +106,11 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
// unsupported browsers fall through to a regular <a download>.
const downloadPhotoMutation = useSavePhotoToDevice();
const currentPhoto = photos[currentIndex];
// Per-category download permission (#640). AND'd with the event-level
// allowDownloads — disabling at either level hides the download button.
// Defaults true for uncategorised photos and pre-migration-135 categories.
const photoAllowsDownload =
allowDownloads && currentPhoto?.category_allow_downloads !== false;
// DevTools protection - enabled by individual setting OR legacy protection level
const devToolsEnabled = enableDevtoolsProtection || (useEnhancedProtection && (protectionLevel === 'enhanced' || protectionLevel === 'maximum'));
@@ -171,7 +176,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
break;
case 'd':
case 'D':
if (allowDownloads) {
if (photoAllowsDownload) {
handleDownload();
}
break;
@@ -353,7 +358,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
};
const handleDownload = () => {
if (!allowDownloads) return;
if (!photoAllowsDownload) return;
downloadPhotoMutation.mutate({
slug,
photoId: currentPhoto.id,
@@ -680,7 +685,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
<div className="w-px h-6 bg-white/20 mx-2" />
{allowDownloads && (
{photoAllowsDownload && (
<button
onClick={handleDownload}
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"