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:
olli 2025-11-08 10:50:00 +01:00
parent c07c90cdaa
commit fcfda9d9be
11 changed files with 1390 additions and 112 deletions

View File

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

View File

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

View File

@ -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>&copy; {{ currentYear }} myCRM - Moderne CRM-Lösung</p>
<p>&copy; {{ 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>

View File

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

View File

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

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

View File

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

View File

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

View File

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

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