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

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>