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.
This commit is contained in:
Torsten Schulz (local)
2025-10-17 09:44:10 +02:00
parent 2dd5e28cbc
commit 56f0ce2f27
31 changed files with 2854 additions and 92 deletions

210
PERMISSIONS_GUIDE.md Normal file
View File

@@ -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
<script setup>
import { usePermissions } from '@/composables/usePermissions.js';
const { can, canWrite, canDelete, isAdmin, isOwner, userRole } = usePermissions();
// Beispiel
if (can('diary', 'write')) {
// Zeige Bearbeitungsbutton
}
</script>
```
### Direktiven verwenden
```vue
<template>
<!-- Nur anzeigen, wenn Schreibrechte für diary vorhanden -->
<button v-can:diary.write>Bearbeiten</button>
<!-- Nur anzeigen, wenn Löschrechte für members vorhanden -->
<button v-can:members.delete>Löschen</button>
<!-- Alternative Syntax -->
<div v-can="'diary.write'">Inhalt nur für Berechtigte</div>
<!-- Nur für Admins -->
<div v-admin>Admin-Bereich</div>
<!-- Nur für Owner -->
<div v-owner>Owner-Bereich</div>
</template>
```
### 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
```

235
PERMISSIONS_MIGRATION.md Normal file
View File

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

View File

@@ -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);

View File

@@ -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
};

View File

@@ -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
};

View File

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

View File

@@ -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`;

View File

@@ -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',

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);

View File

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

View File

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

View File

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

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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')));

View File

@@ -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) {

View File

@@ -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();

View File

@@ -11,7 +11,7 @@ class SchedulerService {
/**
* Start the scheduler
*/
*/
start() {
if (this.isRunning) {
devLog('Scheduler is already running');

View File

@@ -7,6 +7,28 @@
<span>Trainingstagebuch</span>
</router-link>
</h1>
<div v-if="isAuthenticated" class="user-menu">
<button @click="toggleUserDropdown" class="user-info">
<span class="user-icon">👤</span>
<span class="user-email">{{ username }}</span>
<span class="dropdown-arrow"></span>
</button>
<div v-if="userDropdownOpen" class="user-dropdown">
<router-link to="/mytischtennis-account" class="dropdown-item" @click="userDropdownOpen = false">
<span class="dropdown-icon">🔗</span>
myTischtennis-Account
</router-link>
<router-link v-if="canManagePermissions" to="/permissions" class="dropdown-item" @click="userDropdownOpen = false">
<span class="dropdown-icon">🔐</span>
Berechtigungen
</router-link>
<div class="dropdown-divider"></div>
<button @click="logout" class="dropdown-item logout-item">
<span class="dropdown-icon">🚪</span>
Ausloggen
</button>
</div>
</div>
</header>
<div class="app-container">
@@ -33,74 +55,57 @@
<nav v-if="selectedClub" class="nav-menu">
<div class="nav-section">
<h4 class="nav-title">Verwaltung</h4>
<a href="/members" class="nav-link" title="Mitglieder">
<router-link v-if="hasPermission('members', 'read')" to="/members" class="nav-link" title="Mitglieder">
<span class="nav-icon">👥</span>
Mitglieder
</a>
<a href="/diary" class="nav-link" title="Tagebuch">
</router-link>
<router-link v-if="hasPermission('diary', 'read')" to="/diary" class="nav-link" title="Tagebuch">
<span class="nav-icon">📝</span>
Tagebuch
</a>
<a href="/pending-approvals" class="nav-link" title="Freigaben">
</router-link>
<router-link v-if="canManageApprovals" to="/pending-approvals" class="nav-link" title="Freigaben">
<span class="nav-icon"></span>
Freigaben
</a>
<a href="/training-stats" class="nav-link" title="Trainings-Statistik">
</router-link>
<router-link v-if="hasPermission('statistics', 'read')" to="/training-stats" class="nav-link" title="Trainings-Statistik">
<span class="nav-icon">📊</span>
Trainings-Statistik
</a>
</router-link>
</div>
<div class="nav-section">
<h4 class="nav-title">Organisation</h4>
<a href="/schedule" class="nav-link" title="Spielpläne">
<router-link v-if="hasPermission('schedule', 'read')" to="/schedule" class="nav-link" title="Spielpläne">
<span class="nav-icon">📅</span>
Spielpläne
</a>
<a href="/tournaments" class="nav-link" title="Interne Turniere">
</router-link>
<router-link v-if="hasPermission('tournaments', 'read')" to="/tournaments" class="nav-link" title="Interne Turniere">
<span class="nav-icon">🏆</span>
Interne Turniere
</a>
<a href="/official-tournaments" class="nav-link" title="Offizielle Turniere">
</router-link>
<router-link v-if="hasPermission('tournaments', 'read')" to="/official-tournaments" class="nav-link" title="Offizielle Turniere">
<span class="nav-icon">📄</span>
Offizielle Turniere
</a>
<a href="/predefined-activities" class="nav-link" title="Vordefinierte Aktivitäten">
</router-link>
<router-link v-if="hasPermission('predefined_activities', 'read')" to="/predefined-activities" class="nav-link" title="Vordefinierte Aktivitäten">
<span class="nav-icon"></span>
Vordefinierte Aktivitäten
</a>
<a href="/team-management" class="nav-link" title="Team-Verwaltung">
</router-link>
<router-link v-if="hasPermission('teams', 'read')" to="/team-management" class="nav-link" title="Team-Verwaltung">
<span class="nav-icon">👥</span>
Team-Verwaltung
</a>
</router-link>
</div>
</nav>
<nav v-else class="nav-menu"></nav>
<nav class="sidebar-footer">
<div class="nav-section">
<h4 class="nav-title">Einstellungen</h4>
<a href="/mytischtennis-account" class="nav-link" title="myTischtennis-Account">
<span class="nav-icon">🔗</span>
myTischtennis-Account
</a>
</div>
</nav>
<div class="sidebar-footer">
<button @click="logout()" class="btn-secondary logout-btn" title="Ausloggen">
<span class="nav-icon">🚪</span>
Ausloggen
</button>
</div>
</div>
</aside>
<div v-else class="auth-nav">
<div class="auth-links">
<a href="/login" class="btn-primary">Einloggen</a>
<a href="/register" class="btn-secondary">Registrieren</a>
<router-link to="/login" class="btn-primary">Einloggen</router-link>
<router-link to="/register" class="btn-secondary">Registrieren</router-link>
</div>
</div>
@@ -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);
}
};
</script>
@@ -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;
}

View File

@@ -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
};
}

View File

@@ -0,0 +1,198 @@
/**
* Vue directive for permission-based element visibility
* Usage: v-can:resource.action or v-can="'resource.action'"
*
* Examples:
* <button v-can:diary.write>Bearbeiten</button>
* <button v-can:diary.delete>Löschen</button>
* <div v-can="'members.write'">...</div>
*/
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
};

View File

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

View File

@@ -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 },
];

View File

@@ -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),

View File

@@ -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) {

View File

@@ -0,0 +1,631 @@
<template>
<div class="permissions-view">
<div class="header">
<h1>Berechtigungsverwaltung</h1>
<p class="subtitle">Verwalten Sie die Zugriffsrechte für Clubmitglieder</p>
</div>
<div v-if="loading" class="loading">Lade Mitglieder...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else class="permissions-content">
<!-- Role Legend -->
<div class="role-legend">
<h3>Verfügbare Rollen</h3>
<div class="roles-grid">
<div v-for="role in availableRoles" :key="role.value" class="role-card">
<div class="role-name">{{ role.label }}</div>
<div class="role-description">{{ role.description }}</div>
</div>
</div>
</div>
<!-- Members Table -->
<div class="members-table">
<h3>Clubmitglieder</h3>
<table>
<thead>
<tr>
<th>Email</th>
<th>Rolle</th>
<th>Status</th>
<th v-if="!isReadOnly">Aktionen</th>
</tr>
</thead>
<tbody>
<tr v-for="member in members" :key="member.userId">
<td>{{ member.user?.email || 'N/A' }}</td>
<td>
<select
v-if="!member.isOwner && !isReadOnly"
v-model="member.role"
@change="updateMemberRole(member)"
class="role-select"
>
<option v-for="role in availableRoles" :key="role.value" :value="role.value">
{{ role.label }}
</option>
</select>
<span v-else class="role-badge" :class="`role-${member.role}`">
{{ getRoleLabel(member.role) }}
<span v-if="member.isOwner" class="owner-badge">👑 Ersteller</span>
</span>
</td>
<td>
<span
v-if="member.approved !== false"
class="status-badge status-active"
:class="{ 'clickable': !member.isOwner && !isReadOnly }"
@click="!member.isOwner && !isReadOnly ? toggleMemberStatus(member) : null"
:title="!member.isOwner && !isReadOnly ? 'Klicken zum Deaktivieren' : ''"
>
Aktiv
</span>
<span
v-else
class="status-badge status-inactive"
:class="{ 'clickable': !isReadOnly }"
@click="!isReadOnly ? toggleMemberStatus(member) : null"
:title="!isReadOnly ? 'Klicken zum Aktivieren' : ''"
>
Deaktiviert
</span>
</td>
<td v-if="!isReadOnly">
<button
v-if="!member.isOwner"
@click="openPermissionsDialog(member)"
class="btn-small"
>
Anpassen
</button>
<span v-else class="muted"></span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Custom Permissions Dialog -->
<div v-if="selectedMember" class="dialog-overlay" @click.self="closePermissionsDialog">
<div class="dialog-content">
<div class="dialog-header">
<h2>Berechtigungen für {{ selectedMember.user?.email }}</h2>
<button @click="closePermissionsDialog" class="close-btn">&times;</button>
</div>
<div class="dialog-body">
<p class="info-text">
Basis-Rolle: <strong>{{ getRoleLabel(selectedMember.role) }}</strong><br>
Hier können Sie individuelle Anpassungen vornehmen.
</p>
<div class="permissions-grid">
<div v-for="(resource, key) in permissionStructure" :key="key" class="permission-group">
<h4>{{ resource.label }}</h4>
<div class="permission-actions">
<label v-for="action in resource.actions" :key="action" class="permission-checkbox">
<input
type="checkbox"
v-model="customPermissions[key][action]"
/>
{{ getActionLabel(action) }}
</label>
</div>
</div>
</div>
</div>
<div class="dialog-footer">
<button @click="closePermissionsDialog" class="btn-secondary">Abbrechen</button>
<button @click="saveCustomPermissions" class="btn-primary">Speichern</button>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref, computed, onMounted } from 'vue';
import { useStore } from 'vuex';
import apiClient from '../apiClient.js';
import { usePermissions } from '../composables/usePermissions.js';
export default {
name: 'PermissionsView',
setup() {
const store = useStore();
const { isOwner, isAdmin, can } = usePermissions();
const currentClub = computed(() => store.getters.currentClub);
const members = ref([]);
const availableRoles = ref([]);
const permissionStructure = ref({});
const loading = ref(true);
const error = ref(null);
const selectedMember = ref(null);
const customPermissions = ref({});
const isReadOnly = computed(() => {
return !can('permissions', 'write');
});
const loadData = async () => {
loading.value = true;
error.value = null;
try {
// Load available roles
const rolesResponse = await apiClient.get('/permissions/roles/available');
availableRoles.value = rolesResponse.data;
// Load permission structure
const structureResponse = await apiClient.get('/permissions/structure/all');
permissionStructure.value = structureResponse.data;
// Load members with permissions
const membersResponse = await apiClient.get(`/permissions/${currentClub.value}/members`);
members.value = membersResponse.data;
} catch (err) {
console.error('Error loading permissions data:', err);
if (err.response?.status === 403) {
error.value = 'Keine Berechtigung: Nur Administratoren können Berechtigungen verwalten.';
// Redirect nach kurzer Verzögerung
setTimeout(() => {
window.location.href = '/';
}, 2000);
} else {
error.value = err.response?.data?.error || 'Fehler beim Laden der Daten';
}
} finally {
loading.value = false;
}
};
const updateMemberRole = async (member) => {
try {
await apiClient.put(
`/permissions/${currentClub.value}/user/${member.userId}/role`,
{ role: member.role }
);
// Reload data to get updated permissions
await loadData();
} catch (err) {
console.error('Error updating role:', err);
alert(err.response?.data?.error || 'Fehler beim Aktualisieren der Rolle');
// Reload to revert changes
await loadData();
}
};
const toggleMemberStatus = async (member) => {
const newStatus = member.approved === false ? true : false;
const action = newStatus ? 'aktivieren' : 'deaktivieren';
if (!confirm(`Möchten Sie ${member.user?.email} wirklich ${action}?`)) {
return;
}
try {
await apiClient.put(
`/permissions/${currentClub.value}/user/${member.userId}/status`,
{ approved: newStatus }
);
// Update local state - visual feedback happens automatically
member.approved = newStatus;
} catch (err) {
console.error('Error updating status:', err);
// Reload to revert changes on error
await loadData();
}
};
const openPermissionsDialog = (member) => {
selectedMember.value = member;
// Initialize custom permissions
customPermissions.value = {};
for (const resource in permissionStructure.value) {
customPermissions.value[resource] = {};
const effectivePerms = member.effectivePermissions[resource] || {};
for (const action of permissionStructure.value[resource].actions) {
customPermissions.value[resource][action] = effectivePerms[action] || false;
}
}
};
const closePermissionsDialog = () => {
selectedMember.value = null;
customPermissions.value = {};
};
const saveCustomPermissions = async () => {
try {
await apiClient.put(
`/permissions/${currentClub.value}/user/${selectedMember.value.userId}/permissions`,
{ permissions: customPermissions.value }
);
closePermissionsDialog();
await loadData();
} catch (err) {
console.error('Error saving permissions:', err);
alert(err.response?.data?.error || 'Fehler beim Speichern der Berechtigungen');
}
};
const getRoleLabel = (roleValue) => {
const role = availableRoles.value.find(r => r.value === roleValue);
return role ? role.label : roleValue;
};
const getActionLabel = (action) => {
const labels = {
read: 'Lesen',
write: 'Schreiben',
delete: 'Löschen'
};
return labels[action] || action;
};
onMounted(() => {
if (!currentClub.value) {
error.value = 'Bitte wählen Sie einen Club aus';
loading.value = false;
return;
}
loadData();
});
return {
loading,
error,
members,
availableRoles,
permissionStructure,
selectedMember,
customPermissions,
isReadOnly,
isOwner,
isAdmin,
updateMemberRole,
toggleMemberStatus,
openPermissionsDialog,
closePermissionsDialog,
saveCustomPermissions,
getRoleLabel,
getActionLabel
};
}
};
</script>
<style scoped>
.permissions-view {
padding: 20px;
max-width: 1400px;
margin: 0 auto;
}
.header {
margin-bottom: 30px;
}
.header h1 {
margin: 0 0 10px 0;
color: #2c3e50;
}
.subtitle {
color: #7f8c8d;
margin: 0;
}
.loading, .error {
padding: 20px;
text-align: center;
border-radius: 8px;
}
.error {
background-color: #fee;
color: #c33;
}
.role-legend {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-bottom: 30px;
}
.role-legend h3 {
margin-top: 0;
color: #2c3e50;
}
.roles-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 15px;
margin-top: 15px;
}
.role-card {
background: white;
padding: 15px;
border-radius: 6px;
border: 1px solid #e0e0e0;
}
.role-name {
font-weight: 600;
color: #2c3e50;
margin-bottom: 5px;
}
.role-description {
font-size: 0.9em;
color: #666;
}
.members-table {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.members-table h3 {
margin-top: 0;
color: #2c3e50;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 15px;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
th {
background-color: #f8f9fa;
font-weight: 600;
color: #2c3e50;
}
.role-select {
padding: 6px 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.95em;
}
.role-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
border-radius: 4px;
font-size: 0.9em;
font-weight: 500;
}
.role-admin {
background-color: #e3f2fd;
color: #1976d2;
}
.role-trainer {
background-color: #f3e5f5;
color: #7b1fa2;
}
.role-team_manager {
background-color: #fff3e0;
color: #f57c00;
}
.role-member {
background-color: #f5f5f5;
color: #616161;
}
.owner-badge {
font-size: 0.9em;
}
.status-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 4px;
font-size: 0.85em;
font-weight: 500;
}
.status-active {
background-color: #e8f5e9;
color: #2e7d32;
}
.status-inactive {
background-color: #ffebee;
color: #c62828;
}
.status-badge.clickable {
cursor: pointer;
transition: opacity 0.2s;
}
.status-badge.clickable:hover {
opacity: 0.7;
}
.btn-small {
padding: 6px 12px;
background-color: #2196f3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
}
.btn-small:hover {
background-color: #1976d2;
}
.muted {
color: #999;
}
/* Dialog Styles */
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.dialog-content {
background: white;
border-radius: 8px;
width: 90%;
max-width: 800px;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #e0e0e0;
}
.dialog-header h2 {
margin: 0;
color: #2c3e50;
}
.close-btn {
background: none;
border: none;
font-size: 28px;
cursor: pointer;
color: #666;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
color: #333;
}
.dialog-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.info-text {
background-color: #f8f9fa;
padding: 12px;
border-radius: 4px;
margin-bottom: 20px;
}
.permissions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
}
.permission-group {
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 15px;
}
.permission-group h4 {
margin: 0 0 12px 0;
color: #2c3e50;
font-size: 1em;
}
.permission-actions {
display: flex;
flex-direction: column;
gap: 10px;
}
.permission-checkbox {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 0.95em;
}
.permission-checkbox input[type="checkbox"] {
cursor: pointer;
}
.dialog-footer {
padding: 15px 20px;
border-top: 1px solid #e0e0e0;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.btn-primary, .btn-secondary {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
}
.btn-primary {
background-color: #2196f3;
color: white;
}
.btn-primary:hover {
background-color: #1976d2;
}
.btn-secondary {
background-color: #f5f5f5;
color: #333;
}
.btn-secondary:hover {
background-color: #e0e0e0;
}
</style>