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:
@@ -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;
|
||||
}
|
||||
|
||||
66
frontend/src/composables/usePermissions.js
Normal file
66
frontend/src/composables/usePermissions.js
Normal 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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
198
frontend/src/directives/permissions.js
Normal file
198
frontend/src/directives/permissions.js
Normal 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
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
631
frontend/src/views/PermissionsView.vue
Normal file
631
frontend/src/views/PermissionsView.vue
Normal 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">×</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>
|
||||
|
||||
Reference in New Issue
Block a user