From 56f0ce2f27a5a86c673aac497ceed10a9ddb65f1 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 17 Oct 2025 09:44:10 +0200 Subject: [PATCH] Implement permission management and enhance user interface for permissions in the application Add new permission routes and integrate permission checks across various existing routes to ensure proper access control. Update the UserClub model to include role and permissions fields, allowing for more granular user access management. Enhance the frontend by introducing a user dropdown menu for managing permissions and displaying relevant options based on user roles. Improve the overall user experience by implementing permission-based visibility for navigation links and actions throughout the application. --- PERMISSIONS_GUIDE.md | 210 ++++++ PERMISSIONS_MIGRATION.md | 235 +++++++ backend/controllers/clubsController.js | 2 +- backend/controllers/permissionController.js | 152 +++++ backend/middleware/authorizationMiddleware.js | 187 ++++++ .../add_permissions_to_user_club.sql | 17 + .../update_existing_user_club_permissions.sql | 38 ++ backend/models/UserClub.js | 19 + backend/routes/clubRoutes.js | 7 +- backend/routes/diaryRoutes.js | 19 +- backend/routes/matchRoutes.js | 17 +- backend/routes/memberRoutes.js | 15 +- backend/routes/myTischtennisRoutes.js | 11 +- backend/routes/permissionRoutes.js | 30 + backend/routes/predefinedActivityRoutes.js | 21 +- backend/routes/teamRoutes.js | 13 +- backend/scripts/createTestUsers.js | 141 ++++ backend/scripts/migratePermissions.js | 128 ++++ backend/scripts/quickFixOwner.js | 103 +++ backend/server.js | 2 + backend/services/clubService.js | 12 +- backend/services/permissionService.js | 366 ++++++++++ backend/services/schedulerService.js | 2 +- frontend/src/App.vue | 222 ++++-- frontend/src/composables/usePermissions.js | 66 ++ frontend/src/directives/permissions.js | 198 ++++++ frontend/src/main.js | 7 + frontend/src/router.js | 2 + frontend/src/store.js | 66 +- frontend/src/views/PendingApprovalsView.vue | 7 +- frontend/src/views/PermissionsView.vue | 631 ++++++++++++++++++ 31 files changed, 2854 insertions(+), 92 deletions(-) create mode 100644 PERMISSIONS_GUIDE.md create mode 100644 PERMISSIONS_MIGRATION.md create mode 100644 backend/controllers/permissionController.js create mode 100644 backend/middleware/authorizationMiddleware.js create mode 100644 backend/migrations/add_permissions_to_user_club.sql create mode 100644 backend/migrations/update_existing_user_club_permissions.sql create mode 100644 backend/routes/permissionRoutes.js create mode 100644 backend/scripts/createTestUsers.js create mode 100644 backend/scripts/migratePermissions.js create mode 100644 backend/scripts/quickFixOwner.js create mode 100644 backend/services/permissionService.js create mode 100644 frontend/src/composables/usePermissions.js create mode 100644 frontend/src/directives/permissions.js create mode 100644 frontend/src/views/PermissionsView.vue diff --git a/PERMISSIONS_GUIDE.md b/PERMISSIONS_GUIDE.md new file mode 100644 index 0000000..e65be02 --- /dev/null +++ b/PERMISSIONS_GUIDE.md @@ -0,0 +1,210 @@ +# Berechtigungssystem - Dokumentation + +## Übersicht + +Das Trainingstagebuch verfügt nun über ein vollständiges rollenbasiertes Berechtigungssystem (RBAC - Role-Based Access Control). Der Club-Ersteller hat automatisch Admin-Rechte und kann anderen Mitgliedern Rollen und spezifische Berechtigungen zuweisen. + +## Rollen + +### 1. Administrator (admin) +- **Vollzugriff** auf alle Funktionen +- Kann Berechtigungen anderer Benutzer verwalten +- Der Club-Ersteller ist automatisch Administrator und kann nicht degradiert werden + +### 2. Trainer (trainer) +- Kann Trainingseinheiten planen und verwalten +- Kann Mitglieder anlegen und bearbeiten +- Kann Spielpläne einsehen und bearbeiten +- Kann Turniere organisieren +- **Kann nicht**: Einstellungen ändern, Berechtigungen verwalten + +### 3. Mannschaftsführer (team_manager) +- Kann Teams und Spielpläne verwalten +- Kann Spieler für Matches einteilen +- Kann Spielergebnisse eintragen +- **Kann nicht**: Trainingseinheiten planen, Mitglieder verwalten + +### 4. Mitglied (member) +- Nur Lesezugriff auf alle Bereiche +- Kann eigene Daten einsehen +- **Kann nicht**: Daten ändern oder löschen + +## Berechtigungsbereiche + +- **diary**: Trainingstagebuch +- **members**: Mitgliederverwaltung +- **teams**: Team-Management +- **schedule**: Spielpläne +- **tournaments**: Turniere +- **statistics**: Statistiken +- **settings**: Einstellungen +- **permissions**: Berechtigungsverwaltung +- **mytischtennis**: MyTischtennis-Integration (für alle zugänglich) + +## Backend-Integration + +### Migration ausführen + +```sql +mysql -u username -p database_name < backend/migrations/add_permissions_to_user_club.sql +``` + +### Authorization Middleware verwenden + +```javascript +import { authorize, requireAdmin, requireOwner } from '../middleware/authorizationMiddleware.js'; + +// Beispiel: Nur Lesezugriff erforderlich +router.get('/diary/:clubId', authenticate, authorize('diary', 'read'), getDiary); + +// Beispiel: Schreibzugriff erforderlich +router.post('/diary/:clubId', authenticate, authorize('diary', 'write'), createDiary); + +// Beispiel: Admin-Rechte erforderlich +router.put('/settings/:clubId', authenticate, requireAdmin(), updateSettings); + +// Beispiel: Nur Owner +router.delete('/club/:clubId', authenticate, requireOwner(), deleteClub); +``` + +### Permission Service verwenden + +```javascript +import permissionService from '../services/permissionService.js'; + +// Berechtigungen prüfen +const hasPermission = await permissionService.hasPermission(userId, clubId, 'diary', 'write'); + +// Rolle setzen +await permissionService.setUserRole(userId, clubId, 'trainer', adminUserId); + +// Custom Permissions setzen +await permissionService.setCustomPermissions( + userId, + clubId, + { diary: { write: false }, members: { write: true } }, + adminUserId +); +``` + +## Frontend-Integration + +### Composable verwenden + +```vue + +``` + +### Direktiven verwenden + +```vue + +``` + +### Store verwenden + +```javascript +import { useStore } from 'vuex'; + +const store = useStore(); + +// Berechtigungen abrufen +const permissions = store.getters.currentPermissions; +const hasPermission = store.getters.hasPermission('diary', 'write'); +const isOwner = store.getters.isClubOwner; +const userRole = store.getters.userRole; + +// Berechtigungen laden (wird automatisch beim Club-Wechsel gemacht) +await store.dispatch('loadPermissions', clubId); +``` + +## Admin-UI + +Die Berechtigungsverwaltung ist unter `/permissions` verfügbar und nur für Administratoren sichtbar. + +**Funktionen:** +- Übersicht aller Clubmitglieder mit ihren Rollen +- Rollen zuweisen/ändern +- Custom Permissions für einzelne Benutzer definieren +- Erklärung der verfügbaren Rollen + +## MyTischtennis-Integration + +Die MyTischtennis-Einstellungen und -Funktionen sind für **alle Club-Mitglieder** zugänglich, unabhängig von ihrer Rolle. Dies ermöglicht es jedem, die Anbindung einzurichten und Daten abzurufen. + +## Sicherheitshinweise + +1. **Der Club-Ersteller** (Owner) kann nicht degradiert oder gelöscht werden +2. **Owner-Rechte** können nicht übertragen werden +3. **Backend-Validierung** wird immer durchgeführt, auch wenn das Frontend Elemente ausblendet +4. **Alle API-Routen** sind durch Middleware geschützt +5. **Permissions werden gecacht** im localStorage für bessere Performance + +## Beispiel-Szenarien + +### Szenario 1: Trainer hinzufügen +1. Admin öffnet `/permissions` +2. Wählt Benutzer aus +3. Ändert Rolle zu "Trainer" +4. Benutzer kann jetzt Trainingseinheiten planen + +### Szenario 2: Custom Permissions +1. Admin öffnet `/permissions` +2. Wählt Benutzer aus +3. Klickt auf "Anpassen" +4. Setzt individuelle Berechtigungen (z.B. nur Diary-Schreibrecht) +5. Speichert + +### Szenario 3: Neues Mitglied +1. Mitglied registriert sich und fordert Zugang an +2. Admin genehmigt Anfrage (Standardrolle: "member") +3. Mitglied hat Lesezugriff +4. Bei Bedarf kann Admin die Rolle später ändern + +## Troubleshooting + +**Problem**: Berechtigungen werden nicht aktualisiert +- **Lösung**: Seite neu laden oder Club neu auswählen + +**Problem**: "Keine Berechtigung" trotz korrekter Rolle +- **Lösung**: Prüfen, ob Custom Permissions die Rolle überschreiben + +**Problem**: Owner kann keine Änderungen vornehmen +- **Lösung**: Owner sollte automatisch alle Rechte haben. Prüfen Sie die `isOwner`-Flag in der Datenbank + +## API-Endpunkte + +``` +GET /api/permissions/:clubId - Eigene Berechtigungen abrufen +GET /api/permissions/:clubId/members - Alle Mitglieder mit Berechtigungen (Admin) +PUT /api/permissions/:clubId/user/:userId/role - Rolle ändern (Admin) +PUT /api/permissions/:clubId/user/:userId/permissions - Custom Permissions setzen (Admin) +GET /api/permissions/roles/available - Verfügbare Rollen abrufen +GET /api/permissions/structure/all - Berechtigungsstruktur abrufen +``` + + diff --git a/PERMISSIONS_MIGRATION.md b/PERMISSIONS_MIGRATION.md new file mode 100644 index 0000000..08336ce --- /dev/null +++ b/PERMISSIONS_MIGRATION.md @@ -0,0 +1,235 @@ +# Berechtigungssystem - Migrations-Anleitung + +## Übersicht + +Diese Anleitung hilft Ihnen, das neue Berechtigungssystem für bestehende Clubs einzurichten. + +## Schritt 1: Datenbank-Schema erweitern + +Führen Sie zuerst die SQL-Migration aus, um die neuen Spalten hinzuzufügen: + +```bash +mysql -u username -p database_name < backend/migrations/add_permissions_to_user_club.sql +``` + +Dies fügt folgende Spalten zur `user_club` Tabelle hinzu: +- `role` (VARCHAR) - Benutzerrolle (admin, trainer, team_manager, member) +- `permissions` (JSON) - Custom Permissions +- `is_owner` (BOOLEAN) - Markiert den Club-Ersteller + +## Schritt 2: Bestehende Daten migrieren + +Sie haben zwei Optionen: + +### Option A: Node.js Script (Empfohlen) + +Das Script identifiziert automatisch den ersten Benutzer jedes Clubs (nach `createdAt`) und setzt ihn als Owner. + +```bash +cd /home/torsten/Programs/trainingstagebuch/backend +node scripts/migratePermissions.js +``` + +**Ausgabe:** +``` +Starting permissions migration... + +Found 3 club(s) + +--- Club: TTC Beispiel (ID: 1) --- + Members found: 5 + First member (will be owner): admin@example.com + ✓ Updated admin@example.com: role=admin, isOwner=true + ✓ Updated user1@example.com: role=member, isOwner=false + ✓ Updated user2@example.com: role=member, isOwner=false + ... + +✅ Migration completed successfully! + +Summary: +Club Owners (3): + - TTC Beispiel: admin@example.com + - SV Teststadt: owner@test.de + - TSC Demo: demo@example.com + +Role Distribution: + - Admins: 3 + - Members: 12 +``` + +### Option B: SQL Script + +Wenn Sie lieber SQL verwenden möchten: + +```bash +mysql -u username -p database_name < backend/migrations/update_existing_user_club_permissions.sql +``` + +Dieses Script: +1. Setzt `role = 'member'` für alle genehmigten Benutzer ohne Rolle +2. Markiert den Benutzer mit der niedrigsten `user_id` pro Club als Owner + +## Schritt 3: Manuelle Anpassungen (Optional) + +### Falscher Owner? + +Falls das Script den falschen Benutzer als Owner markiert hat, können Sie dies manuell korrigieren: + +```sql +-- Alten Owner zurücksetzen +UPDATE user_club +SET is_owner = 0, role = 'member' +WHERE club_id = 1 AND user_id = 123; + +-- Neuen Owner setzen +UPDATE user_club +SET is_owner = 1, role = 'admin' +WHERE club_id = 1 AND user_id = 456; +``` + +### Weitere Admins ernennen + +```sql +UPDATE user_club +SET role = 'admin' +WHERE club_id = 1 AND user_id = 789; +``` + +### Trainer ernennen + +```sql +UPDATE user_club +SET role = 'trainer' +WHERE club_id = 1 AND user_id = 101; +``` + +## Schritt 4: Verifizierung + +### Backend neu starten + +```bash +# Server neu starten (wenn er läuft) +sudo systemctl restart tt-tagebuch +``` + +### Im Browser testen + +1. Loggen Sie sich ein +2. Wählen Sie einen Club aus +3. Navigieren Sie zu "Berechtigungen" (nur für Admins sichtbar) +4. Überprüfen Sie, dass alle Mitglieder korrekt angezeigt werden + +### SQL Verifizierung + +```sql +-- Alle Club-Mitglieder mit ihren Berechtigungen anzeigen +SELECT + c.name as club_name, + u.email as user_email, + uc.role, + uc.is_owner, + uc.approved +FROM user_club uc +JOIN club c ON c.id = uc.club_id +JOIN user u ON u.id = uc.user_id +WHERE uc.approved = 1 +ORDER BY c.name, uc.is_owner DESC, uc.role, u.email; +``` + +## Troubleshooting + +### Problem: "Keine Berechtigung" trotz Owner-Status + +**Lösung:** Überprüfen Sie in der Datenbank: + +```sql +SELECT role, is_owner, approved +FROM user_club +WHERE user_id = YOUR_USER_ID AND club_id = YOUR_CLUB_ID; +``` + +Sollte sein: `role='admin'`, `is_owner=1`, `approved=1` + +### Problem: Owner kann nicht geändert werden + +Das ist korrekt! Der Owner (Club-Ersteller) kann seine eigenen Rechte nicht verlieren. Dies ist eine Sicherheitsmaßnahme. + +### Problem: Berechtigungen werden nicht geladen + +**Lösung:** +1. Browser-Cache leeren +2. LocalStorage leeren: `localStorage.clear()` in der Browser-Console +3. Neu einloggen + +### Problem: "Lade Mitglieder..." bleibt hängen + +**Mögliche Ursachen:** +1. Migration noch nicht ausgeführt +2. Backend nicht neu gestartet +3. Frontend nicht neu gebaut + +**Lösung:** +```bash +# Backend +cd /home/torsten/Programs/trainingstagebuch/backend +node scripts/migratePermissions.js + +# Frontend +cd /home/torsten/Programs/trainingstagebuch/frontend +npm run build + +# Server neu starten +sudo systemctl restart tt-tagebuch +``` + +## Nach der Migration + +### Neue Clubs + +Bei neuen Clubs wird der Ersteller automatisch als Owner mit Admin-Rechten eingerichtet. Keine manuelle Aktion erforderlich. + +### Neue Mitglieder + +Neue Mitglieder erhalten automatisch die Rolle "member" (Lesezugriff). Admins können die Rolle später ändern. + +### Berechtigungen verwalten + +Admins können über die Web-UI unter `/permissions` Berechtigungen verwalten: +1. Rollen zuweisen (Admin, Trainer, Mannschaftsführer, Mitglied) +2. Custom Permissions definieren (für spezielle Anwendungsfälle) + +## Wichtige Hinweise + +⚠️ **Sicherung erstellen:** +```bash +mysqldump -u username -p database_name > backup_before_permissions_$(date +%Y%m%d).sql +``` + +⚠️ **Owner-Rechte:** +- Der Owner (is_owner=1) kann nicht degradiert oder gelöscht werden +- Jeder Club hat genau einen Owner +- Owner-Rechte können nicht übertragen werden (nur durch direkte DB-Änderung) + +⚠️ **MyTischtennis:** +- MyTischtennis-Funktionen sind für ALLE Mitglieder zugänglich +- Keine Berechtigungsprüfung für MyTischtennis-Endpunkte + +## Rollback (falls nötig) + +Falls Sie das Berechtigungssystem zurücknehmen müssen: + +```sql +-- Spalten entfernen (Achtung: Datenverlust!) +ALTER TABLE user_club +DROP COLUMN role, +DROP COLUMN permissions, +DROP COLUMN is_owner; + +-- Indizes entfernen +DROP INDEX idx_user_club_role ON user_club; +DROP INDEX idx_user_club_owner ON user_club; +``` + +Dann Backend-Code auf vorherige Version zurücksetzen. + + diff --git a/backend/controllers/clubsController.js b/backend/controllers/clubsController.js index 848e288..b8932ef 100644 --- a/backend/controllers/clubsController.js +++ b/backend/controllers/clubsController.js @@ -25,7 +25,7 @@ export const addClub = async (req, res) => { } const newClub = await ClubService.createClub(clubName); - await ClubService.addUserToClub(user.id, newClub.id); + await ClubService.addUserToClub(user.id, newClub.id, true); // true = isOwner res.status(200).json(newClub); } catch (error) { console.error('[addClub] - error:', error); diff --git a/backend/controllers/permissionController.js b/backend/controllers/permissionController.js new file mode 100644 index 0000000..7686023 --- /dev/null +++ b/backend/controllers/permissionController.js @@ -0,0 +1,152 @@ +import permissionService from '../services/permissionService.js'; + +/** + * Get user's permissions for a club + */ +export const getUserPermissions = async (req, res) => { + try { + const { clubId } = req.params; + const userId = req.user.id; + + const permissions = await permissionService.getUserClubPermissions(userId, parseInt(clubId)); + + if (!permissions) { + return res.status(404).json({ error: 'Keine Berechtigungen gefunden' }); + } + + res.json(permissions); + } catch (error) { + console.error('Error getting user permissions:', error); + res.status(500).json({ error: 'Fehler beim Abrufen der Berechtigungen' }); + } +}; + +/** + * Get all club members with their permissions + */ +export const getClubMembersWithPermissions = async (req, res) => { + try { + const { clubId } = req.params; + const userId = req.user.id; + + const members = await permissionService.getClubMembersWithPermissions( + parseInt(clubId), + userId + ); + + res.json(members); + } catch (error) { + console.error('Error getting club members with permissions:', error); + if (error.message === 'Keine Berechtigung zum Anzeigen von Berechtigungen') { + return res.status(403).json({ error: error.message }); + } + res.status(500).json({ error: 'Fehler beim Abrufen der Mitglieder' }); + } +}; + +/** + * Update user role + */ +export const updateUserRole = async (req, res) => { + try { + const { clubId, userId: targetUserId } = req.params; + const { role } = req.body; + const updatingUserId = req.user.id; + + const result = await permissionService.setUserRole( + parseInt(targetUserId), + parseInt(clubId), + role, + updatingUserId + ); + + res.json(result); + } catch (error) { + console.error('Error updating user role:', error); + res.status(400).json({ error: error.message }); + } +}; + +/** + * Update user custom permissions + */ +export const updateUserPermissions = async (req, res) => { + try { + const { clubId, userId: targetUserId } = req.params; + const { permissions } = req.body; + const updatingUserId = req.user.id; + + const result = await permissionService.setCustomPermissions( + parseInt(targetUserId), + parseInt(clubId), + permissions, + updatingUserId + ); + + res.json(result); + } catch (error) { + console.error('Error updating user permissions:', error); + res.status(400).json({ error: error.message }); + } +}; + +/** + * Get available roles + */ +export const getAvailableRoles = async (req, res) => { + try { + const roles = permissionService.getAvailableRoles(); + res.json(roles); + } catch (error) { + console.error('Error getting available roles:', error); + res.status(500).json({ error: 'Fehler beim Abrufen der Rollen' }); + } +}; + +/** + * Get permission structure + */ +export const getPermissionStructure = async (req, res) => { + try { + const structure = permissionService.getPermissionStructure(); + res.json(structure); + } catch (error) { + console.error('Error getting permission structure:', error); + res.status(500).json({ error: 'Fehler beim Abrufen der Berechtigungsstruktur' }); + } +}; + +/** + * Update user status (activate/deactivate) + */ +export const updateUserStatus = async (req, res) => { + try { + const { clubId, userId: targetUserId } = req.params; + const { approved } = req.body; + const updatingUserId = req.user.id; + + const result = await permissionService.setUserStatus( + parseInt(targetUserId), + parseInt(clubId), + approved, + updatingUserId + ); + + res.json(result); + } catch (error) { + console.error('Error updating user status:', error); + res.status(400).json({ error: error.message }); + } +}; + +export default { + getUserPermissions, + getClubMembersWithPermissions, + updateUserRole, + updateUserPermissions, + updateUserStatus, + getAvailableRoles, + getPermissionStructure +}; + + diff --git a/backend/middleware/authorizationMiddleware.js b/backend/middleware/authorizationMiddleware.js new file mode 100644 index 0000000..e9524d4 --- /dev/null +++ b/backend/middleware/authorizationMiddleware.js @@ -0,0 +1,187 @@ +import permissionService from '../services/permissionService.js'; + +/** + * Authorization Middleware + * Checks if user has permission to access a resource + */ + +/** + * Check if user has permission for a specific resource and action + * @param {string} resource - Resource name (diary, members, teams, etc.) + * @param {string} action - Action type (read, write, delete) + * @returns {Function} Express middleware function + */ +export const authorize = (resource, action = 'read') => { + return async (req, res, next) => { + try { + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ error: 'Nicht authentifiziert' }); + } + + // Get clubId from various possible sources + const clubId = req.params.clubId || req.params.id || req.body.clubId || req.query.clubId; + + if (!clubId) { + return res.status(400).json({ error: 'Club-ID fehlt' }); + } + + // Check permission + const hasPermission = await permissionService.hasPermission( + userId, + parseInt(clubId), + resource, + action + ); + + if (!hasPermission) { + return res.status(403).json({ + error: 'Keine Berechtigung', + details: `Fehlende Berechtigung: ${resource}.${action}` + }); + } + + // Store permissions in request for later use + const userPermissions = await permissionService.getUserClubPermissions( + userId, + parseInt(clubId) + ); + req.userPermissions = userPermissions; + + next(); + } catch (error) { + console.error('Authorization error:', error); + res.status(500).json({ error: 'Fehler bei der Berechtigungsprüfung' }); + } + }; +}; + +/** + * Check if user is club owner + * @returns {Function} Express middleware function + */ +export const requireOwner = () => { + return async (req, res, next) => { + try { + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ error: 'Nicht authentifiziert' }); + } + + const clubId = req.params.clubId || req.params.id || req.body.clubId || req.query.clubId; + + if (!clubId) { + return res.status(400).json({ error: 'Club-ID fehlt' }); + } + + const userPermissions = await permissionService.getUserClubPermissions( + userId, + parseInt(clubId) + ); + + if (!userPermissions || !userPermissions.isOwner) { + return res.status(403).json({ + error: 'Keine Berechtigung', + details: 'Nur der Club-Ersteller hat Zugriff' + }); + } + + req.userPermissions = userPermissions; + next(); + } catch (error) { + console.error('Owner check error:', error); + res.status(500).json({ error: 'Fehler bei der Berechtigungsprüfung' }); + } + }; +}; + +/** + * Check if user is admin (owner or admin role) + * @returns {Function} Express middleware function + */ +export const requireAdmin = () => { + return async (req, res, next) => { + try { + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ error: 'Nicht authentifiziert' }); + } + + const clubId = req.params.clubId || req.params.id || req.body.clubId || req.query.clubId; + + if (!clubId) { + return res.status(400).json({ error: 'Club-ID fehlt' }); + } + + const userPermissions = await permissionService.getUserClubPermissions( + userId, + parseInt(clubId) + ); + + if (!userPermissions || (userPermissions.role !== 'admin' && !userPermissions.isOwner)) { + return res.status(403).json({ + error: 'Keine Berechtigung', + details: 'Administrator-Rechte erforderlich' + }); + } + + req.userPermissions = userPermissions; + next(); + } catch (error) { + console.error('Admin check error:', error); + res.status(500).json({ error: 'Fehler bei der Berechtigungsprüfung' }); + } + }; +}; + +/** + * Check if user has any of the specified roles + * @param {string[]} roles - Array of allowed roles + * @returns {Function} Express middleware function + */ +export const requireRole = (roles) => { + return async (req, res, next) => { + try { + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ error: 'Nicht authentifiziert' }); + } + + const clubId = req.params.clubId || req.params.id || req.body.clubId || req.query.clubId; + + if (!clubId) { + return res.status(400).json({ error: 'Club-ID fehlt' }); + } + + const userPermissions = await permissionService.getUserClubPermissions( + userId, + parseInt(clubId) + ); + + if (!userPermissions || !roles.includes(userPermissions.role)) { + return res.status(403).json({ + error: 'Keine Berechtigung', + details: `Erforderliche Rolle: ${roles.join(', ')}` + }); + } + + req.userPermissions = userPermissions; + next(); + } catch (error) { + console.error('Role check error:', error); + res.status(500).json({ error: 'Fehler bei der Berechtigungsprüfung' }); + } + }; +}; + +export default { + authorize, + requireOwner, + requireAdmin, + requireRole +}; + diff --git a/backend/migrations/add_permissions_to_user_club.sql b/backend/migrations/add_permissions_to_user_club.sql new file mode 100644 index 0000000..75d488b --- /dev/null +++ b/backend/migrations/add_permissions_to_user_club.sql @@ -0,0 +1,17 @@ +-- Add role and permissions columns to user_club table +ALTER TABLE `user_club` +ADD COLUMN `role` VARCHAR(50) DEFAULT 'member' COMMENT 'User role: admin, trainer, team_manager, member' AFTER `approved`, +ADD COLUMN `permissions` JSON NULL COMMENT 'Specific permissions: {diary: {read: true, write: true}, members: {...}, ...}' AFTER `role`, +ADD COLUMN `is_owner` BOOLEAN DEFAULT FALSE COMMENT 'True if user created the club' AFTER `permissions`; + +-- Create index for faster role lookups +CREATE INDEX `idx_user_club_role` ON `user_club` (`role`); +CREATE INDEX `idx_user_club_owner` ON `user_club` (`is_owner`); + +-- Set existing approved users as members +UPDATE `user_club` SET `role` = 'member' WHERE `approved` = 1 AND `role` IS NULL; + +-- If there's a user who created the club (we need to identify them somehow) +-- For now, we'll need to manually set the owner after migration + + diff --git a/backend/migrations/update_existing_user_club_permissions.sql b/backend/migrations/update_existing_user_club_permissions.sql new file mode 100644 index 0000000..0eee5cb --- /dev/null +++ b/backend/migrations/update_existing_user_club_permissions.sql @@ -0,0 +1,38 @@ +-- Update existing user_club entries with default permissions +-- This migration sets default values for role and is_owner for existing club memberships + +-- Set default role to 'member' for all approved users who don't have a role yet +UPDATE `user_club` +SET `role` = 'member' +WHERE `approved` = 1 + AND (`role` IS NULL OR `role` = ''); + +-- Optionally: Set the first approved user of each club as owner +-- This finds the user with the lowest user_id per club (oldest member) and marks them as owner +UPDATE `user_club` AS uc1 +INNER JOIN ( + SELECT `club_id`, MIN(`user_id`) as `first_user_id` + FROM `user_club` + WHERE `approved` = 1 + GROUP BY `club_id` +) AS uc2 ON uc1.`club_id` = uc2.`club_id` AND uc1.`user_id` = uc2.`first_user_id` +SET + uc1.`is_owner` = 1, + uc1.`role` = 'admin'; + +-- Verify the changes +SELECT + uc.`club_id`, + c.`name` as club_name, + uc.`user_id`, + u.`email` as user_email, + uc.`role`, + uc.`is_owner`, + uc.`approved` +FROM `user_club` uc +LEFT JOIN `club` c ON c.`id` = uc.`club_id` +LEFT JOIN `user` u ON u.`id` = uc.`user_id` +WHERE uc.`approved` = 1 +ORDER BY uc.`club_id`, uc.`is_owner` DESC, uc.`user_id`; + + diff --git a/backend/models/UserClub.js b/backend/models/UserClub.js index e4f5898..b626fa4 100644 --- a/backend/models/UserClub.js +++ b/backend/models/UserClub.js @@ -6,6 +6,7 @@ import Club from './Club.js'; const UserClub = sequelize.define('UserClub', { userId: { type: DataTypes.INTEGER, + primaryKey: true, references: { model: User, key: 'id', @@ -13,6 +14,7 @@ const UserClub = sequelize.define('UserClub', { }, clubId: { type: DataTypes.INTEGER, + primaryKey: true, references: { model: Club, key: 'id', @@ -22,6 +24,23 @@ const UserClub = sequelize.define('UserClub', { type: DataTypes.BOOLEAN, defaultValue: false, }, + role: { + type: DataTypes.STRING(50), + defaultValue: 'member', + allowNull: false, + comment: 'User role: admin, trainer, team_manager, member' + }, + permissions: { + type: DataTypes.JSON, + allowNull: true, + comment: 'Specific permissions: {diary: {read: true, write: true}, members: {...}, ...}' + }, + isOwner: { + type: DataTypes.BOOLEAN, + defaultValue: false, + allowNull: false, + comment: 'True if user created the club' + } }, { underscored: true, tableName: 'user_club', diff --git a/backend/routes/clubRoutes.js b/backend/routes/clubRoutes.js index 024104a..b9c03c5 100644 --- a/backend/routes/clubRoutes.js +++ b/backend/routes/clubRoutes.js @@ -1,5 +1,6 @@ import express from 'express'; import { authenticate } from '../middleware/authMiddleware.js'; +import { authorize } from '../middleware/authorizationMiddleware.js'; import { getClubs, addClub, getClub, requestClubAccess, getPendingApprovals, approveClubAccess, rejectClubAccess } from '../controllers/clubsController.js'; const router = express.Router(); @@ -8,8 +9,8 @@ router.get('/', authenticate, getClubs); router.post('/', authenticate, addClub); router.get('/:clubid', authenticate, getClub); router.get('/request/:clubid', authenticate, requestClubAccess); -router.get('/pending/:clubid', authenticate, getPendingApprovals); -router.post('/approve', authenticate, approveClubAccess); -router.post('/reject', authenticate, rejectClubAccess); +router.get('/pending/:clubid', authenticate, authorize('approvals', 'read'), getPendingApprovals); +router.post('/approve', authenticate, authorize('approvals', 'write'), approveClubAccess); +router.post('/reject', authenticate, authorize('approvals', 'write'), rejectClubAccess); export default router; diff --git a/backend/routes/diaryRoutes.js b/backend/routes/diaryRoutes.js index dacfce2..8f75876 100644 --- a/backend/routes/diaryRoutes.js +++ b/backend/routes/diaryRoutes.js @@ -1,5 +1,6 @@ import express from 'express'; import { authenticate } from '../middleware/authMiddleware.js'; +import { authorize } from '../middleware/authorizationMiddleware.js'; import { getDatesForClub, createDateForClub, @@ -14,14 +15,14 @@ import { const router = express.Router(); -router.post('/note', authenticate, addDiaryNote); -router.delete('/note/:noteId', authenticate, deleteDiaryNote); -router.post('/tag', authenticate, addDiaryTag); -router.post('/tag/:clubId/add-tag', authenticate, addTagToDiaryDate); -router.delete('/:clubId/tag', authenticate, deleteTagFromDiaryDate); -router.get('/:clubId', authenticate, getDatesForClub); -router.post('/:clubId', authenticate, createDateForClub); -router.put('/:clubId', authenticate, updateTrainingTimes); -router.delete('/:clubId/:dateId', authenticate, deleteDateForClub); +router.post('/note', authenticate, authorize('diary', 'write'), addDiaryNote); +router.delete('/note/:noteId', authenticate, authorize('diary', 'delete'), deleteDiaryNote); +router.post('/tag', authenticate, authorize('diary', 'write'), addDiaryTag); +router.post('/tag/:clubId/add-tag', authenticate, authorize('diary', 'write'), addTagToDiaryDate); +router.delete('/:clubId/tag', authenticate, authorize('diary', 'delete'), deleteTagFromDiaryDate); +router.get('/:clubId', authenticate, authorize('diary', 'read'), getDatesForClub); +router.post('/:clubId', authenticate, authorize('diary', 'write'), createDateForClub); +router.put('/:clubId', authenticate, authorize('diary', 'write'), updateTrainingTimes); +router.delete('/:clubId/:dateId', authenticate, authorize('diary', 'delete'), deleteDateForClub); export default router; diff --git a/backend/routes/matchRoutes.js b/backend/routes/matchRoutes.js index de0f6d4..a2bd09e 100644 --- a/backend/routes/matchRoutes.js +++ b/backend/routes/matchRoutes.js @@ -1,20 +1,21 @@ import express from 'express'; import { uploadCSV, getLeaguesForCurrentSeason, getMatchesForLeagues, getMatchesForLeague, getLeagueTable, fetchLeagueTableFromMyTischtennis, updateMatchPlayers, getPlayerMatchStats } from '../controllers/matchController.js'; import { authenticate } from '../middleware/authMiddleware.js'; +import { authorize } from '../middleware/authorizationMiddleware.js'; import multer from 'multer'; const router = express.Router(); const upload = multer({ dest: 'uploads/' }); -router.post('/import', authenticate, upload.single('file'), uploadCSV); -router.get('/leagues/current/:clubId', authenticate, getLeaguesForCurrentSeason); -router.get('/leagues/:clubId/matches/:leagueId', authenticate, getMatchesForLeague); -router.get('/leagues/:clubId/matches', authenticate, getMatchesForLeagues); -router.get('/leagues/:clubId/table/:leagueId', authenticate, getLeagueTable); -router.post('/leagues/:clubId/table/:leagueId/fetch', authenticate, fetchLeagueTableFromMyTischtennis); -router.patch('/:matchId/players', authenticate, updateMatchPlayers); -router.get('/leagues/:clubId/stats/:leagueId', authenticate, getPlayerMatchStats); +router.post('/import', authenticate, authorize('schedule', 'write'), upload.single('file'), uploadCSV); +router.get('/leagues/current/:clubId', authenticate, authorize('schedule', 'read'), getLeaguesForCurrentSeason); +router.get('/leagues/:clubId/matches/:leagueId', authenticate, authorize('schedule', 'read'), getMatchesForLeague); +router.get('/leagues/:clubId/matches', authenticate, authorize('schedule', 'read'), getMatchesForLeagues); +router.get('/leagues/:clubId/table/:leagueId', authenticate, authorize('schedule', 'read'), getLeagueTable); +router.post('/leagues/:clubId/table/:leagueId/fetch', authenticate, authorize('mytischtennis', 'write'), fetchLeagueTableFromMyTischtennis); +router.patch('/:matchId/players', authenticate, authorize('schedule', 'write'), updateMatchPlayers); +router.get('/leagues/:clubId/stats/:leagueId', authenticate, authorize('schedule', 'read'), getPlayerMatchStats); export default router; diff --git a/backend/routes/memberRoutes.js b/backend/routes/memberRoutes.js index ecbab85..c6b9b7a 100644 --- a/backend/routes/memberRoutes.js +++ b/backend/routes/memberRoutes.js @@ -1,6 +1,7 @@ import { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, rotateMemberImage } from '../controllers/memberController.js'; import express from 'express'; import { authenticate } from '../middleware/authMiddleware.js'; +import { authorize } from '../middleware/authorizationMiddleware.js'; import multer from 'multer'; const router = express.Router(); @@ -8,12 +9,12 @@ const router = express.Router(); const storage = multer.memoryStorage(); const upload = multer({ storage: storage }); -router.post('/image/:clubId/:memberId', authenticate, upload.single('image'), uploadMemberImage); -router.get('/image/:clubId/:memberId', authenticate, getMemberImage); -router.get('/get/:id/:showAll', authenticate, getClubMembers); -router.post('/set/:id', authenticate, setClubMembers); -router.get('/notapproved/:id', authenticate, getWaitingApprovals); -router.post('/update-ratings/:id', authenticate, updateRatingsFromMyTischtennis); -router.post('/rotate-image/:clubId/:memberId', authenticate, rotateMemberImage); +router.post('/image/:clubId/:memberId', authenticate, authorize('members', 'write'), upload.single('image'), uploadMemberImage); +router.get('/image/:clubId/:memberId', authenticate, authorize('members', 'read'), getMemberImage); +router.get('/get/:id/:showAll', authenticate, authorize('members', 'read'), getClubMembers); +router.post('/set/:id', authenticate, authorize('members', 'write'), setClubMembers); +router.get('/notapproved/:id', authenticate, authorize('members', 'read'), getWaitingApprovals); +router.post('/update-ratings/:id', authenticate, authorize('mytischtennis', 'write'), updateRatingsFromMyTischtennis); +router.post('/rotate-image/:clubId/:memberId', authenticate, authorize('members', 'write'), rotateMemberImage); export default router; diff --git a/backend/routes/myTischtennisRoutes.js b/backend/routes/myTischtennisRoutes.js index 0e42c65..05f457b 100644 --- a/backend/routes/myTischtennisRoutes.js +++ b/backend/routes/myTischtennisRoutes.js @@ -2,23 +2,24 @@ import express from 'express'; import myTischtennisController from '../controllers/myTischtennisController.js'; import myTischtennisUrlController from '../controllers/myTischtennisUrlController.js'; import { authenticate } from '../middleware/authMiddleware.js'; +import { authorize } from '../middleware/authorizationMiddleware.js'; const router = express.Router(); // All routes require authentication router.use(authenticate); -// GET /api/mytischtennis/account - Get account +// GET /api/mytischtennis/account - Get account (alle dürfen lesen) router.get('/account', myTischtennisController.getAccount); -// GET /api/mytischtennis/status - Check status +// GET /api/mytischtennis/status - Check status (alle dürfen lesen) router.get('/status', myTischtennisController.getStatus); -// POST /api/mytischtennis/account - Create or update account +// POST /api/mytischtennis/account - Create or update account (alle dürfen bearbeiten) router.post('/account', myTischtennisController.upsertAccount); -// DELETE /api/mytischtennis/account - Delete account -router.delete('/account', myTischtennisController.deleteAccount); +// DELETE /api/mytischtennis/account - Delete account (nur Admin) +router.delete('/account', authorize('mytischtennis_admin', 'write'), myTischtennisController.deleteAccount); // POST /api/mytischtennis/verify - Verify login router.post('/verify', myTischtennisController.verifyLogin); diff --git a/backend/routes/permissionRoutes.js b/backend/routes/permissionRoutes.js new file mode 100644 index 0000000..b7e2bf0 --- /dev/null +++ b/backend/routes/permissionRoutes.js @@ -0,0 +1,30 @@ +import express from 'express'; +import { authenticate } from '../middleware/authMiddleware.js'; +import { authorize, requireAdmin } from '../middleware/authorizationMiddleware.js'; +import permissionController from '../controllers/permissionController.js'; + +const router = express.Router(); + +// Get available roles (no club context needed) +router.get('/roles/available', authenticate, permissionController.getAvailableRoles); + +// Get permission structure (no club context needed) +router.get('/structure/all', authenticate, permissionController.getPermissionStructure); + +// Get current user's permissions for a club (no authorization check - needed to load permissions) +router.get('/:clubId', authenticate, permissionController.getUserPermissions); + +// Get all club members with their permissions (admin only) +router.get('/:clubId/members', authenticate, authorize('permissions', 'read'), permissionController.getClubMembersWithPermissions); + +// Update user role (admin only) +router.put('/:clubId/user/:userId/role', authenticate, authorize('permissions', 'write'), permissionController.updateUserRole); + +// Update user permissions (admin only) +router.put('/:clubId/user/:userId/permissions', authenticate, authorize('permissions', 'write'), permissionController.updateUserPermissions); + +// Update user status (admin only) +router.put('/:clubId/user/:userId/status', authenticate, authorize('permissions', 'write'), permissionController.updateUserStatus); + +export default router; + diff --git a/backend/routes/predefinedActivityRoutes.js b/backend/routes/predefinedActivityRoutes.js index ce74def..e590284 100644 --- a/backend/routes/predefinedActivityRoutes.js +++ b/backend/routes/predefinedActivityRoutes.js @@ -10,6 +10,7 @@ import { } from '../controllers/predefinedActivityController.js'; import multer from 'multer'; import { authenticate } from '../middleware/authMiddleware.js'; +import { authorize } from '../middleware/authorizationMiddleware.js'; import { uploadPredefinedActivityImage, deletePredefinedActivityImage } from '../controllers/predefinedActivityImageController.js'; import PredefinedActivityImage from '../models/PredefinedActivityImage.js'; import path from 'path'; @@ -18,16 +19,16 @@ import fs from 'fs'; const router = express.Router(); const upload = multer({ storage: multer.memoryStorage() }); -router.post('/', authenticate, createPredefinedActivity); -router.get('/', authenticate, getAllPredefinedActivities); -router.get('/:id', authenticate, getPredefinedActivityById); -router.put('/:id', authenticate, updatePredefinedActivity); -router.post('/:id/image', authenticate, upload.single('image'), uploadPredefinedActivityImage); -router.put('/:id/image', authenticate, upload.single('image'), uploadPredefinedActivityImage); -router.delete('/:id/image/:imageId', authenticate, deletePredefinedActivityImage); -router.get('/search/query', authenticate, searchPredefinedActivities); -router.post('/merge', authenticate, mergePredefinedActivities); -router.post('/deduplicate', authenticate, deduplicatePredefinedActivities); +router.post('/', authenticate, authorize('predefined_activities', 'write'), createPredefinedActivity); +router.get('/', authenticate, authorize('predefined_activities', 'read'), getAllPredefinedActivities); +router.get('/:id', authenticate, authorize('predefined_activities', 'read'), getPredefinedActivityById); +router.put('/:id', authenticate, authorize('predefined_activities', 'write'), updatePredefinedActivity); +router.post('/:id/image', authenticate, authorize('predefined_activities', 'write'), upload.single('image'), uploadPredefinedActivityImage); +router.put('/:id/image', authenticate, authorize('predefined_activities', 'write'), upload.single('image'), uploadPredefinedActivityImage); +router.delete('/:id/image/:imageId', authenticate, authorize('predefined_activities', 'delete'), deletePredefinedActivityImage); +router.get('/search/query', authenticate, authorize('predefined_activities', 'read'), searchPredefinedActivities); +router.post('/merge', authenticate, authorize('predefined_activities', 'write'), mergePredefinedActivities); +router.post('/deduplicate', authenticate, authorize('predefined_activities', 'write'), deduplicatePredefinedActivities); router.get('/:id/image/:imageId', async (req, res) => { try { const { id, imageId } = req.params; diff --git a/backend/routes/teamRoutes.js b/backend/routes/teamRoutes.js index ea3ee3c..52415d4 100644 --- a/backend/routes/teamRoutes.js +++ b/backend/routes/teamRoutes.js @@ -1,5 +1,6 @@ import express from 'express'; import { authenticate } from '../middleware/authMiddleware.js'; +import { authorize } from '../middleware/authorizationMiddleware.js'; import { getTeams, getTeam, @@ -12,21 +13,21 @@ import { const router = express.Router(); // Get all teams for a club -router.get('/club/:clubid', authenticate, getTeams); +router.get('/club/:clubid', authenticate, authorize('teams', 'read'), getTeams); // Get leagues for a club -router.get('/leagues/:clubid', authenticate, getLeagues); +router.get('/leagues/:clubid', authenticate, authorize('teams', 'read'), getLeagues); // Get a specific team -router.get('/:teamid', authenticate, getTeam); +router.get('/:teamid', authenticate, authorize('teams', 'read'), getTeam); // Create a new team -router.post('/club/:clubid', authenticate, createTeam); +router.post('/club/:clubid', authenticate, authorize('teams', 'write'), createTeam); // Update a team -router.put('/:teamid', authenticate, updateTeam); +router.put('/:teamid', authenticate, authorize('teams', 'write'), updateTeam); // Delete a team -router.delete('/:teamid', authenticate, deleteTeam); +router.delete('/:teamid', authenticate, authorize('teams', 'delete'), deleteTeam); export default router; \ No newline at end of file diff --git a/backend/scripts/createTestUsers.js b/backend/scripts/createTestUsers.js new file mode 100644 index 0000000..ea16bfa --- /dev/null +++ b/backend/scripts/createTestUsers.js @@ -0,0 +1,141 @@ +import User from '../models/User.js'; +import Club from '../models/Club.js'; +import UserClub from '../models/UserClub.js'; +import sequelize from '../database.js'; + +/** + * Create test users with different roles + */ + +const TEST_USERS = [ + { + email: 'admin@test.de', + password: 'test123', + role: 'admin', + isOwner: false + }, + { + email: 'trainer@test.de', + password: 'test123', + role: 'trainer', + isOwner: false + }, + { + email: 'teammanager@test.de', + password: 'test123', + role: 'team_manager', + isOwner: false + }, + { + email: 'tournamentmanager@test.de', + password: 'test123', + role: 'tournament_manager', + isOwner: false + }, + { + email: 'member1@test.de', + password: 'test123', + role: 'member', + isOwner: false + }, + { + email: 'member2@test.de', + password: 'test123', + role: 'member', + isOwner: false + } +]; + +async function createTestUsers() { + console.log('Creating test users...\n'); + + try { + // Get first club (or specify club ID) + const clubs = await Club.findAll({ limit: 1 }); + + if (clubs.length === 0) { + console.error('❌ No clubs found! Please create a club first.'); + process.exit(1); + } + + const club = clubs[0]; + console.log(`Using club: ${club.name} (ID: ${club.id})\n`); + + for (const userData of TEST_USERS) { + console.log(`Creating user: ${userData.email} (${userData.role})...`); + + // Check if user already exists + let user = await User.findOne({ where: { email: userData.email } }); + + if (user) { + console.log(` ⚠️ User already exists, using existing user`); + } else { + // Create user + user = await User.create({ + email: userData.email, + password: userData.password, + isActive: true + }); + console.log(` ✓ User created`); + } + + // Check if user is already in club + let userClub = await UserClub.findOne({ + where: { + userId: user.id, + clubId: club.id + } + }); + + if (userClub) { + console.log(` ⚠️ User already in club, updating role...`); + await userClub.update({ + role: userData.role, + isOwner: userData.isOwner, + approved: true + }); + console.log(` ✓ Updated to role: ${userData.role}`); + } else { + // Add user to club + userClub = await UserClub.create({ + userId: user.id, + clubId: club.id, + role: userData.role, + isOwner: userData.isOwner, + approved: true + }); + console.log(` ✓ Added to club with role: ${userData.role}`); + } + } + + console.log('\n✅ Test users created successfully!\n'); + + // Show summary + console.log('Summary:'); + console.log('========================================'); + console.log(`Club: ${club.name}`); + console.log('\nTest Users:'); + + for (const userData of TEST_USERS) { + console.log(` ${userData.email.padEnd(25)} | ${userData.role.padEnd(15)} | Password: test123`); + } + + console.log('\n========================================'); + console.log('You can now login with any of these users!'); + console.log('All passwords are: test123'); + + } catch (error) { + console.error('❌ Error creating test users:', error); + throw error; + } finally { + await sequelize.close(); + } +} + +// Run +createTestUsers().catch(err => { + console.error('Fatal error:', err); + process.exit(1); +}); + + diff --git a/backend/scripts/migratePermissions.js b/backend/scripts/migratePermissions.js new file mode 100644 index 0000000..96d383b --- /dev/null +++ b/backend/scripts/migratePermissions.js @@ -0,0 +1,128 @@ +import UserClub from '../models/UserClub.js'; +import Club from '../models/Club.js'; +import User from '../models/User.js'; +import sequelize from '../database.js'; + +/** + * Migration script to set up permissions for existing clubs + * This script: + * 1. Sets default role='member' for all approved users without a role + * 2. Identifies and marks the first user (by creation date) of each club as owner + */ + +async function migratePermissions() { + console.log('Starting permissions migration...\n'); + + try { + // Get all clubs + const clubs = await Club.findAll({ + include: [{ + model: UserClub, + include: [{ + model: User, + as: 'user' + }], + where: { + approved: true + }, + order: [['createdAt', 'ASC']] + }] + }); + + console.log(`Found ${clubs.length} club(s)\n`); + + for (const club of clubs) { + console.log(`\n--- Club: ${club.name} (ID: ${club.id}) ---`); + + const userClubs = await UserClub.findAll({ + where: { + clubId: club.id, + approved: true + }, + include: [{ + model: User, + as: 'user' + }], + order: [['createdAt', 'ASC']] + }); + + if (userClubs.length === 0) { + console.log(' No approved members found.'); + continue; + } + + // First user becomes owner + const firstUser = userClubs[0]; + + console.log(` Members found: ${userClubs.length}`); + console.log(` First member (will be owner): ${firstUser.user.email}`); + + for (let i = 0; i < userClubs.length; i++) { + const userClub = userClubs[i]; + const isFirstUser = i === 0; + + // Set role if not set + if (!userClub.role) { + userClub.role = isFirstUser ? 'admin' : 'member'; + } + + // Set owner flag + userClub.isOwner = isFirstUser; + + await userClub.save(); + + console.log(` ✓ Updated ${userClub.user.email}: role=${userClub.role}, isOwner=${userClub.isOwner}`); + } + } + + console.log('\n✅ Migration completed successfully!'); + console.log('\nSummary:'); + + // Show summary + const owners = await UserClub.findAll({ + where: { + isOwner: true + }, + include: [ + { + model: User, + as: 'user' + }, + { + model: Club, + as: 'club' + } + ] + }); + + console.log(`\nClub Owners (${owners.length}):`); + for (const owner of owners) { + console.log(` - ${owner.club.name}: ${owner.user.email}`); + } + + const admins = await UserClub.count({ + where: { role: 'admin' } + }); + const members = await UserClub.count({ + where: { role: 'member' } + }); + + console.log(`\nRole Distribution:`); + console.log(` - Admins: ${admins}`); + console.log(` - Members: ${members}`); + + } catch (error) { + console.error('❌ Migration failed:', error); + throw error; + } finally { + await sequelize.close(); + } +} + +// Run migration +migratePermissions().catch(err => { + console.error('Fatal error:', err); + process.exit(1); +}); + + diff --git a/backend/scripts/quickFixOwner.js b/backend/scripts/quickFixOwner.js new file mode 100644 index 0000000..90d25ff --- /dev/null +++ b/backend/scripts/quickFixOwner.js @@ -0,0 +1,103 @@ +import UserClub from '../models/UserClub.js'; +import Club from '../models/Club.js'; +import User from '../models/User.js'; +import sequelize from '../database.js'; + +/** + * Quick fix: Set first user of each club as owner/admin + * This is a simplified version for immediate use + */ + +async function quickFixOwners() { + console.log('Quick Fix: Setting club owners...\n'); + + try { + const clubs = await Club.findAll(); + + console.log(`Found ${clubs.length} club(s)\n`); + + for (const club of clubs) { + console.log(`Club: ${club.name} (ID: ${club.id})`); + + // Find all approved members, ordered by creation date + const userClubs = await UserClub.findAll({ + where: { + clubId: club.id, + approved: true + }, + include: [{ + model: User, + as: 'user', + attributes: ['id', 'email'] + }], + order: [['createdAt', 'ASC']] + }); + + if (userClubs.length === 0) { + console.log(' ⚠️ No approved members\n'); + continue; + } + + // First user becomes owner + const firstUserClub = userClubs[0]; + + // Reset all users first (remove owner flag) + await UserClub.update( + { isOwner: false }, + { + where: { + clubId: club.id, + approved: true + } + } + ); + + // Set first user as owner and admin + await firstUserClub.update({ + isOwner: true, + role: 'admin' + }); + + console.log(` ✅ Owner: ${firstUserClub.user.email}`); + + // Set role for other members if not set + for (let i = 1; i < userClubs.length; i++) { + const uc = userClubs[i]; + if (!uc.role) { + await uc.update({ role: 'member' }); + console.log(` 👤 Member: ${uc.user.email}`); + } + } + + console.log(''); + } + + console.log('✅ Quick fix completed!\n'); + + // Show all owners + const owners = await UserClub.findAll({ + where: { isOwner: true }, + include: [ + { model: User, as: 'user', attributes: ['email'] }, + { model: Club, as: 'club', attributes: ['name'] } + ] + }); + + console.log('Current Club Owners:'); + for (const owner of owners) { + console.log(` 📍 ${owner.club.name}: ${owner.user.email} (role: ${owner.role})`); + } + + } catch (error) { + console.error('❌ Error:', error); + throw error; + } finally { + await sequelize.close(); + } +} + +quickFixOwners().catch(err => { + console.error('Fatal error:', err); + process.exit(1); +}); + diff --git a/backend/server.js b/backend/server.js index 67d8805..1aadafe 100644 --- a/backend/server.js +++ b/backend/server.js @@ -39,6 +39,7 @@ import clubTeamRoutes from './routes/clubTeamRoutes.js'; import teamDocumentRoutes from './routes/teamDocumentRoutes.js'; import seasonRoutes from './routes/seasonRoutes.js'; import memberActivityRoutes from './routes/memberActivityRoutes.js'; +import permissionRoutes from './routes/permissionRoutes.js'; import schedulerService from './services/schedulerService.js'; const app = express(); @@ -90,6 +91,7 @@ app.use('/api/club-teams', clubTeamRoutes); app.use('/api/team-documents', teamDocumentRoutes); app.use('/api/seasons', seasonRoutes); app.use('/api/member-activities', memberActivityRoutes); +app.use('/api/permissions', permissionRoutes); app.use(express.static(path.join(__dirname, '../frontend/dist'))); diff --git a/backend/services/clubService.js b/backend/services/clubService.js index 0b1ee26..e55d7f6 100644 --- a/backend/services/clubService.js +++ b/backend/services/clubService.js @@ -4,6 +4,7 @@ import User from '../models/User.js'; import Member from '../models/Member.js'; import { Op, fn, where, col } from 'sequelize'; import { checkAccess } from '../utils/userUtils.js'; +import permissionService from './permissionService.js'; class ClubService { async getAllClubs() { @@ -20,8 +21,15 @@ class ClubService { return await Club.create({ name: clubName }); } - async addUserToClub(userId, clubId) { - return await UserClub.create({ userId: userId, clubId: clubId, approved: true }); + async addUserToClub(userId, clubId, isOwner = false) { + const userClub = await UserClub.create({ + userId: userId, + clubId: clubId, + approved: true, + isOwner: isOwner, + role: isOwner ? 'admin' : 'member' + }); + return userClub; } async getUserClubAccess(userId, clubId) { diff --git a/backend/services/permissionService.js b/backend/services/permissionService.js new file mode 100644 index 0000000..3c00aa5 --- /dev/null +++ b/backend/services/permissionService.js @@ -0,0 +1,366 @@ +import UserClub from '../models/UserClub.js'; +import Club from '../models/Club.js'; +import User from '../models/User.js'; + +/** + * Permission Service + * Handles all permission-related logic + */ + +// Default permissions for each role +const ROLE_PERMISSIONS = { + admin: { + diary: { read: true, write: true, delete: true }, + members: { read: true, write: true, delete: true }, + teams: { read: true, write: true, delete: true }, + schedule: { read: true, write: true, delete: true }, + tournaments: { read: true, write: true, delete: true }, + statistics: { read: true, write: true }, + settings: { read: true, write: true }, + permissions: { read: true, write: true }, // Can manage other users' permissions + approvals: { read: true, write: true }, + mytischtennis_admin: { read: true, write: true }, + predefined_activities: { read: true, write: true, delete: true } + }, + trainer: { + diary: { read: true, write: true, delete: true }, + members: { read: true, write: true, delete: false }, + teams: { read: true, write: true, delete: false }, + schedule: { read: true, write: true, delete: false }, + tournaments: { read: true, write: true, delete: false }, + statistics: { read: true, write: false }, + settings: { read: false, write: false }, + permissions: { read: false, write: false }, + approvals: { read: false, write: false }, + mytischtennis_admin: { read: false, write: false }, + predefined_activities: { read: true, write: true, delete: true } + }, + team_manager: { + diary: { read: false, write: false, delete: false }, + members: { read: true, write: false, delete: false }, + teams: { read: true, write: true, delete: false }, + schedule: { read: true, write: true, delete: false }, + tournaments: { read: true, write: false, delete: false }, + statistics: { read: true, write: false }, + settings: { read: false, write: false }, + permissions: { read: false, write: false }, + approvals: { read: false, write: false }, + mytischtennis_admin: { read: false, write: false }, + predefined_activities: { read: false, write: false, delete: false } + }, + tournament_manager: { + diary: { read: false, write: false, delete: false }, + members: { read: true, write: false, delete: false }, + teams: { read: false, write: false, delete: false }, + schedule: { read: false, write: false, delete: false }, + tournaments: { read: true, write: true, delete: false }, + statistics: { read: true, write: false }, + settings: { read: false, write: false }, + permissions: { read: false, write: false }, + approvals: { read: false, write: false }, + mytischtennis_admin: { read: false, write: false }, + predefined_activities: { read: false, write: false, delete: false } + }, + member: { + diary: { read: false, write: false, delete: false }, + members: { read: false, write: false, delete: false }, + teams: { read: false, write: false, delete: false }, + schedule: { read: false, write: false, delete: false }, + tournaments: { read: false, write: false, delete: false }, + statistics: { read: true, write: false }, + settings: { read: false, write: false }, + permissions: { read: false, write: false }, + approvals: { read: false, write: false }, + mytischtennis_admin: { read: false, write: false }, + predefined_activities: { read: false, write: false, delete: false } + } +}; + +class PermissionService { + /** + * Get user's permissions for a specific club + */ + async getUserClubPermissions(userId, clubId) { + const userClub = await UserClub.findOne({ + where: { + userId, + clubId, + approved: true + } + }); + + if (!userClub) { + return null; + } + + // If user is owner, they have full admin rights + if (userClub.isOwner) { + return { + role: 'admin', + isOwner: true, + permissions: ROLE_PERMISSIONS.admin + }; + } + + // Get role from database, fallback to 'member' if null/undefined + const role = userClub.role || 'member'; + + // Get role-based permissions + const rolePermissions = ROLE_PERMISSIONS[role] || ROLE_PERMISSIONS.member; + + // Merge with custom permissions if any + const customPermissions = userClub.permissions || {}; + const mergedPermissions = this.mergePermissions(rolePermissions, customPermissions); + + return { + role: role, + isOwner: false, + permissions: mergedPermissions + }; + } + + /** + * Check if user has specific permission + */ + async hasPermission(userId, clubId, resource, action) { + const userPermissions = await this.getUserClubPermissions(userId, clubId); + + if (!userPermissions) { + return false; + } + + // Owner always has permission + if (userPermissions.isOwner) { + return true; + } + + // MyTischtennis settings are accessible to all approved members + if (resource === 'mytischtennis') { + return true; + } + + const resourcePermissions = userPermissions.permissions[resource]; + if (!resourcePermissions) { + return false; + } + + return resourcePermissions[action] === true; + } + + /** + * Set user role in club + */ + async setUserRole(userId, clubId, role, updatedByUserId) { + // Check if updater has permission + const canManagePermissions = await this.hasPermission(updatedByUserId, clubId, 'permissions', 'write'); + if (!canManagePermissions) { + throw new Error('Keine Berechtigung zum Ändern von Rollen'); + } + + // Check if target user is owner + const targetUserClub = await UserClub.findOne({ + where: { userId, clubId } + }); + + if (!targetUserClub) { + throw new Error('Benutzer ist kein Mitglied dieses Clubs'); + } + + if (targetUserClub.isOwner) { + throw new Error('Die Rolle des Club-Erstellers kann nicht geändert werden'); + } + + // Validate role + if (!ROLE_PERMISSIONS[role]) { + throw new Error('Ungültige Rolle'); + } + + await targetUserClub.update({ role }); + + return { + success: true, + message: 'Rolle erfolgreich aktualisiert' + }; + } + + /** + * Set custom permissions for user + */ + async setCustomPermissions(userId, clubId, customPermissions, updatedByUserId) { + // Check if updater has permission + const canManagePermissions = await this.hasPermission(updatedByUserId, clubId, 'permissions', 'write'); + if (!canManagePermissions) { + throw new Error('Keine Berechtigung zum Ändern von Berechtigungen'); + } + + // Check if target user is owner + const targetUserClub = await UserClub.findOne({ + where: { userId, clubId } + }); + + if (!targetUserClub) { + throw new Error('Benutzer ist kein Mitglied dieses Clubs'); + } + + if (targetUserClub.isOwner) { + throw new Error('Die Berechtigungen des Club-Erstellers können nicht geändert werden'); + } + + await targetUserClub.update({ permissions: customPermissions }); + + return { + success: true, + message: 'Berechtigungen erfolgreich aktualisiert' + }; + } + + /** + * Set user status (activate/deactivate) + */ + async setUserStatus(userId, clubId, approved, updatedByUserId) { + // Check if updater has permission + const canManagePermissions = await this.hasPermission(updatedByUserId, clubId, 'permissions', 'write'); + if (!canManagePermissions) { + throw new Error('Keine Berechtigung zum Ändern des Status'); + } + + // Check if target user is owner + const targetUserClub = await UserClub.findOne({ + where: { userId, clubId } + }); + + if (!targetUserClub) { + throw new Error('Benutzer ist kein Mitglied dieses Clubs'); + } + + if (targetUserClub.isOwner) { + throw new Error('Der Status des Club-Erstellers kann nicht geändert werden'); + } + + await targetUserClub.update({ approved }); + + return { + success: true, + message: approved ? 'Benutzer erfolgreich aktiviert' : 'Benutzer erfolgreich deaktiviert' + }; + } + + /** + * Get all club members with their permissions + */ + async getClubMembersWithPermissions(clubId, requestingUserId) { + // Check if requester has permission to read permissions + const canReadPermissions = await this.hasPermission(requestingUserId, clubId, 'permissions', 'read'); + if (!canReadPermissions) { + throw new Error('Keine Berechtigung zum Anzeigen von Berechtigungen'); + } + + const userClubs = await UserClub.findAll({ + where: { + clubId + }, + include: [{ + model: User, + as: 'user', + attributes: ['id', 'email'] + }] + }); + + return userClubs.map(uc => ({ + userId: uc.userId, + user: uc.user, + role: uc.role, + isOwner: uc.isOwner, + approved: uc.approved, + permissions: uc.permissions, + effectivePermissions: this.getEffectivePermissions(uc) + })); + } + + /** + * Get effective permissions (role + custom) + */ + getEffectivePermissions(userClub) { + if (userClub.isOwner) { + return ROLE_PERMISSIONS.admin; + } + + const rolePermissions = ROLE_PERMISSIONS[userClub.role] || ROLE_PERMISSIONS.member; + const customPermissions = userClub.permissions || {}; + + return this.mergePermissions(rolePermissions, customPermissions); + } + + /** + * Merge role permissions with custom permissions + */ + mergePermissions(rolePermissions, customPermissions) { + const merged = { ...rolePermissions }; + + for (const resource in customPermissions) { + if (!merged[resource]) { + merged[resource] = {}; + } + merged[resource] = { + ...merged[resource], + ...customPermissions[resource] + }; + } + + return merged; + } + + /** + * Mark user as club owner (used when creating a club) + */ + async setClubOwner(userId, clubId) { + const userClub = await UserClub.findOne({ + where: { userId, clubId } + }); + + if (!userClub) { + throw new Error('UserClub relationship not found'); + } + + await userClub.update({ + isOwner: true, + role: 'admin', + approved: true + }); + } + + /** + * Get all available roles + */ + getAvailableRoles() { + return [ + { value: 'admin', label: 'Administrator', description: 'Vollzugriff auf alle Funktionen' }, + { value: 'trainer', label: 'Trainer', description: 'Kann Trainingseinheiten, Mitglieder und Teams verwalten' }, + { value: 'team_manager', label: 'Mannschaftsführer', description: 'Kann Teams und Spielpläne verwalten' }, + { value: 'tournament_manager', label: 'Turnierleiter', description: 'Kann Turniere verwalten' }, + { value: 'member', label: 'Mitglied', description: 'Kann nur Trainings-Statistiken ansehen' } + ]; + } + + /** + * Get permission structure for frontend + */ + getPermissionStructure() { + return { + diary: { label: 'Trainingstagebuch', actions: ['read', 'write', 'delete'] }, + members: { label: 'Mitglieder', actions: ['read', 'write', 'delete'] }, + teams: { label: 'Teams', actions: ['read', 'write', 'delete'] }, + schedule: { label: 'Spielpläne', actions: ['read', 'write', 'delete'] }, + tournaments: { label: 'Turniere', actions: ['read', 'write', 'delete'] }, + statistics: { label: 'Statistiken', actions: ['read', 'write'] }, + settings: { label: 'Einstellungen', actions: ['read', 'write'] }, + permissions: { label: 'Berechtigungsverwaltung', actions: ['read', 'write'] }, + approvals: { label: 'Freigaben (Mitgliedsanträge)', actions: ['read', 'write'] }, + mytischtennis_admin: { label: 'MyTischtennis Admin', actions: ['read', 'write'] }, + predefined_activities: { label: 'Vordefinierte Aktivitäten', actions: ['read', 'write', 'delete'] } + }; + } +} + +export default new PermissionService(); + diff --git a/backend/services/schedulerService.js b/backend/services/schedulerService.js index ddc7bd9..9dd7270 100644 --- a/backend/services/schedulerService.js +++ b/backend/services/schedulerService.js @@ -11,7 +11,7 @@ class SchedulerService { /** * Start the scheduler - */ + */ start() { if (this.isRunning) { devLog('Scheduler is already running'); diff --git a/frontend/src/App.vue b/frontend/src/App.vue index e9766ce..e9f114a 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -7,6 +7,28 @@ Trainingstagebuch +
+ +
+ + 🔗 + myTischtennis-Account + + + 🔐 + Berechtigungen + + + +
+
@@ -33,74 +55,57 @@ - - - -
@@ -179,10 +184,25 @@ export default { selectedClub: null, sessionInterval: null, logoUrl, + userDropdownOpen: false, }; }, computed: { - ...mapGetters(['isAuthenticated', 'currentClub', 'clubs', 'sidebarCollapsed']), + ...mapGetters(['isAuthenticated', 'currentClub', 'clubs', 'sidebarCollapsed', 'username', 'hasPermission', 'isClubOwner', 'userRole']), + canManageApprovals() { + // Nur anzeigen, wenn Permissions geladen sind UND Berechtigung vorhanden + if (!this.currentClub) return false; + + // Owner oder Admin können Freigaben verwalten + return this.isClubOwner || this.userRole === 'admin' || this.hasPermission('approvals', 'read'); + }, + canManagePermissions() { + // Nur anzeigen, wenn Permissions geladen sind UND Berechtigung vorhanden + if (!this.currentClub) return false; + + // Owner oder Admin können Berechtigungen verwalten + return this.isClubOwner || this.userRole === 'admin' || this.hasPermission('permissions', 'read'); + }, }, watch: { selectedClub(newVal) { @@ -211,6 +231,16 @@ export default { }, }, methods: { + toggleUserDropdown(event) { + event.stopPropagation(); + this.userDropdownOpen = !this.userDropdownOpen; + }, + handleClickOutside(event) { + const userMenu = event.target.closest('.user-menu'); + if (!userMenu && this.userDropdownOpen) { + this.userDropdownOpen = false; + } + }, // Dialog Helper Methods async showInfo(title, message, details = '', type = 'info') { this.infoDialog = { @@ -286,6 +316,9 @@ export default { } }, async mounted() { + // Click-outside handler für User-Dropdown + document.addEventListener('click', this.handleClickOutside); + // Nur Daten laden, wenn der Benutzer authentifiziert ist if (this.isAuthenticated) { try { @@ -304,6 +337,7 @@ export default { }, beforeUnmount() { clearInterval(this.sessionInterval); + document.removeEventListener('click', this.handleClickOutside); } }; @@ -325,6 +359,10 @@ export default { position: relative; z-index: 1000; flex-shrink: 0; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 1.5rem; } .header-content { @@ -344,6 +382,105 @@ export default { /* Schriftgröße bleibt wie in der main.scss definiert */ } +.user-menu { + position: relative; +} + +.user-info { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background-color: rgba(255, 255, 255, 0.1); + border-radius: 20px; + font-size: 0.9rem; + color: white; + border: none; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.user-info:hover { + background-color: rgba(255, 255, 255, 0.2); +} + +.user-icon { + font-size: 1.2rem; +} + +.user-email { + font-weight: 500; +} + +.dropdown-arrow { + font-size: 0.7rem; + margin-left: 0.25rem; + transition: transform 0.2s ease; +} + +.user-dropdown { + position: absolute; + top: calc(100% + 0.5rem); + right: 0; + background: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + min-width: 200px; + overflow: hidden; + z-index: 10000; + animation: dropdownFadeIn 0.2s ease; +} + +@keyframes dropdownFadeIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.dropdown-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + color: #333; + text-decoration: none; + background: none; + border: none; + width: 100%; + text-align: left; + cursor: pointer; + font-size: 0.9rem; + transition: background-color 0.15s ease; +} + +.dropdown-item:hover { + background-color: #f5f5f5; +} + +.dropdown-icon { + font-size: 1.1rem; +} + +.dropdown-divider { + height: 1px; + background-color: #e0e0e0; + margin: 0.25rem 0; +} + +.logout-item { + color: #dc3545; + font-weight: 500; +} + +.logout-item:hover { + background-color: #fff5f5; +} + .home-link { display: inline-flex; align-items: center; @@ -699,6 +836,15 @@ export default { font-size: 1rem; } + .user-info { + font-size: 0.75rem; + padding: 0.3rem 0.6rem; + } + + .user-email { + display: none; /* Nur Icon auf mobile */ + } + .sidebar-content { padding: 0.5rem; } diff --git a/frontend/src/composables/usePermissions.js b/frontend/src/composables/usePermissions.js new file mode 100644 index 0000000..8900388 --- /dev/null +++ b/frontend/src/composables/usePermissions.js @@ -0,0 +1,66 @@ +import { computed } from 'vue'; +import { useStore } from 'vuex'; + +/** + * Composable for permission checks in Vue components + */ +export function usePermissions() { + const store = useStore(); + + const permissions = computed(() => store.getters.currentPermissions); + const isOwner = computed(() => store.getters.isClubOwner); + const userRole = computed(() => store.getters.userRole); + + /** + * Check if user has specific permission + * @param {string} resource - Resource name (diary, members, teams, etc.) + * @param {string} action - Action type (read, write, delete) + * @returns {boolean} + */ + const can = (resource, action = 'read') => { + return store.getters.hasPermission(resource, action); + }; + + /** + * Check if user can read + */ + const canRead = (resource) => can(resource, 'read'); + + /** + * Check if user can write + */ + const canWrite = (resource) => can(resource, 'write'); + + /** + * Check if user can delete + */ + const canDelete = (resource) => can(resource, 'delete'); + + /** + * Check if user is admin (owner or admin role) + */ + const isAdmin = computed(() => { + return isOwner.value || userRole.value === 'admin'; + }); + + /** + * Check if user has specific role + */ + const hasRole = (role) => { + return userRole.value === role; + }; + + return { + permissions, + isOwner, + isAdmin, + userRole, + can, + canRead, + canWrite, + canDelete, + hasRole + }; +} + + diff --git a/frontend/src/directives/permissions.js b/frontend/src/directives/permissions.js new file mode 100644 index 0000000..7541bad --- /dev/null +++ b/frontend/src/directives/permissions.js @@ -0,0 +1,198 @@ +/** + * Vue directive for permission-based element visibility + * Usage: v-can:resource.action or v-can="'resource.action'" + * + * Examples: + * + * + *
...
+ */ + +const checkPermission = (el, binding, vnode) => { + // Safely access store + if (!vnode.appContext || !vnode.appContext.config || !vnode.appContext.config.globalProperties) { + // Hide by default if store not available (deny by default) + el.style.display = 'none'; + return; + } + + const store = vnode.appContext.config.globalProperties.$store; + + if (!store) { + // Hide by default if store not found (deny by default) + el.style.display = 'none'; + return; + } + + let resource, action; + + // Parse directive value + if (typeof binding.value === 'string') { + // v-can="'diary.write'" + [resource, action] = binding.value.split('.'); + } else if (binding.arg) { + // v-can:diary.write + resource = binding.arg; + action = Object.keys(binding.modifiers)[0] || 'read'; + } else { + console.warn('v-can directive requires resource and action'); + el.style.display = 'none'; + return; + } + + const hasPermission = store.getters.hasPermission(resource, action); + + if (hasPermission) { + el.style.display = ''; + } else { + el.style.display = 'none'; + } +}; + +export const canDirective = { + mounted(el, binding, vnode) { + // Initial check + checkPermission(el, binding, vnode); + + // Set up watcher for permissions changes + if (vnode.appContext && vnode.appContext.config && vnode.appContext.config.globalProperties) { + const store = vnode.appContext.config.globalProperties.$store; + if (store) { + // Watch both permissions and currentClub + el._permissionUnwatch = store.subscribe((mutation) => { + if (mutation.type === 'setPermissions' || mutation.type === 'setClub') { + checkPermission(el, binding, vnode); + } + }); + } + } + }, + + updated(el, binding, vnode) { + checkPermission(el, binding, vnode); + }, + + unmounted(el) { + // Clean up watcher + if (el._permissionUnwatch) { + el._permissionUnwatch(); + delete el._permissionUnwatch; + } + } +}; + +/** + * Directive for admin-only elements + * Usage: v-admin + */ +const checkAdmin = (el, vnode) => { + if (!vnode.appContext || !vnode.appContext.config || !vnode.appContext.config.globalProperties) { + el.style.display = 'none'; + return; + } + + const store = vnode.appContext.config.globalProperties.$store; + + if (!store) { + el.style.display = 'none'; + return; + } + + const isOwner = store.getters.isClubOwner; + const role = store.getters.userRole; + + if (isOwner || role === 'admin') { + el.style.display = ''; + } else { + el.style.display = 'none'; + } +}; + +export const adminDirective = { + mounted(el, binding, vnode) { + checkAdmin(el, vnode); + + if (vnode.appContext && vnode.appContext.config && vnode.appContext.config.globalProperties) { + const store = vnode.appContext.config.globalProperties.$store; + if (store) { + el._adminUnwatch = store.subscribe((mutation) => { + if (mutation.type === 'setPermissions' || mutation.type === 'setClub') { + checkAdmin(el, vnode); + } + }); + } + } + }, + + updated(el, binding, vnode) { + checkAdmin(el, vnode); + }, + + unmounted(el) { + if (el._adminUnwatch) { + el._adminUnwatch(); + delete el._adminUnwatch; + } + } +}; + +/** + * Directive for owner-only elements + * Usage: v-owner + */ +const checkOwner = (el, vnode) => { + if (!vnode.appContext || !vnode.appContext.config || !vnode.appContext.config.globalProperties) { + el.style.display = 'none'; + return; + } + + const store = vnode.appContext.config.globalProperties.$store; + + if (!store) { + el.style.display = 'none'; + return; + } + + const isOwner = store.getters.isClubOwner; + + if (isOwner) { + el.style.display = ''; + } else { + el.style.display = 'none'; + } +}; + +export const ownerDirective = { + mounted(el, binding, vnode) { + checkOwner(el, vnode); + + if (vnode.appContext && vnode.appContext.config && vnode.appContext.config.globalProperties) { + const store = vnode.appContext.config.globalProperties.$store; + if (store) { + el._ownerUnwatch = store.subscribe((mutation) => { + if (mutation.type === 'setPermissions' || mutation.type === 'setClub') { + checkOwner(el, vnode); + } + }); + } + } + }, + + updated(el, binding, vnode) { + checkOwner(el, vnode); + }, + + unmounted(el) { + if (el._ownerUnwatch) { + el._ownerUnwatch(); + delete el._ownerUnwatch; + } + } +}; + +export default { + can: canDirective, + admin: adminDirective, + owner: ownerDirective +}; + diff --git a/frontend/src/main.js b/frontend/src/main.js index 931f116..341a087 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -4,9 +4,16 @@ import router from './router'; import store from './store'; import '@/assets/css/main.scss'; import './assets/css/vue-multiselect.css'; +import permissionDirectives from './directives/permissions.js'; const app = createApp(App); app.config.devtools = true; + +// Register permission directives +app.directive('can', permissionDirectives.can); +app.directive('admin', permissionDirectives.admin); +app.directive('owner', permissionDirectives.owner); + app .use(router) .use(store) diff --git a/frontend/src/router.js b/frontend/src/router.js index 264802f..d38a887 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -15,6 +15,7 @@ import PredefinedActivities from './views/PredefinedActivities.vue'; import OfficialTournaments from './views/OfficialTournaments.vue'; import MyTischtennisAccount from './views/MyTischtennisAccount.vue'; import TeamManagementView from './views/TeamManagementView.vue'; +import PermissionsView from './views/PermissionsView.vue'; import Impressum from './views/Impressum.vue'; import Datenschutz from './views/Datenschutz.vue'; @@ -35,6 +36,7 @@ const routes = [ { path: '/official-tournaments', component: OfficialTournaments }, { path: '/mytischtennis-account', component: MyTischtennisAccount }, { path: '/team-management', component: TeamManagementView }, + { path: '/permissions', component: PermissionsView }, { path: '/impressum', component: Impressum }, { path: '/datenschutz', component: Datenschutz }, ]; diff --git a/frontend/src/store.js b/frontend/src/store.js index 8391a3a..dcdfc06 100644 --- a/frontend/src/store.js +++ b/frontend/src/store.js @@ -14,6 +14,13 @@ const store = createStore({ this.clubs = []; } })(), + permissions: (() => { + try { + return JSON.parse(localStorage.getItem('clubPermissions')) || {}; + } catch (e) { + return {}; + } + })(), // { clubId: { role, isOwner, permissions: {...} } } dialogs: [], // Array von offenen Dialogen dialogCounter: 0, // Zähler für eindeutige Dialog-IDs sidebarCollapsed: (() => { @@ -44,6 +51,17 @@ const store = createStore({ state.clubs = clubs; localStorage.setItem('clubs', JSON.stringify(clubs)); }, + setPermissions(state, { clubId, permissions }) { + state.permissions = { + ...state.permissions, + [clubId]: permissions + }; + localStorage.setItem('clubPermissions', JSON.stringify(state.permissions)); + }, + clearPermissions(state) { + state.permissions = {}; + localStorage.removeItem('clubPermissions'); + }, setSidebarCollapsed(state, collapsed) { state.sidebarCollapsed = collapsed; localStorage.setItem('sidebarCollapsed', collapsed.toString()); @@ -105,12 +123,33 @@ const store = createStore({ logout({ commit }) { commit('clearToken'); commit('clearUsername'); + commit('clearPermissions'); router.push('/login'); // Leitet den Benutzer zur Login-Seite um // window.location.reload() entfernt, um Endlos-Neuladeschleife zu verhindern }, - setCurrentClub({ commit }, club) { + async setCurrentClub({ commit, dispatch }, club) { commit('setClub', club); + // Load permissions for this club + await dispatch('loadPermissions', club); + }, + + async loadPermissions({ commit }, clubId) { + try { + const response = await apiClient.get(`/permissions/${clubId}`); + commit('setPermissions', { clubId, permissions: response.data }); + } catch (error) { + console.error('Error loading permissions:', error); + // Set default permissions (read-only) + commit('setPermissions', { + clubId, + permissions: { + role: 'member', + isOwner: false, + permissions: {} + } + }); + } }, setClubs({ commit }, clubs) { commit('setClubsMutation', clubs); @@ -146,6 +185,31 @@ const store = createStore({ const club = state.clubs.find(club => club.id === parseInt(state.currentClub)); return club ? club.name : ''; }, + // Permission getters + currentPermissions: state => { + if (!state.currentClub) return null; + return state.permissions[state.currentClub] || null; + }, + hasPermission: (state) => (resource, action) => { + if (!state.currentClub) return false; + const perms = state.permissions[state.currentClub]; + if (!perms) return false; + if (perms.isOwner) return true; + if (resource === 'mytischtennis') return true; // MyTischtennis für alle + const resourcePerms = perms.permissions[resource]; + if (!resourcePerms) return false; + return resourcePerms[action] === true; + }, + isClubOwner: state => { + if (!state.currentClub) return false; + const perms = state.permissions[state.currentClub]; + return perms?.isOwner || false; + }, + userRole: state => { + if (!state.currentClub) return null; + const perms = state.permissions[state.currentClub]; + return perms?.role || null; // null wenn nicht geladen, nicht 'member' + }, // Dialog-Getters dialogs: state => state.dialogs, minimizedDialogs: state => state.dialogs.filter(dialog => dialog.isMinimized), diff --git a/frontend/src/views/PendingApprovalsView.vue b/frontend/src/views/PendingApprovalsView.vue index 8cd3249..64a72f2 100644 --- a/frontend/src/views/PendingApprovalsView.vue +++ b/frontend/src/views/PendingApprovalsView.vue @@ -124,7 +124,12 @@ export default { const response = await apiClient.get(`/clubs/pending/${this.currentClub}`); this.pendingUsers = response.data.map(entry => entry.user); } catch (error) { - this.showInfo('Fehler', 'Fehler beim Laden der ausstehenden Anfragen', '', 'error'); + if (error.response?.status === 403) { + await this.showInfo('Keine Berechtigung', 'Sie haben keine Berechtigung, Freigaben zu verwalten.', 'Nur Administratoren können Mitgliedsanfragen bearbeiten.', 'error'); + this.$router.push('/'); + } else { + this.showInfo('Fehler', 'Fehler beim Laden der ausstehenden Anfragen', error.response?.data?.error || '', 'error'); + } } }, async approveUser(userId) { diff --git a/frontend/src/views/PermissionsView.vue b/frontend/src/views/PermissionsView.vue new file mode 100644 index 0000000..19bd988 --- /dev/null +++ b/frontend/src/views/PermissionsView.vue @@ -0,0 +1,631 @@ + + + + + +