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 @@ + + + + + +