feat(phase2): Complete Management Portal with reusable ConsentCheckboxes

Phase 2 Frontend completed (Tasks 12-17, 19-20) - 14. Nov 2025

Backend Enhancements:
- Enhanced PUT /api/manage/:token/consents to support creating new consents
- INSERT new consent row when restoring consent for platform not selected during upload
- Enables granting consents for previously unselected platforms

Frontend Refactoring (Code Deduplizierung):
- Extended ConsentCheckboxes component for both modes (upload & manage)
- Removed ~150 lines of duplicated consent UI code from ManagementPortalPage
- New mode prop: 'upload' (default) | 'manage'
- Dynamic hint texts and validation rules based on mode
- Workshop consent required only in upload mode

ManagementPortalPage Updates:
- Replaced custom consent UI with reusable ConsentCheckboxes component
- New state currentConsents tracks checkbox values
- New handler handleConsentChange() computes changes vs original
- Local change collection with batch save on button click
- Email link for social media post deletion (mailto workaround)
- Save/Discard buttons only visible when pending changes exist

ConsentBadges Fix:
- Now correctly displays only active (non-revoked) consents
- Updates properly after consent revocation

Documentation:
- Updated FEATURE_PLAN with Phase 2 Frontend completion status
- Added refactoring section documenting code deduplizierung
- Updated README with Management Portal features
- Documented email backend solution requirement (future work)

Results:
 100% consistent UI between upload and management
 Zero code duplication for consent handling
 ConsentBadges correctly filters revoked consents
 Backend supports granting new consents after upload
 Management link displayed on upload success page
 All manual tests passed

Tasks Completed:
- Task 12: Management Portal UI (/manage/:token)
- Task 13: Consent Management (revoke/restore)
- Task 14: Metadata Editor (title/description)
- Task 15: Image Management (add/delete)
- Task 16: Group Deletion (with confirmation)
- Task 17: Upload Success Page (management link)
- Task 19: Documentation updates
- Task 20: nginx routing configuration

Pending:
- Task 18: E2E Testing (formal test suite)
This commit is contained in:
Matthias Lotz 2025-11-14 14:38:03 +01:00
parent e065f2bbc4
commit 324c46d735
6 changed files with 458 additions and 275 deletions

View File

@ -28,15 +28,18 @@ This project extends the original [Image-Uploader by vallezw](https://github.com
- Consent badges and filtering in moderation panel
- CSV/JSON export for legal documentation
- Group ID tracking for consent withdrawal requests
- **🔑 Self-Service Management Portal** (Phase 2 Backend Complete - Nov 11):
- **🔑 Self-Service Management Portal** (Phase 2 Complete - Nov 11-14):
- Secure UUID-based management tokens for user self-service
- Token-based API for consent revocation and metadata editing
- Frontend portal at `/manage/:token` for consent management
- Revoke/restore consents for workshop and social media
- Edit metadata (title, description) after upload
- Add/delete images after upload (with moderation re-approval)
- Complete group deletion with audit trail
- Reusable ConsentCheckboxes component (no code duplication)
- Email link for social media post deletion requests
- IP-based rate limiting (10 requests/hour)
- Brute-force protection (20 failed attempts → 24h ban)
- Management audit log for security tracking
- Frontend portal coming soon (Tasks 12-18)
- **<EFBFBD> Slideshow Optimization**: Intelligent image preloading eliminates loading delays and duplicate images
- **📅 Chronological Display**: Slideshows now play in chronological order (year → upload date)
- **Automatic Cleanup**: Unapproved groups are automatically deleted after 7 days
@ -108,9 +111,26 @@ docker compose -f docker/dev/docker-compose.yml up -d
- ✅ **Workshop Display**: Required consent to display images on local monitor
- ☐ **Social Media** (optional): Per-platform consent for Facebook, Instagram, TikTok
5. Click "Upload Images" to process the batch
6. Receive your **Group ID** as reference for future contact
6. Receive your **Group ID** and **Management Link** as reference
7. Images are grouped and await moderation approval
### Self-Service Management Portal
After upload, users receive a unique management link (`/manage/:token`) to:
- **View Upload**: See all images and metadata
- **Manage Consents**: Revoke or restore workshop/social media consents
- **Edit Metadata**: Update title, description, year (triggers re-moderation)
- **Manage Images**: Add new images or delete existing ones
- **Delete Group**: Complete removal with double-confirmation
- **Email Contact**: Request deletion of already published social media posts
**Security Features**:
- No authentication required (token-based access)
- Rate limiting: 10 requests per hour per IP
- Brute-force protection: 20 failed attempts → 24h ban
- Complete audit trail of all management actions
### Slideshow Mode
- **Automatic Access**: Navigate to `http://localhost/slideshow`

View File

@ -160,9 +160,40 @@ router.put('/:token/consents', async (req, res) => {
const socialMediaRepo = new SocialMediaRepository(dbManager);
if (action === 'revoke') {
await socialMediaRepo.revokeConsent(groupData.groupId, platformId);
// Check if consent exists before revoking
const existing = await dbManager.get(
'SELECT id FROM group_social_media_consents WHERE group_id = ? AND platform_id = ?',
[groupData.groupId, platformId]
);
if (existing) {
await socialMediaRepo.revokeConsent(groupData.groupId, platformId);
} else {
// Can't revoke what doesn't exist - return error
return res.status(400).json({
success: false,
error: 'Cannot revoke consent that was never granted'
});
}
} else {
await socialMediaRepo.restoreConsent(groupData.groupId, platformId);
// action === 'restore'
// Check if consent exists
const existing = await dbManager.get(
'SELECT id, revoked FROM group_social_media_consents WHERE group_id = ? AND platform_id = ?',
[groupData.groupId, platformId]
);
if (existing) {
// Restore existing consent
await socialMediaRepo.restoreConsent(groupData.groupId, platformId);
} else {
// Create new consent (user wants to grant consent for a platform they didn't select during upload)
await dbManager.run(
`INSERT INTO group_social_media_consents (group_id, platform_id, consented, consent_timestamp)
VALUES (?, ?, 1, CURRENT_TIMESTAMP)`,
[groupData.groupId, platformId]
);
}
}
return res.json({

View File

@ -5,7 +5,7 @@
**Feature**: Einwilligungsverwaltung für Bildveröffentlichung in Werkstatt und Social Media
**Ziel**: Rechtskonforme Einholung und Verwaltung von Nutzerzustimmungen für die Anzeige von Bildern in der Werkstatt und Veröffentlichung auf Social Media Plattformen
**Priorität**: High (Rechtliche Anforderung)
**Status**: ✅ Phase 1 komplett (9-10. Nov 2025) | ✅ Phase 2 Backend komplett (11. Nov 2025)
**Status**: ✅ Phase 1 komplett (9-10. Nov 2025) | ✅ Phase 2 Backend komplett (11. Nov 2025) | ✅ Phase 2 Frontend komplett (13-14. Nov 2025)
**API-Endpoints**:
- ✅ `GET /api/social-media/platforms` - Liste aktiver Social Media Plattformen
- ✅ `POST /api/groups/:groupId/consents` - Consents speichern
@ -1066,23 +1066,23 @@ MANAGEMENT_TOKEN_EXPIRY=90
- ✅ Task 10: Management Audit-Log (Migration 007, Repository, Admin-Endpoints)
- ✅ Task 11: Widerruf-Verhalten validiert (Workshop: display_in_workshop=0, Social Media: revoked=1)
**Frontend (Tasks 12-18) - ⏳ IN ARBEIT (13. Nov 2025)**:
**Frontend (Tasks 12-18) - ✅ KOMPLETT (14. Nov 2025)**:
- ✅ Task 12: Management Portal Grundgerüst (/manage/:token Route) - KOMPLETT
- Task 13: Consent-Management UI (Widerruf/Wiederherstellen) - KOMPLETT (in Task 12 integriert)
- Task 14: Metadata-Edit UI (Titel/Beschreibung ändern) - KOMPLETT (in Task 12 integriert)
- Task 15: Bilder-Management UI (Hinzufügen/Löschen) - KOMPLETT (in Task 12 integriert)
- Task 16: Gruppe löschen UI (mit Bestätigung) - KOMPLETT (in Task 12 integriert)
- ⏳ Task 17: Upload-Erfolgsseite (Management-Link prominent anzeigen)
- Task 13: Consent-Management UI (Widerruf/Wiederherstellen) - KOMPLETT
- Task 14: Metadata-Edit UI (Titel/Beschreibung ändern) - KOMPLETT
- Task 15: Bilder-Management UI (Hinzufügen/Löschen) - KOMPLETT
- Task 16: Gruppe löschen UI (mit Bestätigung) - KOMPLETT
- ✅ Task 17: Upload-Erfolgsseite (Management-Link prominent angezeigt)
- ⏳ Task 18: E2E Testing (alle Flows testen)
**Dokumentation & Deployment (Tasks 19-20) - ⏳ IN ARBEIT (13. Nov 2025)**:
- Task 19: Dokumentation aktualisieren
**Dokumentation & Deployment (Tasks 19-20) - ✅ KOMPLETT (14. Nov 2025)**:
- Task 19: Dokumentation aktualisieren
- ✅ Task 20: nginx Konfiguration (/api/manage/* Routing) - KOMPLETT
**Zeitaufwand Phase 2**:
- Backend: 1 Tag (11. Nov 2025) - ✅ komplett
- Frontend Tasks 12 & 20: 1 Tag (13. Nov 2025) - ✅ komplett
- Testing & Deployment: Geplant ~1 Tag
- Frontend Tasks 12-16 & 20: 2 Tage (13-14. Nov 2025) - ✅ komplett
- Testing & Deployment: Tasks 17-18 geplant ~0.5 Tag
## 🐛 Bekannte Issues & Fixes
@ -1291,6 +1291,50 @@ MANAGEMENT_TOKEN_EXPIRY=90
---
### Phase 2 Frontend Refactoring (14. Nov 2025)
**Ziel**: Code-Deduplizierung durch Wiederverwendung der `ConsentCheckboxes` Komponente
**Problem**:
- ManagementPortalPage hatte komplett eigene Consent-UI (Buttons, Chips, Status-Anzeige)
- ConsentCheckboxes wurde nur beim Upload verwendet
- ~150 Zeilen duplizierter UI-Code für die gleiche Funktionalität
- User-Feedback: "Warum haben wir beim Upload eine andere GUI als beim ManagementPortalPage.js obwohl ich ausdrücklich auf Wiederverwendung hingewiesen habe?"
**Lösung**:
- ✅ **ConsentCheckboxes erweitert** für beide Modi (`mode='upload'` | `mode='manage'`)
- Neue Props: `mode`, `groupId`
- Dynamische Hinweis-Texte je nach Modus
- Werkstatt-Pflichtfeld nur im Upload-Modus
- Widerrufs-Hinweis nur im Upload-Modus
- ✅ **ManagementPortalPage refactored**:
- Custom Consent-UI komplett entfernt (~150 Zeilen gelöscht)
- Ersetzt durch `<ConsentCheckboxes mode="manage" .../>`
- Neuer State `currentConsents` - speichert Checkbox-Zustände
- Neue Funktion `handleConsentChange()` - berechnet Änderungen vs. Original
- Speicher-Button-Sektion separat (nur bei pending changes sichtbar)
- Email-Link für Social Media Widerruf unterhalb der Checkboxen
- ✅ **ConsentBadges gefixed**:
- Filter für Social Media Consents: `consented && !revoked`
- Zeigt nur **aktive** (nicht-widerrufene) Consents an
- Aktualisiert sich korrekt nach Consent-Widerruf
**Ergebnis**:
- ✅ Gleiche UI für Upload und Management (100% konsistent)
- ✅ ~150 Zeilen Code eliminiert
- ✅ Keine Duplikation mehr
- ✅ Wartbarkeit verbessert (nur eine Komponente zu pflegen)
- ✅ ConsentBadges zeigt korrekten Status nach Änderungen
**Geänderte Dateien**:
- `frontend/src/Components/ComponentUtils/MultiUpload/ConsentCheckboxes.js` - Mode-Support hinzugefügt
- `frontend/src/Components/Pages/ManagementPortalPage.js` - Custom UI entfernt, ConsentCheckboxes integriert
- `frontend/src/Components/ComponentUtils/ConsentBadges.js` - Filter für revoked Consents
---
**Management Portal APIs** (alle getestet):
- ✅ `GET /api/manage/:token` - Token validieren & Gruppendaten laden
- ✅ `PUT /api/manage/:token/consents` - Consents widerrufen/wiederherstellen
@ -1336,7 +1380,28 @@ MANAGEMENT_TOKEN_EXPIRY=90
- Nutzt vorhandene Datenbank-Infrastruktur
- Integration in bestehendes Moderation-Panel
## 📚 Referenzen
## <20> Bekannte Einschränkungen & Verbesserungsvorschläge
### mailto: Link Problem (14. Nov 2025)
**Problem**: Der mailto: Link zum Kontakt für Löschung bereits veröffentlichter Social Media Posts öffnet nicht zuverlässig den nativen Mail-Client in allen Browser/OS-Kombinationen.
**Aktueller Workaround**: Einfacher HTML `<a href="mailto:...">` Link mit vereinfachtem Body-Text (keine Zeilenumbrüche).
**Geplante Lösung**:
- **E-Mail Backend-Service** implementieren
- Backend-Endpoint: `POST /api/manage/:token/request-deletion`
- Payload: `{ platforms: ['facebook', 'instagram'], message: string }`
- Backend sendet E-Mail via `nodemailer` an it@hobbyhimmel.de
- Vorteile:
- Unabhängig von Browser/OS Mail-Client Konfiguration
- Bessere Nachverfolgbarkeit (Audit-Log)
- Strukturierte E-Mail-Vorlage mit allen relevanten Infos (Gruppen-ID, Plattformen, Timestamp)
- User-Feedback (Bestätigung dass Anfrage eingegangen ist)
- Spam-Schutz & Rate-Limiting möglich
**Priorität**: Medium (funktionaler Workaround vorhanden, aber UX nicht optimal)
## <20>📚 Referenzen
- [DSGVO Art. 7 - Bedingungen für die Einwilligung](https://dsgvo-gesetz.de/art-7-dsgvo/)
- [Material-UI Checkbox Documentation](https://mui.com/material-ui/react-checkbox/)

View File

@ -32,29 +32,31 @@ const ConsentBadges = ({ group }) => {
</Tooltip>
);
// Social media consent badges
const socialMediaBadges = group.socialMediaConsents?.map(consent => {
const IconComponent = ICON_MAP[consent.icon_name] || CheckCircleIcon;
return (
<Tooltip
key={consent.platform_id}
title={`${consent.display_name} Consent am ${new Date(consent.consent_timestamp).toLocaleString('de-DE')}`}
arrow
>
<Chip
icon={<IconComponent />}
label={consent.display_name}
size="small"
variant="outlined"
sx={{
borderColor: '#2196F3',
color: '#2196F3',
'& .MuiChip-icon': { color: '#2196F3' }
}}
/>
</Tooltip>
);
});
// Social media consent badges - only show active (not revoked) consents
const socialMediaBadges = group.socialMediaConsents
?.filter(consent => consent.consented && !consent.revoked)
.map(consent => {
const IconComponent = ICON_MAP[consent.icon_name] || CheckCircleIcon;
return (
<Tooltip
key={consent.platform_id}
title={`${consent.display_name} Consent am ${new Date(consent.consent_timestamp).toLocaleString('de-DE')}`}
arrow
>
<Chip
icon={<IconComponent />}
label={consent.display_name}
size="small"
variant="outlined"
sx={{
borderColor: '#2196F3',
color: '#2196F3',
'& .MuiChip-icon': { color: '#2196F3' }
}}
/>
</Tooltip>
);
});
// If no consents at all, show nothing or a neutral indicator
if (!group.display_in_workshop && (!group.socialMediaConsents || group.socialMediaConsents.length === 0)) {

View File

@ -25,8 +25,21 @@ const ICON_MAP = {
* GDPR-konforme Einwilligungsabfrage für Bildveröffentlichung
* - Pflicht: Werkstatt-Anzeige Zustimmung
* - Optional: Social Media Plattform-Zustimmungen
*
* @param {Object} props
* @param {Function} props.onConsentChange - Callback wenn sich Consents ändern
* @param {Object} props.consents - Aktueller Consent-Status
* @param {boolean} props.disabled - Ob Checkboxen deaktiviert sind
* @param {string} props.mode - 'upload' (default) oder 'manage' (für Management Portal)
* @param {string} props.groupId - Gruppen-ID (nur für 'manage' Modus)
*/
function ConsentCheckboxes({ onConsentChange, consents, disabled = false }) {
function ConsentCheckboxes({
onConsentChange,
consents,
disabled = false,
mode = 'upload',
groupId = null
}) {
const [platforms, setPlatforms] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@ -83,6 +96,9 @@ function ConsentCheckboxes({ onConsentChange, consents, disabled = false }) {
return consents.socialMediaConsents?.some(c => c.platformId === platformId) || false;
};
const isManageMode = mode === 'manage';
const isUploadMode = mode === 'upload';
return (
<Paper
sx={{
@ -96,22 +112,31 @@ function ConsentCheckboxes({ onConsentChange, consents, disabled = false }) {
{/* Aufklärungshinweis */}
<Alert severity="info" icon={<InfoIcon />} sx={{ mb: 3 }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>
Wichtiger Hinweis
</Typography>
<Typography variant="body2">
Alle hochgeladenen Inhalte werden vom Hobbyhimmel-Team geprüft, bevor sie
angezeigt oder veröffentlicht werden. Wir behalten uns vor, Inhalte nicht
zu zeigen oder rechtswidrige Inhalte zu entfernen.
</Typography>
<Typography variant="body2" sx={{ mt: 1 }}>
Nach dem Upload erhalten Sie eine Gruppen-ID als Referenz für die Kontaktaufnahme.
{isUploadMode ? 'Wichtiger Hinweis' : 'Einwilligungen verwalten'}
</Typography>
{isUploadMode ? (
<>
<Typography variant="body2">
Alle hochgeladenen Inhalte werden vom Hobbyhimmel-Team geprüft, bevor sie
angezeigt oder veröffentlicht werden. Wir behalten uns vor, Inhalte nicht
zu zeigen oder rechtswidrige Inhalte zu entfernen.
</Typography>
<Typography variant="body2" sx={{ mt: 1 }}>
Nach dem Upload erhalten Sie eine Gruppen-ID als Referenz für die Kontaktaufnahme.
</Typography>
</>
) : (
<Typography variant="body2">
Sie können Ihre Einwilligungen jederzeit widerrufen oder erteilen.
Änderungen werden erst nach dem Speichern übernommen.
</Typography>
)}
</Alert>
{/* Pflicht-Zustimmung: Werkstatt-Anzeige */}
<Box sx={{ mb: 3 }}>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600, color: '#333' }}>
Anzeige in der Werkstatt *
Anzeige in der Werkstatt {isUploadMode && '*'}
</Typography>
<FormControlLabel
control={
@ -119,7 +144,7 @@ function ConsentCheckboxes({ onConsentChange, consents, disabled = false }) {
checked={consents.workshopConsent || false}
onChange={handleWorkshopChange}
disabled={disabled}
required
required={isUploadMode}
sx={{
color: '#4CAF50',
'&.Mui-checked': { color: '#4CAF50' }
@ -131,7 +156,8 @@ function ConsentCheckboxes({ onConsentChange, consents, disabled = false }) {
Ich willige ein, dass meine hochgeladenen Bilder auf dem Monitor in
der offenen Werkstatt des Hobbyhimmels angezeigt werden dürfen.
Die Bilder sind nur lokal im Hobbyhimmel sichtbar und werden nicht
über das Internet zugänglich gemacht. <strong>(Pflichtfeld)</strong>
über das Internet zugänglich gemacht.
{isUploadMode && <strong> (Pflichtfeld)</strong>}
</Typography>
}
/>
@ -192,13 +218,15 @@ function ConsentCheckboxes({ onConsentChange, consents, disabled = false }) {
</Box>
{/* Widerrufs-Hinweis */}
<Alert severity="info" sx={{ mt: 3 }}>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>Widerruf Ihrer Einwilligung:</strong> Sie können Ihre Einwilligung
jederzeit widerrufen. Kontaktieren Sie uns hierzu mit Ihrer Gruppen-ID unter:{' '}
<strong>it@hobbyhimmel.de</strong>
</Typography>
</Alert>
{isUploadMode && (
<Alert severity="info" sx={{ mt: 3 }}>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>Widerruf Ihrer Einwilligung:</strong> Sie können Ihre Einwilligung
jederzeit widerrufen. Kontaktieren Sie uns hierzu mit Ihrer Gruppen-ID unter:{' '}
<strong>it@hobbyhimmel.de</strong>
</Typography>
</Alert>
)}
</Paper>
);
}

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Button, Container, Box, Typography, Paper, Divider, Chip } from '@mui/material';
import { Button, Container, Box, Typography, Paper } from '@mui/material';
import Swal from 'sweetalert2/dist/sweetalert2.js';
import 'sweetalert2/src/sweetalert2.scss';
@ -11,11 +11,11 @@ import ImageGallery from '../ComponentUtils/ImageGallery';
import ImageGalleryCard from '../ComponentUtils/ImageGalleryCard';
import DescriptionInput from '../ComponentUtils/MultiUpload/DescriptionInput';
import ConsentBadges from '../ComponentUtils/ConsentBadges';
import ConsentCheckboxes from '../ComponentUtils/MultiUpload/ConsentCheckboxes';
// Icons
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import CancelIcon from '@mui/icons-material/Cancel';
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
import CancelIcon from '@mui/icons-material/Cancel';
const ManagementPortalPage = () => {
const { token } = useParams();
@ -36,11 +36,56 @@ const ManagementPortalPage = () => {
});
const [imageDescriptions, setImageDescriptions] = useState({});
const [isEditMode, setIsEditMode] = useState(false);
// Pending consent changes (collected locally before saving)
const [pendingConsentChanges, setPendingConsentChanges] = useState([]);
// Current consents (for ConsentCheckboxes component - includes pending changes)
const [currentConsents, setCurrentConsents] = useState({
workshopConsent: false,
socialMediaConsents: []
});
// All available social media platforms
const [allPlatforms, setAllPlatforms] = useState([]);
useEffect(() => {
loadGroup();
loadAllPlatforms();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
// Reset pending changes when group is reloaded
useEffect(() => {
if (group) {
setPendingConsentChanges([]);
// Initialize currentConsents from group data
const workshopStatus = group.consents?.workshopConsent || false;
const socialMediaStatus = allPlatforms.map(platform => {
const consent = group.consents?.socialMediaConsents?.find(c => c.platformId === platform.id);
const isActive = consent ? (consent.consented && !consent.revoked) : false;
return isActive ? { platformId: platform.id, consented: true } : null;
}).filter(Boolean);
setCurrentConsents({
workshopConsent: workshopStatus,
socialMediaConsents: socialMediaStatus
});
}
}, [group, allPlatforms]);
const loadAllPlatforms = async () => {
try {
const res = await fetch('/api/social-media/platforms');
if (res.ok) {
const data = await res.json();
// Backend returns array directly, not wrapped in {platforms: [...]}
setAllPlatforms(Array.isArray(data) ? data : []);
}
} catch (e) {
console.error('Error loading platforms:', e);
}
};
const loadGroup = useCallback(async () => {
try {
@ -221,141 +266,94 @@ const ManagementPortalPage = () => {
}
};
// Handle consent revocation
const handleRevokeConsent = async (consentType, platformId = null) => {
const consentName = consentType === 'workshop'
? 'Werkstatt-Anzeige'
: group.consents.socialMediaConsents.find(c => c.platformId === platformId)?.platformDisplayName || 'Social Media';
if (consentType === 'workshop') {
const result = await Swal.fire({
title: `Einwilligung widerrufen?`,
html: `Möchten Sie Ihre Einwilligung für <strong>${consentName}</strong> widerrufen?<br><br>
<small>Ihre Bilder werden aus der Werkstatt-Anzeige entfernt.</small>`,
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#d33',
cancelButtonColor: '#3085d6',
confirmButtonText: 'Ja, widerrufen',
cancelButtonText: 'Abbrechen'
});
if (!result.isConfirmed) return;
} else {
// Social Media Widerruf
const result = await Swal.fire({
title: `Einwilligung widerrufen?`,
html: `Möchten Sie Ihre Einwilligung für <strong>${consentName}</strong> widerrufen?<br><br>
<small>Ihre Bilder werden nicht mehr auf ${consentName} veröffentlicht.<br>
Bereits veröffentlichte Beiträge bleiben bestehen, aber es werden keine neuen Posts mit Ihren Bildern erstellt.</small>`,
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#d33',
cancelButtonColor: '#3085d6',
confirmButtonText: 'Ja, widerrufen',
cancelButtonText: 'Abbrechen',
footer: `<div style="font-size: 13px; color: #666;">Wenn Sie die Löschung bereits veröffentlichter Beiträge wünschen, kontaktieren Sie uns nach dem Widerruf.</div>`
});
if (!result.isConfirmed) return;
}
try {
const payload = consentType === 'workshop'
? { consentType: 'workshop', action: 'revoke' }
: { consentType: 'social_media', action: 'revoke', platformId };
const res = await fetch(`/api/manage/${token}/consents`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || 'Fehler beim Widerrufen');
}
// Erfolg - zeige Bestätigung mit Kontaktinfo für Social Media
if (consentType === 'social-media') {
const mailtoLink = `mailto:it@hobbyhimmel.de?subject=${encodeURIComponent(`Löschung Social Media Post - Gruppe ${group.groupId}`)}&body=${encodeURIComponent(`Hallo,\n\nBitte löschen Sie die bereits veröffentlichten Beiträge meiner Gruppe ${group.groupId} von ${consentName}.\n\nVielen Dank`)}`;
await Swal.fire({
icon: 'success',
title: 'Einwilligung widerrufen',
html: `Ihre Einwilligung für ${consentName} wurde widerrufen.<br><br>
<div style="background: #f5f5f5; padding: 15px; border-radius: 8px; margin-top: 15px;">
<strong>Bereits veröffentlichte Beiträge löschen?</strong><br>
<small>Kontaktieren Sie uns mit Ihrer Gruppen-ID:</small><br>
<div style="margin-top: 10px;">
<strong>Gruppen-ID:</strong> ${group.groupId}<br>
<strong>E-Mail:</strong> <span style="color: #1976d2; cursor: pointer;" onclick="window.open='${mailtoLink}'">it@hobbyhimmel.de</span>
</div>
</div>`,
confirmButtonText: 'Verstanden'
});
} else {
await Swal.fire({
icon: 'success',
title: 'Einwilligung widerrufen',
text: `Ihre Einwilligung für ${consentName} wurde widerrufen.`,
timer: 2000,
showConfirmButton: false
});
}
// Reload group to get updated consent status
await loadGroup();
} catch (error) {
console.error('Error revoking consent:', error);
Swal.fire({
icon: 'error',
title: 'Fehler',
text: error.message || 'Einwilligung konnte nicht widerrufen werden'
// Handle consent changes from ConsentCheckboxes component
const handleConsentChange = (newConsents) => {
setCurrentConsents(newConsents);
if (!group) return;
const changes = [];
// Check workshop consent change
const originalWorkshop = group.consents?.workshopConsent || false;
if (newConsents.workshopConsent !== originalWorkshop) {
changes.push({
consentType: 'workshop',
action: newConsents.workshopConsent ? 'restore' : 'revoke',
platformId: null
});
}
// Check social media consent changes
allPlatforms.forEach(platform => {
const consent = group.consents?.socialMediaConsents?.find(c => c.platformId === platform.id);
const originalStatus = consent ? (consent.consented && !consent.revoked) : false;
const newStatus = newConsents.socialMediaConsents?.some(c => c.platformId === platform.id) || false;
if (newStatus !== originalStatus) {
changes.push({
consentType: 'social_media',
action: newStatus ? 'restore' : 'revoke',
platformId: platform.id
});
}
});
setPendingConsentChanges(changes);
};
// Handle consent restoration
const handleRestoreConsent = async (consentType, platformId = null) => {
const consentName = consentType === 'workshop'
? 'Werkstatt-Anzeige'
: group.consents.socialMediaConsents.find(c => c.platformId === platformId)?.platformDisplayName || 'Social Media';
const result = await Swal.fire({
title: `Einwilligung wiederherstellen?`,
html: `Möchten Sie Ihre Einwilligung für <strong>${consentName}</strong> wiederherstellen?`,
icon: 'question',
showCancelButton: true,
confirmButtonColor: '#28a745',
cancelButtonColor: '#6c757d',
confirmButtonText: 'Ja, wiederherstellen',
cancelButtonText: 'Abbrechen'
// Handle consent revocation (collect locally, don't save yet)
const handleRevokeConsent = (consentType, platformId = null) => {
const change = { consentType, action: 'revoke', platformId };
setPendingConsentChanges(prev => {
// Remove any previous change for the same consent
const filtered = prev.filter(c =>
!(c.consentType === consentType && c.platformId === platformId)
);
return [...filtered, change];
});
};
if (!result.isConfirmed) return;
// Handle consent restoration (collect locally, don't save yet)
const handleRestoreConsent = (consentType, platformId = null) => {
const change = { consentType, action: 'restore', platformId };
setPendingConsentChanges(prev => {
// Remove any previous change for the same consent
const filtered = prev.filter(c =>
!(c.consentType === consentType && c.platformId === platformId)
);
return [...filtered, change];
});
};
// Save all pending consent changes
const handleSaveConsentChanges = async () => {
if (pendingConsentChanges.length === 0) return;
setSaving(true);
try {
const payload = consentType === 'workshop'
? { consentType: 'workshop', action: 'restore' }
: { consentType: 'social_media', action: 'restore', platformId };
// Send all changes to backend
for (const change of pendingConsentChanges) {
const payload = change.consentType === 'workshop'
? { consentType: 'workshop', action: change.action }
: { consentType: 'social_media', action: change.action, platformId: change.platformId };
const res = await fetch(`/api/manage/${token}/consents`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const res = await fetch(`/api/manage/${token}/consents`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || 'Fehler beim Wiederherstellen');
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || 'Fehler beim Speichern');
}
}
await Swal.fire({
icon: 'success',
title: 'Einwilligung wiederhergestellt',
text: `Ihre Einwilligung für ${consentName} wurde wiederhergestellt.`,
title: 'Änderungen gespeichert',
text: 'Ihre Einwilligungsänderungen wurden erfolgreich gespeichert.',
timer: 2000,
showConfirmButton: false
});
@ -364,14 +362,58 @@ const ManagementPortalPage = () => {
await loadGroup();
} catch (error) {
console.error('Error restoring consent:', error);
console.error('Error saving consent changes:', error);
Swal.fire({
icon: 'error',
title: 'Fehler',
text: error.message || 'Einwilligung konnte nicht wiederhergestellt werden'
text: error.message || 'Änderungen konnten nicht gespeichert werden'
});
} finally {
setSaving(false);
}
};
// Helper: Get effective consent status considering pending changes
const getEffectiveConsentStatus = (consentType, platformId = null) => {
// Check if there's a pending change for this consent
const pendingChange = pendingConsentChanges.find(c =>
c.consentType === consentType && c.platformId === platformId
);
if (pendingChange) {
return pendingChange.action === 'restore'; // true if restoring, false if revoking
}
// No pending change, return current status
if (consentType === 'workshop') {
return group?.consents?.workshopConsent || false;
} else if (consentType === 'social_media') {
const consent = group?.consents?.socialMediaConsents?.find(c => c.platformId === platformId);
return consent ? (consent.consented && !consent.revoked) : false;
}
return false;
};
// Helper: Generate mailto link for revoked social media consents
const getMailtoLink = () => {
if (!group) return '';
const revokedPlatforms = pendingConsentChanges
.filter(c => c.consentType === 'social_media' && c.action === 'revoke')
.map(c => {
// Look up platform name in allPlatforms (works even if consent was never granted)
const platform = allPlatforms.find(p => p.id === c.platformId);
return platform?.display_name || 'Unbekannte Plattform';
});
if (revokedPlatforms.length === 0) return '';
const subject = `Löschung Social Media Posts - Gruppe ${group.groupId}`;
const body = `Hallo, ich habe die Einwilligung zur Veröffentlichung auf folgenden Plattformen widerrufen: ${revokedPlatforms.join(', ')}. Bitte löschen Sie die bereits veröffentlichten Beiträge meiner Gruppe ${group.groupId}. Vielen Dank`;
return `mailto:it@hobbyhimmel.de?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
};
// Handle group deletion
const handleDeleteGroup = async () => {
@ -527,96 +569,91 @@ const ManagementPortalPage = () => {
{/* Consent Management Section */}
{group.consents && (
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600 }}>
Einwilligungen verwalten
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Sie können Ihre Einwilligungen jederzeit widerrufen oder wiederherstellen.
</Typography>
<>
<ConsentCheckboxes
onConsentChange={handleConsentChange}
consents={currentConsents}
disabled={saving}
mode="manage"
groupId={group.groupId}
/>
<Divider sx={{ my: 2 }} />
{/* Workshop Consent */}
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Werkstatt-Anzeige
</Typography>
{group.consents.workshopConsent ? (
<Chip label="Erteilt" color="success" size="small" icon={<CheckCircleIcon />} />
) : (
<Chip label="Widerrufen" color="error" size="small" icon={<CancelIcon />} />
)}
</Box>
{group.consents.workshopConsent ? (
<Button
variant="outlined"
color="error"
size="small"
onClick={() => handleRevokeConsent('workshop')}
>
Widerrufen
</Button>
) : (
<Button
variant="outlined"
color="success"
size="small"
onClick={() => handleRestoreConsent('workshop')}
>
Wiederherstellen
</Button>
)}
</Box>
</Box>
{/* Social Media Consents */}
{group.consents.socialMediaConsents && group.consents.socialMediaConsents.length > 0 && (
<>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 600 }}>
Social Media Plattformen:
{/* Save Changes Section (only if there are pending changes) */}
{pendingConsentChanges.length > 0 && (
<Paper sx={{ p: 3, mb: 3, bgcolor: '#fff3cd' }}>
<Typography variant="body2" sx={{ mb: 2, fontWeight: 600 }}>
Sie haben {pendingConsentChanges.length} ungespeicherte Änderung{pendingConsentChanges.length !== 1 ? 'en' : ''}
</Typography>
{group.consents.socialMediaConsents.map(consent => (
<Box key={consent.platformId} sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body1">
{consent.platformDisplayName}
</Typography>
{consent.consented && !consent.revoked ? (
<Chip label="Erteilt" color="success" size="small" icon={<CheckCircleIcon />} />
) : (
<Chip label="Widerrufen" color="error" size="small" icon={<CancelIcon />} />
)}
</Box>
{consent.consented && !consent.revoked ? (
<Button
variant="outlined"
color="error"
size="small"
onClick={() => handleRevokeConsent('social-media', consent.platformId)}
>
Widerrufen
</Button>
) : (
<Button
variant="outlined"
color="success"
size="small"
onClick={() => handleRestoreConsent('social-media', consent.platformId)}
>
Wiederherstellen
</Button>
)}
</Box>
{/* Show mailto link if social media consents are being revoked */}
{getMailtoLink() && (
<Box sx={{ mb: 2, p: 2, bgcolor: '#f5f5f5', borderRadius: 1 }}>
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>Bereits veröffentlichte Social Media Beiträge löschen?</strong>
</Typography>
<Typography variant="body2" sx={{ mb: 1, fontSize: '13px' }}>
Kontaktieren Sie uns für die Löschung bereits veröffentlichter Beiträge:
</Typography>
<a
href={getMailtoLink()}
style={{
display: 'inline-block',
marginTop: '8px',
padding: '6px 16px',
color: '#1976d2',
textDecoration: 'none',
border: '1px solid #1976d2',
borderRadius: '4px',
fontSize: '14px',
fontWeight: 500,
transition: 'all 0.2s'
}}
onMouseOver={(e) => {
e.currentTarget.style.backgroundColor = '#e3f2fd';
}}
onMouseOut={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
}}
>
📧 E-Mail an it@hobbyhimmel.de
</a>
</Box>
))}
</>
)}
<Button
variant="contained"
color="primary"
onClick={handleSaveConsentChanges}
disabled={saving}
sx={{ mr: 2 }}
>
{saving ? '⏳ Speichern...' : '💾 Änderungen speichern'}
</Button>
<Button
variant="outlined"
color="secondary"
onClick={() => {
setPendingConsentChanges([]);
// Reset currentConsents to original group data
const workshopStatus = group.consents?.workshopConsent || false;
const socialMediaStatus = allPlatforms.map(platform => {
const consent = group.consents?.socialMediaConsents?.find(c => c.platformId === platform.id);
const isActive = consent ? (consent.consented && !consent.revoked) : false;
return isActive ? { platformId: platform.id, consented: true } : null;
}).filter(Boolean);
setCurrentConsents({
workshopConsent: workshopStatus,
socialMediaConsents: socialMediaStatus
});
}}
disabled={saving}
>
Verwerfen
</Button>
</Paper>
)}
</Paper>
</>
)}
{/* Image Gallery */}