feat: Implement user management functionality with CRUD operations
- Added UserManagement.vue component for managing users with PrimeVue DataTable. - Integrated API endpoints for user CRUD operations in the backend. - Implemented user password hashing using a custom state processor. - Updated router to include user management route with admin access control. - Enhanced Dashboard.vue and app.scss for improved styling and responsiveness. - Documented user management features and API usage in USER-CRUD.md.
This commit is contained in:
parent
c07c90cdaa
commit
fcfda9d9be
57
README.md
57
README.md
@ -10,6 +10,9 @@ Eine moderne, modulare CRM-Anwendung basierend auf Symfony 7.1 LTS, Vue.js 3 und
|
||||
- **API Platform** - RESTful API mit OpenAPI-Dokumentation
|
||||
- **MariaDB** - Zuverlässige relationale Datenbank
|
||||
- **Webpack Encore** - Asset-Management und Hot Module Replacement
|
||||
- **Modulares Berechtigungssystem** - Flexible Rollen mit Modul-basierter Rechteverwaltung
|
||||
- **User CRUD** - Vollständige Benutzerverwaltung via API Platform
|
||||
- **Login-System** - Form-basierte Authentifizierung mit Remember Me
|
||||
|
||||
## 📋 Voraussetzungen
|
||||
|
||||
@ -39,10 +42,20 @@ cp .env .env.local
|
||||
# 5. Datenbank erstellen
|
||||
php bin/console doctrine:database:create
|
||||
|
||||
# 6. Datenbank-Schema erstellen (wenn Migrations vorhanden)
|
||||
# 6. Datenbank-Schema erstellen
|
||||
php bin/console doctrine:migrations:migrate
|
||||
|
||||
# 7. Test-Daten laden (optional)
|
||||
php bin/console doctrine:fixtures:load
|
||||
```
|
||||
|
||||
### Testbenutzer
|
||||
|
||||
Nach dem Laden der Fixtures stehen folgende Testbenutzer zur Verfügung:
|
||||
|
||||
- **Administrator**: admin@mycrm.local / admin123
|
||||
- **Vertriebsmitarbeiter**: sales@mycrm.local / sales123
|
||||
|
||||
## 🎯 Entwicklung
|
||||
|
||||
### Backend-Server starten
|
||||
@ -127,17 +140,20 @@ npm run watch
|
||||
## 📱 Module
|
||||
|
||||
- **Dashboard** - Übersicht und KPIs
|
||||
- **Kontakte** - Kontaktverwaltung mit Status-Tracking
|
||||
- **Unternehmen** - Firmendatenbank
|
||||
- **Deals** - Sales-Pipeline Management
|
||||
- **Aktivitäten** - Interaktions-Historie
|
||||
- **Kontakte** - Kontaktverwaltung mit Status-Tracking (in Entwicklung)
|
||||
- **Unternehmen** - Firmendatenbank (in Entwicklung)
|
||||
- **Deals** - Sales-Pipeline Management (in Entwicklung)
|
||||
- **Aktivitäten** - Interaktions-Historie (in Entwicklung)
|
||||
- **Benutzerverwaltung** - CRUD für User (✅ implementiert)
|
||||
|
||||
## 🔐 Sicherheit
|
||||
|
||||
- Symfony Security Component mit Voter-Pattern
|
||||
- CSRF-Schutz
|
||||
- Password Hashing mit Symfony Password Hasher
|
||||
- API-Authentifizierung (JWT/API Keys)
|
||||
- CSRF-Schutz aktiviert
|
||||
- Password Hashing mit Symfony PasswordHasher (bcrypt)
|
||||
- Session-basierte Authentifizierung mit Remember Me (7 Tage)
|
||||
- API-Sicherheit mit granularen Berechtigungen (ROLE_ADMIN, object == user)
|
||||
- Modulares Berechtigungssystem mit 6 Aktionstypen (View, Create, Edit, Delete, Export, Manage)
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
@ -154,11 +170,14 @@ php bin/console doctrine:schema:validate
|
||||
|
||||
## 📚 Weitere Dokumentationen
|
||||
|
||||
- [LOGIN.md](docs/LOGIN.md) - Authentifizierungssystem
|
||||
- [PERMISSIONS.md](docs/PERMISSIONS.md) - Modulares Berechtigungssystem
|
||||
- [USER-CRUD.md](docs/USER-CRUD.md) - Benutzerverwaltung mit API Platform
|
||||
- [AI Agent Instructions](.github/copilot-instructions.md) - Entwickler-Richtlinien
|
||||
- [Symfony Documentation](https://symfony.com/doc/current/index.html)
|
||||
- [API Platform Documentation](https://api-platform.com/docs/)
|
||||
- [Vue.js Guide](https://vuejs.org/guide/)
|
||||
- [PrimeVue Documentation](https://primevue.org/)
|
||||
- [AI Agent Instructions](.github/copilot-instructions.md)
|
||||
|
||||
## 🤝 Entwicklungs-Konventionen
|
||||
|
||||
@ -178,10 +197,20 @@ Dein Team
|
||||
|
||||
---
|
||||
|
||||
**Status:** ✅ Projekt initialisiert und bereit für die Entwicklung!
|
||||
**Status:** ✅ Grundsystem implementiert - Ready for CRM-Module!
|
||||
|
||||
**Implementiert:**
|
||||
- ✅ Projekt-Setup (Symfony 7.1 + Vue.js 3 + PrimeVue)
|
||||
- ✅ Modulares Berechtigungssystem (User, Role, Module, RolePermission)
|
||||
- ✅ Login-System mit Remember Me
|
||||
- ✅ User-CRUD mit API Platform
|
||||
- ✅ Vue.js Frontend mit PrimeVue DataTable, Dialogs, Forms
|
||||
- ✅ Password-Hashing via State Processor
|
||||
- ✅ Admin-Navigation und Schutz
|
||||
|
||||
**Next Steps:**
|
||||
1. Erste Entity erstellen: `php bin/console make:entity Contact`
|
||||
2. Migration generieren: `php bin/console make:migration`
|
||||
3. Migration ausführen: `php bin/console doctrine:migrations:migrate`
|
||||
4. API Resource erstellen: `php bin/console make:entity --api-resource`
|
||||
1. Contact-Entity erstellen: `php bin/console make:entity Contact`
|
||||
2. Company-Entity erstellen: `php bin/console make:entity Company`
|
||||
3. Deal-Entity mit Pipeline-Stages erstellen
|
||||
4. Activity-Entity für Interaktionshistorie
|
||||
5. Vue.js-Komponenten für Contact/Company/Deal-Management
|
||||
|
||||
@ -11,6 +11,8 @@ import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import PrimeVue from 'primevue/config';
|
||||
import Aura from '@primevue/themes/aura';
|
||||
import ToastService from 'primevue/toastservice';
|
||||
import Tooltip from 'primevue/tooltip';
|
||||
import router from './js/router';
|
||||
import App from './js/App.vue';
|
||||
import { useAuthStore } from './js/stores/auth';
|
||||
@ -29,10 +31,13 @@ app.use(PrimeVue, {
|
||||
theme: {
|
||||
preset: Aura,
|
||||
options: {
|
||||
darkModeSelector: false, // Can be customized later
|
||||
darkModeSelector: false,
|
||||
cssLayer: false
|
||||
}
|
||||
}
|
||||
});
|
||||
app.use(ToastService);
|
||||
app.directive('tooltip', Tooltip);
|
||||
|
||||
app.mount('#app');
|
||||
|
||||
|
||||
@ -1,129 +1,329 @@
|
||||
<template>
|
||||
<div id="crm-app">
|
||||
<Toast />
|
||||
<div id="app-layout">
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<h1>📊 myCRM</h1>
|
||||
</div>
|
||||
|
||||
<nav class="header-nav">
|
||||
<router-link to="/">Dashboard</router-link>
|
||||
<router-link to="/contacts">Kontakte</router-link>
|
||||
<router-link to="/companies">Unternehmen</router-link>
|
||||
<router-link to="/deals">Deals</router-link>
|
||||
</nav>
|
||||
|
||||
<div class="header-right">
|
||||
<div class="header-content">
|
||||
<div class="logo">
|
||||
<i class="pi pi-database"></i>
|
||||
<span>myCRM</span>
|
||||
</div>
|
||||
|
||||
<button class="hamburger" @click="toggleMobileMenu" :class="{ active: mobileMenuOpen }">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</button>
|
||||
|
||||
<nav :class="{ open: mobileMenuOpen }">
|
||||
<RouterLink to="/" @click="closeMobileMenu">
|
||||
<i class="pi pi-home"></i> Dashboard
|
||||
</RouterLink>
|
||||
<RouterLink to="/contacts" @click="closeMobileMenu">
|
||||
<i class="pi pi-users"></i> Kontakte
|
||||
</RouterLink>
|
||||
<RouterLink to="/companies" @click="closeMobileMenu">
|
||||
<i class="pi pi-building"></i> Unternehmen
|
||||
</RouterLink>
|
||||
<RouterLink to="/deals" @click="closeMobileMenu">
|
||||
<i class="pi pi-chart-line"></i> Deals
|
||||
</RouterLink>
|
||||
<RouterLink to="/users" v-if="authStore.isAdmin" @click="closeMobileMenu">
|
||||
<i class="pi pi-user-edit"></i> Benutzerverwaltung
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<div class="user-info" v-if="authStore.isAuthenticated">
|
||||
<i class="pi pi-user"></i>
|
||||
<span>{{ authStore.fullName }}</span>
|
||||
<Button
|
||||
icon="pi pi-sign-out"
|
||||
severity="secondary"
|
||||
text
|
||||
size="small"
|
||||
@click="handleLogout"
|
||||
label="Logout"
|
||||
/>
|
||||
<a href="/logout" class="logout-link">
|
||||
<i class="pi pi-sign-out"></i> Logout
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="app-main">
|
||||
<router-view />
|
||||
<RouterView />
|
||||
</main>
|
||||
|
||||
<footer class="app-footer">
|
||||
<p>© {{ currentYear }} myCRM - Moderne CRM-Lösung</p>
|
||||
<p>© {{ new Date().getFullYear() }} myCRM</p>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import { RouterLink, RouterView } from 'vue-router';
|
||||
import Toast from 'primevue/toast';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
import Button from 'primevue/button';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const currentYear = computed(() => new Date().getFullYear());
|
||||
authStore.initializeFromElement(document.getElementById('app'));
|
||||
|
||||
const handleLogout = () => {
|
||||
if (confirm('Möchten Sie sich wirklich abmelden?')) {
|
||||
authStore.logout();
|
||||
}
|
||||
const mobileMenuOpen = ref(false);
|
||||
|
||||
const toggleMobileMenu = () => {
|
||||
mobileMenuOpen.value = !mobileMenuOpen.value;
|
||||
};
|
||||
|
||||
const closeMobileMenu = () => {
|
||||
mobileMenuOpen.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-header {
|
||||
background: #2563eb;
|
||||
color: white;
|
||||
padding: 1rem 2rem;
|
||||
#app-layout {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.header-left {
|
||||
.header-content {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
@media (min-width: 768px) {
|
||||
padding: 1rem 2rem;
|
||||
gap: 2rem;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
.logo {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
z-index: 1001;
|
||||
|
||||
a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
i {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
font-size: 1.5rem;
|
||||
|
||||
&:hover, &.router-link-active {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
i {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
.user-info {
|
||||
.hamburger {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
z-index: 1001;
|
||||
order: 2;
|
||||
margin-left: auto;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
span {
|
||||
width: 2rem;
|
||||
height: 0.2rem;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
transition: all 0.3s linear;
|
||||
position: relative;
|
||||
transform-origin: 1px;
|
||||
|
||||
&:first-child {
|
||||
transform: rotate(0);
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
transform: rotate(0);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
span:first-child {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
span:nth-child(2) {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
span:nth-child(3) {
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nav {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100vh;
|
||||
width: 70%;
|
||||
max-width: 300px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
flex-direction: column;
|
||||
padding: 5rem 1.5rem 2rem;
|
||||
gap: 0.5rem;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.2);
|
||||
z-index: 1000;
|
||||
|
||||
&.open {
|
||||
display: flex;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
display: flex;
|
||||
position: static;
|
||||
height: auto;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
flex-direction: row;
|
||||
padding: 0;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
order: 1;
|
||||
}
|
||||
|
||||
a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
gap: 0.5rem;
|
||||
font-size: 1rem;
|
||||
white-space: nowrap;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding: 0.6rem 1.2rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
span {
|
||||
font-weight: 500;
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
&.router-link-active {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
order: 3;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
gap: 1rem;
|
||||
order: 2;
|
||||
}
|
||||
|
||||
span {
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
display: none;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
display: inline;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.logout-link {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 0.5rem 0.8rem;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding: 0.5rem 1rem;
|
||||
gap: 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.app-main {
|
||||
min-height: calc(100vh - 150px);
|
||||
padding: 2rem;
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
max-width: 1400px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
background: #f3f4f6;
|
||||
padding: 1rem 2rem;
|
||||
background: #f8f9fa;
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
color: #6c757d;
|
||||
border-top: 1px solid #dee2e6;
|
||||
font-size: 0.875rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,33 +1,21 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import Dashboard from './views/Dashboard.vue';
|
||||
import ContactList from './views/ContactList.vue';
|
||||
import CompanyList from './views/CompanyList.vue';
|
||||
import DealList from './views/DealList.vue';
|
||||
import UserManagement from './views/UserManagement.vue';
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Dashboard',
|
||||
component: Dashboard
|
||||
},
|
||||
{
|
||||
path: '/contacts',
|
||||
name: 'ContactList',
|
||||
component: ContactList
|
||||
},
|
||||
{
|
||||
path: '/companies',
|
||||
name: 'CompanyList',
|
||||
component: () => import('./views/CompanyList.vue')
|
||||
},
|
||||
{
|
||||
path: '/deals',
|
||||
name: 'DealList',
|
||||
component: () => import('./views/DealList.vue')
|
||||
}
|
||||
{ path: '/', name: 'dashboard', component: Dashboard },
|
||||
{ path: '/contacts', name: 'contacts', component: ContactList },
|
||||
{ path: '/companies', name: 'companies', component: CompanyList },
|
||||
{ path: '/deals', name: 'deals', component: DealList },
|
||||
{ path: '/users', name: 'users', component: UserManagement, meta: { requiresAdmin: true } },
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@ -43,13 +43,36 @@ import Card from 'primevue/card';
|
||||
.dashboard {
|
||||
h2 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.5rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.95rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
|
||||
@media (min-width: 640px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
551
assets/js/views/UserManagement.vue
Normal file
551
assets/js/views/UserManagement.vue
Normal file
@ -0,0 +1,551 @@
|
||||
<template>
|
||||
<div class="user-management">
|
||||
<div class="page-header">
|
||||
<h2>Benutzerverwaltung</h2>
|
||||
<Button
|
||||
label="Neuer Benutzer"
|
||||
icon="pi pi-plus"
|
||||
@click="openCreateDialog"
|
||||
v-if="authStore.isAdmin"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:value="users"
|
||||
:loading="loading"
|
||||
paginator
|
||||
:rows="10"
|
||||
:rowsPerPageOptions="[10, 20, 50]"
|
||||
tableStyle="min-width: 50rem"
|
||||
stripedRows
|
||||
sortField="lastName"
|
||||
:sortOrder="1"
|
||||
>
|
||||
<Column field="id" header="ID" sortable style="width: 80px"></Column>
|
||||
<Column field="lastName" header="Nachname" sortable></Column>
|
||||
<Column field="firstName" header="Vorname" sortable></Column>
|
||||
<Column field="email" header="E-Mail" sortable></Column>
|
||||
<Column field="isActive" header="Status" sortable style="width: 120px">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.isActive ? 'Aktiv' : 'Inaktiv'" :severity="data.isActive ? 'success' : 'danger'" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="roles" header="Rollen" style="width: 150px">
|
||||
<template #body="{ data }">
|
||||
<Tag v-for="role in data.roles" :key="role" :value="role" severity="info" class="mr-1" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="lastLoginAt" header="Letzter Login" sortable style="width: 180px">
|
||||
<template #body="{ data }">
|
||||
{{ formatDate(data.lastLoginAt) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Aktionen" style="width: 150px">
|
||||
<template #body="{ data }">
|
||||
<div class="action-buttons">
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
severity="info"
|
||||
text
|
||||
rounded
|
||||
@click="openEditDialog(data)"
|
||||
v-tooltip.top="'Bearbeiten'"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
text
|
||||
rounded
|
||||
@click="confirmDelete(data)"
|
||||
v-tooltip.top="'Löschen'"
|
||||
:disabled="data.id === authStore.user?.id"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
|
||||
<!-- Create/Edit Dialog -->
|
||||
<Dialog
|
||||
v-model:visible="dialogVisible"
|
||||
:header="isEditMode ? 'Benutzer bearbeiten' : 'Neuer Benutzer'"
|
||||
:modal="true"
|
||||
:style="{ width: '600px' }"
|
||||
>
|
||||
<div class="user-form">
|
||||
<div class="form-row">
|
||||
<div class="form-field">
|
||||
<label for="firstName">Vorname *</label>
|
||||
<InputText
|
||||
id="firstName"
|
||||
v-model="formData.firstName"
|
||||
:class="{ 'p-invalid': errors.firstName }"
|
||||
placeholder="Max"
|
||||
/>
|
||||
<small class="p-error" v-if="errors.firstName">{{ errors.firstName }}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="lastName">Nachname *</label>
|
||||
<InputText
|
||||
id="lastName"
|
||||
v-model="formData.lastName"
|
||||
:class="{ 'p-invalid': errors.lastName }"
|
||||
placeholder="Mustermann"
|
||||
/>
|
||||
<small class="p-error" v-if="errors.lastName">{{ errors.lastName }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="email">E-Mail *</label>
|
||||
<InputText
|
||||
id="email"
|
||||
v-model="formData.email"
|
||||
type="email"
|
||||
:class="{ 'p-invalid': errors.email }"
|
||||
placeholder="max@example.com"
|
||||
/>
|
||||
<small class="p-error" v-if="errors.email">{{ errors.email }}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-field" v-if="!isEditMode || showPasswordField">
|
||||
<label for="password">{{ isEditMode ? 'Neues Passwort' : 'Passwort' }} {{ isEditMode ? '' : '*' }}</label>
|
||||
<Password
|
||||
id="password"
|
||||
v-model="formData.plainPassword"
|
||||
toggleMask
|
||||
:class="{ 'p-invalid': errors.plainPassword }"
|
||||
:feedback="false"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<small class="p-error" v-if="errors.plainPassword">{{ errors.plainPassword }}</small>
|
||||
<Button
|
||||
v-if="isEditMode && !showPasswordField"
|
||||
label="Passwort ändern"
|
||||
text
|
||||
size="small"
|
||||
@click="showPasswordField = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label>Symfony Rollen</label>
|
||||
<div class="checkbox-group">
|
||||
<div class="checkbox-item">
|
||||
<Checkbox
|
||||
v-model="formData.roles"
|
||||
inputId="role_user"
|
||||
value="ROLE_USER"
|
||||
/>
|
||||
<label for="role_user">ROLE_USER</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<Checkbox
|
||||
v-model="formData.roles"
|
||||
inputId="role_admin"
|
||||
value="ROLE_ADMIN"
|
||||
/>
|
||||
<label for="role_admin">ROLE_ADMIN</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<div class="checkbox-item">
|
||||
<Checkbox
|
||||
v-model="formData.isActive"
|
||||
inputId="isActive"
|
||||
:binary="true"
|
||||
/>
|
||||
<label for="isActive">Benutzer ist aktiv</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" severity="secondary" @click="dialogVisible = false" />
|
||||
<Button label="Speichern" @click="saveUser" :loading="saving" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Delete Confirmation -->
|
||||
<Dialog
|
||||
v-model:visible="deleteDialogVisible"
|
||||
header="Benutzer löschen"
|
||||
:modal="true"
|
||||
:style="{ width: '450px' }"
|
||||
>
|
||||
<div class="confirmation-content">
|
||||
<i class="pi pi-exclamation-triangle" style="font-size: 3rem; color: var(--red-500)"></i>
|
||||
<p>Möchten Sie den Benutzer <strong>{{ userToDelete?.firstName }} {{ userToDelete?.lastName }}</strong> wirklich löschen?</p>
|
||||
<p class="text-secondary">Diese Aktion kann nicht rückgängig gemacht werden.</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" severity="secondary" @click="deleteDialogVisible = false" />
|
||||
<Button label="Löschen" severity="danger" @click="deleteUser" :loading="deleting" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
import Button from 'primevue/button';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Password from 'primevue/password';
|
||||
import Checkbox from 'primevue/checkbox';
|
||||
import Tag from 'primevue/tag';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const toast = useToast();
|
||||
|
||||
const users = ref([]);
|
||||
const loading = ref(false);
|
||||
const dialogVisible = ref(false);
|
||||
const deleteDialogVisible = ref(false);
|
||||
const isEditMode = ref(false);
|
||||
const saving = ref(false);
|
||||
const deleting = ref(false);
|
||||
const showPasswordField = ref(false);
|
||||
const userToDelete = ref(null);
|
||||
|
||||
const formData = ref({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
plainPassword: '',
|
||||
roles: ['ROLE_USER'],
|
||||
isActive: true
|
||||
});
|
||||
|
||||
const errors = ref({});
|
||||
|
||||
const fetchUsers = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await fetch('/api/users');
|
||||
const data = await response.json();
|
||||
users.value = data['hydra:member'] || data;
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Benutzer konnten nicht geladen werden', life: 3000 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateDialog = () => {
|
||||
isEditMode.value = false;
|
||||
showPasswordField.value = false;
|
||||
formData.value = {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
plainPassword: '',
|
||||
roles: ['ROLE_USER'],
|
||||
isActive: true
|
||||
};
|
||||
errors.value = {};
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const openEditDialog = (user) => {
|
||||
isEditMode.value = true;
|
||||
showPasswordField.value = false;
|
||||
formData.value = {
|
||||
...user,
|
||||
plainPassword: '',
|
||||
roles: user.roles || ['ROLE_USER']
|
||||
};
|
||||
errors.value = {};
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
errors.value = {};
|
||||
|
||||
if (!formData.value.firstName) errors.value.firstName = 'Vorname ist erforderlich';
|
||||
if (!formData.value.lastName) errors.value.lastName = 'Nachname ist erforderlich';
|
||||
if (!formData.value.email) errors.value.email = 'E-Mail ist erforderlich';
|
||||
if (!isEditMode.value && !formData.value.plainPassword) {
|
||||
errors.value.plainPassword = 'Passwort ist erforderlich';
|
||||
}
|
||||
|
||||
return Object.keys(errors.value).length === 0;
|
||||
};
|
||||
|
||||
const saveUser = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = { ...formData.value };
|
||||
if (isEditMode.value && !showPasswordField.value) {
|
||||
delete payload.plainPassword;
|
||||
}
|
||||
if (!payload.plainPassword) {
|
||||
delete payload.plainPassword;
|
||||
}
|
||||
|
||||
const url = isEditMode.value ? `/api/users/${formData.value.id}` : '/api/users';
|
||||
const method = isEditMode.value ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Fehler beim Speichern');
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Erfolg',
|
||||
detail: `Benutzer wurde ${isEditMode.value ? 'aktualisiert' : 'erstellt'}`,
|
||||
life: 3000
|
||||
});
|
||||
|
||||
dialogVisible.value = false;
|
||||
await fetchUsers();
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Benutzer konnte nicht gespeichert werden', life: 3000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = (user) => {
|
||||
userToDelete.value = user;
|
||||
deleteDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const deleteUser = async () => {
|
||||
deleting.value = true;
|
||||
try {
|
||||
const response = await fetch(`/api/users/${userToDelete.value.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Fehler beim Löschen');
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Erfolg', detail: 'Benutzer wurde gelöscht', life: 3000 });
|
||||
deleteDialogVisible.value = false;
|
||||
await fetchUsers();
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Benutzer konnte nicht gelöscht werden', life: 3000 });
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-';
|
||||
return new Date(dateString).toLocaleDateString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchUsers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-management {
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.user-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
label {
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
font-size: 0.9rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.p-error {
|
||||
color: var(--red-500);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.checkbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
label {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.confirmation-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
padding: 1rem 0;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 2.5rem;
|
||||
color: var(--red-500);
|
||||
|
||||
@media (min-width: 768px) {
|
||||
font-size: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: #6b7280;
|
||||
font-size: 0.85rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive DataTable overrides
|
||||
:deep(.p-datatable) {
|
||||
font-size: 0.875rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.p-datatable-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.5rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive Dialog
|
||||
:deep(.p-dialog) {
|
||||
width: 95vw !important;
|
||||
max-width: 600px;
|
||||
|
||||
.p-dialog-header {
|
||||
padding: 1rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.p-dialog-content {
|
||||
padding: 1rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.p-dialog-footer {
|
||||
padding: 1rem;
|
||||
gap: 0.5rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
|
||||
.p-button {
|
||||
flex: 1;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -10,6 +10,8 @@ body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: #f9fafb;
|
||||
color: #1f2937;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
@ -17,8 +19,126 @@ body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* PrimeVue Theme Overrides */
|
||||
:root {
|
||||
--primary-color: #2563eb;
|
||||
--primary-color-text: #ffffff;
|
||||
/* PrimeVue Component Styling Fixes */
|
||||
.p-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.p-card-title {
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.p-card-content {
|
||||
color: #4b5563;
|
||||
font-size: 0.875rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.p-datatable {
|
||||
.p-datatable-header {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.p-datatable-thead > tr > th {
|
||||
background: #f9fafb;
|
||||
color: #374151;
|
||||
border: 1px solid #e5e7eb;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.p-datatable-tbody > tr {
|
||||
background: white;
|
||||
color: #1f2937;
|
||||
|
||||
&:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
> td {
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.p-button {
|
||||
&.p-button-info {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
|
||||
&:hover {
|
||||
background: #2563eb;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
}
|
||||
|
||||
&.p-button-danger {
|
||||
background: #ef4444;
|
||||
border-color: #ef4444;
|
||||
|
||||
&:hover {
|
||||
background: #dc2626;
|
||||
border-color: #dc2626;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.p-dialog {
|
||||
.p-dialog-header {
|
||||
background: white;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.p-dialog-content {
|
||||
background: white;
|
||||
color: #1f2937;
|
||||
}
|
||||
}
|
||||
|
||||
.p-inputtext {
|
||||
background: white;
|
||||
color: #1f2937;
|
||||
border: 1px solid #d1d5db;
|
||||
|
||||
&:enabled:focus {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 0.2rem rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.p-checkbox {
|
||||
.p-checkbox-box {
|
||||
background: white;
|
||||
border: 1px solid #d1d5db;
|
||||
|
||||
&.p-highlight {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.p-tag {
|
||||
&.p-tag-success {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
&.p-tag-danger {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
&.p-tag-info {
|
||||
background: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,3 +22,9 @@ services:
|
||||
|
||||
# add more service definitions when explicit configuration is needed
|
||||
# please note that last definitions always *replace* previous ones
|
||||
|
||||
# API Platform State Processor for User Password Hashing
|
||||
App\State\UserPasswordHasher:
|
||||
decorates: 'api_platform.doctrine.orm.state.persist_processor'
|
||||
arguments:
|
||||
$processor: '@.inner'
|
||||
|
||||
275
docs/USER-CRUD.md
Normal file
275
docs/USER-CRUD.md
Normal file
@ -0,0 +1,275 @@
|
||||
# Benutzerverwaltung (User CRUD)
|
||||
|
||||
## Übersicht
|
||||
|
||||
Das User-CRUD-System ermöglicht Administratoren die vollständige Verwaltung von Benutzern über eine moderne Vue.js-Oberfläche mit PrimeVue-Komponenten.
|
||||
|
||||
## Backend (API Platform)
|
||||
|
||||
### API-Endpunkte
|
||||
|
||||
- **GET /api/users** - Liste aller Benutzer (authentifiziert)
|
||||
- **GET /api/users/{id}** - Einzelner Benutzer (authentifiziert)
|
||||
- **POST /api/users** - Neuen Benutzer erstellen (nur ROLE_ADMIN)
|
||||
- **PUT /api/users/{id}** - Benutzer bearbeiten (ROLE_ADMIN oder eigener Account)
|
||||
- **DELETE /api/users/{id}** - Benutzer löschen (nur ROLE_ADMIN)
|
||||
|
||||
### Sicherheitsregeln
|
||||
|
||||
```php
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(),
|
||||
new Get(),
|
||||
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Put(security: "is_granted('ROLE_ADMIN') or object == user"),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')")
|
||||
],
|
||||
normalizationContext: ['groups' => ['user:read']],
|
||||
denormalizationContext: ['groups' => ['user:write']]
|
||||
)]
|
||||
```
|
||||
|
||||
### Serialization Groups
|
||||
|
||||
**user:read** (Ausgabe):
|
||||
- id, email, firstName, lastName
|
||||
- roles (array)
|
||||
- isActive (boolean)
|
||||
- createdAt, lastLoginAt (DateTimeImmutable)
|
||||
|
||||
**user:write** (Eingabe):
|
||||
- email, firstName, lastName
|
||||
- plainPassword (wird automatisch gehasht)
|
||||
- roles (array)
|
||||
- isActive (boolean)
|
||||
|
||||
### Passwort-Hashing
|
||||
|
||||
Ein eigener State Processor (`App\State\UserPasswordHasher`) sorgt dafür, dass das `plainPassword`-Feld automatisch gehasht wird:
|
||||
|
||||
```php
|
||||
// config/services.yaml
|
||||
App\State\UserPasswordHasher:
|
||||
decorates: 'api_platform.doctrine.orm.state.persist_processor'
|
||||
arguments:
|
||||
$processor: '@.inner'
|
||||
```
|
||||
|
||||
Der Processor:
|
||||
1. Prüft, ob ein `plainPassword` gesetzt wurde
|
||||
2. Hasht das Passwort mit `UserPasswordHasherInterface`
|
||||
3. Setzt das gehashte Passwort
|
||||
4. Ruft `eraseCredentials()` auf (löscht plainPassword aus dem Speicher)
|
||||
5. Delegiert an den Standard-Persist-Processor
|
||||
|
||||
## Frontend (Vue.js)
|
||||
|
||||
### Komponente: UserManagement.vue
|
||||
|
||||
**Features:**
|
||||
- PrimeVue DataTable mit Sortierung, Pagination
|
||||
- Erstellen/Bearbeiten via Dialog
|
||||
- Löschen mit Bestätigungs-Dialog
|
||||
- Formvalidierung
|
||||
- Toast-Benachrichtigungen
|
||||
- Eigenen Account kann nicht gelöscht werden
|
||||
- Nur für Admins sichtbar
|
||||
|
||||
**Formularfelder:**
|
||||
- Vorname* / Nachname*
|
||||
- E-Mail*
|
||||
- Passwort* (bei Erstellung) / Neues Passwort (bei Bearbeitung, optional)
|
||||
- Symfony-Rollen (ROLE_USER, ROLE_ADMIN via Checkboxen)
|
||||
- Status (Aktiv/Inaktiv Toggle)
|
||||
|
||||
### Integration
|
||||
|
||||
```javascript
|
||||
// router.js
|
||||
{ path: '/users', name: 'users', component: UserManagement, meta: { requiresAdmin: true } }
|
||||
|
||||
// App.vue Navigation (nur für Admins)
|
||||
<RouterLink to="/users" v-if="authStore.isAdmin">
|
||||
<i class="pi pi-user-edit"></i> Benutzerverwaltung
|
||||
</RouterLink>
|
||||
```
|
||||
|
||||
## Verwendung
|
||||
|
||||
### Neuen Benutzer erstellen
|
||||
|
||||
1. Navigiere zu `/users` (nur als Admin)
|
||||
2. Klicke auf "Neuer Benutzer"
|
||||
3. Fülle alle Pflichtfelder aus:
|
||||
- Vorname, Nachname, E-Mail
|
||||
- Passwort (mind. 6 Zeichen empfohlen)
|
||||
- Rollen auswählen (ROLE_USER ist Standard)
|
||||
- Status auf "Aktiv" setzen
|
||||
4. Klicke "Speichern"
|
||||
|
||||
**API Request:**
|
||||
```json
|
||||
POST /api/users
|
||||
{
|
||||
"firstName": "Max",
|
||||
"lastName": "Mustermann",
|
||||
"email": "max@example.com",
|
||||
"plainPassword": "sicheres123",
|
||||
"roles": ["ROLE_USER"],
|
||||
"isActive": true
|
||||
}
|
||||
```
|
||||
|
||||
### Benutzer bearbeiten
|
||||
|
||||
1. Klicke auf das Stift-Symbol in der Aktionsspalte
|
||||
2. Ändere gewünschte Felder
|
||||
3. Optional: Klicke "Passwort ändern" um ein neues Passwort zu setzen
|
||||
4. Klicke "Speichern"
|
||||
|
||||
**Hinweis:** Admins können alle Benutzer bearbeiten, normale Benutzer nur ihren eigenen Account.
|
||||
|
||||
### Benutzer löschen
|
||||
|
||||
1. Klicke auf das Papierkorb-Symbol
|
||||
2. Bestätige die Löschung im Dialog
|
||||
|
||||
**Einschränkungen:**
|
||||
- Eigener Account kann nicht gelöscht werden (Button deaktiviert)
|
||||
- Nur Admins können Benutzer löschen
|
||||
|
||||
## Sicherheit
|
||||
|
||||
### Authentifizierung
|
||||
|
||||
Alle API-Endpunkte erfordern Authentifizierung. Die Vue.js-App sendet automatisch die Session-Cookies mit.
|
||||
|
||||
### Autorisierung
|
||||
|
||||
- **POST /api/users**: Nur ROLE_ADMIN
|
||||
- **PUT /api/users/{id}**: ROLE_ADMIN oder eigener Account (`object == user`)
|
||||
- **DELETE /api/users/{id}**: Nur ROLE_ADMIN
|
||||
- **GET**: Alle authentifizierten Benutzer
|
||||
|
||||
### CSRF-Schutz
|
||||
|
||||
Da wir Session-basierte Authentifizierung verwenden, ist CSRF-Schutz automatisch aktiv. Für API-Requests ist dies standardmäßig deaktiviert.
|
||||
|
||||
## Technische Details
|
||||
|
||||
### Dependencies
|
||||
|
||||
**Backend:**
|
||||
- API Platform 4.x
|
||||
- Symfony Security Component
|
||||
- Doctrine ORM
|
||||
- PasswordHasher
|
||||
|
||||
**Frontend:**
|
||||
- Vue.js 3 Composition API
|
||||
- PrimeVue (DataTable, Dialog, InputText, Password, Checkbox, Button, Tag, Toast)
|
||||
- Pinia (Auth Store)
|
||||
|
||||
### Datenbankstruktur
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
email VARCHAR(180) NOT NULL UNIQUE,
|
||||
roles JSON NOT NULL,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
first_name VARCHAR(100),
|
||||
last_name VARCHAR(100),
|
||||
is_active TINYINT(1) DEFAULT 1,
|
||||
created_at DATETIME NOT NULL,
|
||||
last_login_at DATETIME DEFAULT NULL
|
||||
);
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
# API-Endpunkte testen
|
||||
curl -X GET http://localhost:8000/api/users \
|
||||
-H "Cookie: PHPSESSID=..." \
|
||||
-H "Accept: application/json"
|
||||
|
||||
# Neuen User erstellen (als Admin)
|
||||
curl -X POST http://localhost:8000/api/users \
|
||||
-H "Cookie: PHPSESSID=..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"firstName": "Test",
|
||||
"lastName": "User",
|
||||
"email": "test@example.com",
|
||||
"plainPassword": "test123",
|
||||
"roles": ["ROLE_USER"],
|
||||
"isActive": true
|
||||
}'
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
1. Melde dich als Admin an (admin@mycrm.local / admin123)
|
||||
2. Navigiere zu `/users`
|
||||
3. Teste alle CRUD-Operationen
|
||||
4. Prüfe Browser-Konsole auf Fehler
|
||||
5. Prüfe Toast-Benachrichtigungen
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Unauthorized" bei API-Calls
|
||||
|
||||
**Problem:** 401 Unauthorized bei allen API-Requests
|
||||
|
||||
**Lösung:**
|
||||
- Stelle sicher, dass du eingeloggt bist
|
||||
- Prüfe, ob Session-Cookie korrekt gesendet wird
|
||||
- Cache leeren: `php bin/console cache:clear`
|
||||
|
||||
### Passwort-Hashing funktioniert nicht
|
||||
|
||||
**Problem:** Passwort wird nicht gehasht, Login nicht möglich
|
||||
|
||||
**Lösung:**
|
||||
- Prüfe, ob `UserPasswordHasher` Service registriert ist
|
||||
- Stelle sicher, dass `decorates: 'api_platform.doctrine.orm.state.persist_processor'` korrekt ist
|
||||
- Cache leeren
|
||||
|
||||
### Vue-Komponente lädt nicht
|
||||
|
||||
**Problem:** Leere Seite oder JavaScript-Fehler
|
||||
|
||||
**Lösung:**
|
||||
```bash
|
||||
npm run build
|
||||
php bin/console cache:clear
|
||||
```
|
||||
|
||||
### "Cannot delete own account"
|
||||
|
||||
**Problem:** Löschen-Button funktioniert nicht
|
||||
|
||||
**Erklärung:** Das ist gewollt! Eigener Account kann nicht gelöscht werden (`:disabled="data.id === authStore.user?.id"`).
|
||||
|
||||
## Erweiterungen
|
||||
|
||||
### Mögliche zukünftige Features
|
||||
|
||||
1. **Rollen-Zuweisung:** Integration mit Role-Entity für komplexere Berechtigungen
|
||||
2. **Bulk-Operationen:** Mehrere Benutzer gleichzeitig bearbeiten/löschen
|
||||
3. **Export:** Benutzerliste als CSV/Excel exportieren
|
||||
4. **Avatar-Upload:** Profilbilder für Benutzer
|
||||
5. **E-Mail-Benachrichtigungen:** Bei Accounterstellung/Passwortänderung
|
||||
6. **Passwort-Reset:** "Passwort vergessen"-Funktion
|
||||
7. **2FA:** Zwei-Faktor-Authentifizierung
|
||||
8. **Audit Log:** Historie von Änderungen an Benutzeraccounts
|
||||
|
||||
## Weitere Dokumentation
|
||||
|
||||
- [LOGIN.md](./LOGIN.md) - Authentifizierungssystem
|
||||
- [PERMISSIONS.md](./PERMISSIONS.md) - Berechtigungssystem mit Rollen und Modulen
|
||||
- [API Platform Docs](https://api-platform.com/)
|
||||
@ -2,39 +2,63 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use App\Repository\UserRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Serializer\Annotation\Groups;
|
||||
|
||||
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
||||
#[ORM\Table(name: 'users')]
|
||||
#[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_EMAIL', fields: ['email'])]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(),
|
||||
new Get(),
|
||||
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Put(security: "is_granted('ROLE_ADMIN') or object == user"),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')")
|
||||
],
|
||||
normalizationContext: ['groups' => ['user:read']],
|
||||
denormalizationContext: ['groups' => ['user:write']]
|
||||
)]
|
||||
class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['user:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 180)]
|
||||
#[Groups(['user:read', 'user:write'])]
|
||||
private ?string $email = null;
|
||||
|
||||
#[ORM\Column(length: 100)]
|
||||
#[Groups(['user:read', 'user:write'])]
|
||||
private ?string $firstName = null;
|
||||
|
||||
#[ORM\Column(length: 100)]
|
||||
#[Groups(['user:read', 'user:write'])]
|
||||
private ?string $lastName = null;
|
||||
|
||||
#[ORM\Column]
|
||||
#[Groups(['user:read', 'user:write'])]
|
||||
private bool $isActive = true;
|
||||
|
||||
/**
|
||||
* @var list<string> The user roles (Symfony standard roles for basic access control)
|
||||
*/
|
||||
#[ORM\Column]
|
||||
#[Groups(['user:read', 'user:write'])]
|
||||
private array $roles = [];
|
||||
|
||||
/**
|
||||
@ -42,6 +66,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: Role::class, inversedBy: 'users')]
|
||||
#[ORM\JoinTable(name: 'user_roles')]
|
||||
#[Groups(['user:read', 'user:write'])]
|
||||
private Collection $userRoles;
|
||||
|
||||
/**
|
||||
@ -50,10 +75,18 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
#[ORM\Column]
|
||||
private ?string $password = null;
|
||||
|
||||
/**
|
||||
* Plain password for API write operations (not persisted)
|
||||
*/
|
||||
#[Groups(['user:write'])]
|
||||
private ?string $plainPassword = null;
|
||||
|
||||
#[ORM\Column]
|
||||
#[Groups(['user:read'])]
|
||||
private ?\DateTimeImmutable $createdAt = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
#[Groups(['user:read'])]
|
||||
private ?\DateTimeImmutable $lastLoginAt = null;
|
||||
|
||||
public function __construct()
|
||||
@ -126,10 +159,22 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPlainPassword(): ?string
|
||||
{
|
||||
return $this->plainPassword;
|
||||
}
|
||||
|
||||
public function setPlainPassword(?string $plainPassword): static
|
||||
{
|
||||
$this->plainPassword = $plainPassword;
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[\Deprecated]
|
||||
public function eraseCredentials(): void
|
||||
{
|
||||
// @deprecated, to be removed when upgrading to Symfony 8
|
||||
$this->plainPassword = null;
|
||||
}
|
||||
|
||||
public function getFirstName(): ?string
|
||||
|
||||
36
src/State/UserPasswordHasher.php
Normal file
36
src/State/UserPasswordHasher.php
Normal file
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Entity\User;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
class UserPasswordHasher implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private ProcessorInterface $processor,
|
||||
private UserPasswordHasherInterface $passwordHasher
|
||||
) {
|
||||
}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof User) {
|
||||
return $this->processor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
// Hash plain password if provided
|
||||
if ($data->getPlainPassword()) {
|
||||
$hashedPassword = $this->passwordHasher->hashPassword(
|
||||
$data,
|
||||
$data->getPlainPassword()
|
||||
);
|
||||
$data->setPassword($hashedPassword);
|
||||
$data->eraseCredentials();
|
||||
}
|
||||
|
||||
return $this->processor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user