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
|
- **API Platform** - RESTful API mit OpenAPI-Dokumentation
|
||||||
- **MariaDB** - Zuverlässige relationale Datenbank
|
- **MariaDB** - Zuverlässige relationale Datenbank
|
||||||
- **Webpack Encore** - Asset-Management und Hot Module Replacement
|
- **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
|
## 📋 Voraussetzungen
|
||||||
|
|
||||||
@ -39,10 +42,20 @@ cp .env .env.local
|
|||||||
# 5. Datenbank erstellen
|
# 5. Datenbank erstellen
|
||||||
php bin/console doctrine:database:create
|
php bin/console doctrine:database:create
|
||||||
|
|
||||||
# 6. Datenbank-Schema erstellen (wenn Migrations vorhanden)
|
# 6. Datenbank-Schema erstellen
|
||||||
php bin/console doctrine:migrations:migrate
|
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
|
## 🎯 Entwicklung
|
||||||
|
|
||||||
### Backend-Server starten
|
### Backend-Server starten
|
||||||
@ -127,17 +140,20 @@ npm run watch
|
|||||||
## 📱 Module
|
## 📱 Module
|
||||||
|
|
||||||
- **Dashboard** - Übersicht und KPIs
|
- **Dashboard** - Übersicht und KPIs
|
||||||
- **Kontakte** - Kontaktverwaltung mit Status-Tracking
|
- **Kontakte** - Kontaktverwaltung mit Status-Tracking (in Entwicklung)
|
||||||
- **Unternehmen** - Firmendatenbank
|
- **Unternehmen** - Firmendatenbank (in Entwicklung)
|
||||||
- **Deals** - Sales-Pipeline Management
|
- **Deals** - Sales-Pipeline Management (in Entwicklung)
|
||||||
- **Aktivitäten** - Interaktions-Historie
|
- **Aktivitäten** - Interaktions-Historie (in Entwicklung)
|
||||||
|
- **Benutzerverwaltung** - CRUD für User (✅ implementiert)
|
||||||
|
|
||||||
## 🔐 Sicherheit
|
## 🔐 Sicherheit
|
||||||
|
|
||||||
- Symfony Security Component mit Voter-Pattern
|
- Symfony Security Component mit Voter-Pattern
|
||||||
- CSRF-Schutz
|
- CSRF-Schutz aktiviert
|
||||||
- Password Hashing mit Symfony Password Hasher
|
- Password Hashing mit Symfony PasswordHasher (bcrypt)
|
||||||
- API-Authentifizierung (JWT/API Keys)
|
- 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
|
## 🧪 Testing
|
||||||
|
|
||||||
@ -154,11 +170,14 @@ php bin/console doctrine:schema:validate
|
|||||||
|
|
||||||
## 📚 Weitere Dokumentationen
|
## 📚 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)
|
- [Symfony Documentation](https://symfony.com/doc/current/index.html)
|
||||||
- [API Platform Documentation](https://api-platform.com/docs/)
|
- [API Platform Documentation](https://api-platform.com/docs/)
|
||||||
- [Vue.js Guide](https://vuejs.org/guide/)
|
- [Vue.js Guide](https://vuejs.org/guide/)
|
||||||
- [PrimeVue Documentation](https://primevue.org/)
|
- [PrimeVue Documentation](https://primevue.org/)
|
||||||
- [AI Agent Instructions](.github/copilot-instructions.md)
|
|
||||||
|
|
||||||
## 🤝 Entwicklungs-Konventionen
|
## 🤝 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:**
|
**Next Steps:**
|
||||||
1. Erste Entity erstellen: `php bin/console make:entity Contact`
|
1. Contact-Entity erstellen: `php bin/console make:entity Contact`
|
||||||
2. Migration generieren: `php bin/console make:migration`
|
2. Company-Entity erstellen: `php bin/console make:entity Company`
|
||||||
3. Migration ausführen: `php bin/console doctrine:migrations:migrate`
|
3. Deal-Entity mit Pipeline-Stages erstellen
|
||||||
4. API Resource erstellen: `php bin/console make:entity --api-resource`
|
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 { createPinia } from 'pinia';
|
||||||
import PrimeVue from 'primevue/config';
|
import PrimeVue from 'primevue/config';
|
||||||
import Aura from '@primevue/themes/aura';
|
import Aura from '@primevue/themes/aura';
|
||||||
|
import ToastService from 'primevue/toastservice';
|
||||||
|
import Tooltip from 'primevue/tooltip';
|
||||||
import router from './js/router';
|
import router from './js/router';
|
||||||
import App from './js/App.vue';
|
import App from './js/App.vue';
|
||||||
import { useAuthStore } from './js/stores/auth';
|
import { useAuthStore } from './js/stores/auth';
|
||||||
@ -29,10 +31,13 @@ app.use(PrimeVue, {
|
|||||||
theme: {
|
theme: {
|
||||||
preset: Aura,
|
preset: Aura,
|
||||||
options: {
|
options: {
|
||||||
darkModeSelector: false, // Can be customized later
|
darkModeSelector: false,
|
||||||
|
cssLayer: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
app.use(ToastService);
|
||||||
|
app.directive('tooltip', Tooltip);
|
||||||
|
|
||||||
app.mount('#app');
|
app.mount('#app');
|
||||||
|
|
||||||
|
|||||||
@ -1,129 +1,329 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="crm-app">
|
<Toast />
|
||||||
|
<div id="app-layout">
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="header-left">
|
<div class="header-content">
|
||||||
<h1>📊 myCRM</h1>
|
<div class="logo">
|
||||||
|
<i class="pi pi-database"></i>
|
||||||
|
<span>myCRM</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="header-nav">
|
<button class="hamburger" @click="toggleMobileMenu" :class="{ active: mobileMenuOpen }">
|
||||||
<router-link to="/">Dashboard</router-link>
|
<span></span>
|
||||||
<router-link to="/contacts">Kontakte</router-link>
|
<span></span>
|
||||||
<router-link to="/companies">Unternehmen</router-link>
|
<span></span>
|
||||||
<router-link to="/deals">Deals</router-link>
|
</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>
|
</nav>
|
||||||
|
|
||||||
<div class="header-right">
|
|
||||||
<div class="user-info" v-if="authStore.isAuthenticated">
|
<div class="user-info" v-if="authStore.isAuthenticated">
|
||||||
<i class="pi pi-user"></i>
|
|
||||||
<span>{{ authStore.fullName }}</span>
|
<span>{{ authStore.fullName }}</span>
|
||||||
<Button
|
<a href="/logout" class="logout-link">
|
||||||
icon="pi pi-sign-out"
|
<i class="pi pi-sign-out"></i> Logout
|
||||||
severity="secondary"
|
</a>
|
||||||
text
|
|
||||||
size="small"
|
|
||||||
@click="handleLogout"
|
|
||||||
label="Logout"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="app-main">
|
<main class="app-main">
|
||||||
<router-view />
|
<RouterView />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="app-footer">
|
<footer class="app-footer">
|
||||||
<p>© {{ currentYear }} myCRM - Moderne CRM-Lösung</p>
|
<p>© {{ new Date().getFullYear() }} myCRM</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 { useAuthStore } from './stores/auth';
|
||||||
import Button from 'primevue/button';
|
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const currentYear = computed(() => new Date().getFullYear());
|
authStore.initializeFromElement(document.getElementById('app'));
|
||||||
|
|
||||||
const handleLogout = () => {
|
const mobileMenuOpen = ref(false);
|
||||||
if (confirm('Möchten Sie sich wirklich abmelden?')) {
|
|
||||||
authStore.logout();
|
const toggleMobileMenu = () => {
|
||||||
}
|
mobileMenuOpen.value = !mobileMenuOpen.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeMobileMenu = () => {
|
||||||
|
mobileMenuOpen.value = false;
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
#app-layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.app-header {
|
.app-header {
|
||||||
background: #2563eb;
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: white;
|
color: white;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
padding: 1rem 2rem;
|
padding: 1rem 2rem;
|
||||||
display: flex;
|
gap: 2rem;
|
||||||
justify-content: space-between;
|
flex-wrap: nowrap;
|
||||||
align-items: center;
|
}
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
}
|
||||||
|
|
||||||
.header-left {
|
.logo {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
z-index: 1001;
|
||||||
|
|
||||||
h1 {
|
i {
|
||||||
margin: 0;
|
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-nav {
|
.hamburger {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1.5rem;
|
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;
|
flex: 1;
|
||||||
justify-content: center;
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
background: transparent;
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: white;
|
color: white;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
transition: background 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
||||||
&:hover, &.router-link-active {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-right {
|
|
||||||
.user-info {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.5rem;
|
||||||
background: rgba(255, 255, 255, 0.1);
|
font-size: 1rem;
|
||||||
padding: 0.5rem 1rem;
|
white-space: nowrap;
|
||||||
border-radius: 6px;
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
i {
|
i {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&: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 {
|
span {
|
||||||
font-weight: 500;
|
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 {
|
.app-main {
|
||||||
min-height: calc(100vh - 150px);
|
flex: 1;
|
||||||
padding: 2rem;
|
padding: 1rem;
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-footer {
|
.app-footer {
|
||||||
background: #f3f4f6;
|
background: #f8f9fa;
|
||||||
padding: 1rem 2rem;
|
padding: 0.75rem 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #6b7280;
|
color: #6c757d;
|
||||||
border-top: 1px solid #e5e7eb;
|
border-top: 1px solid #dee2e6;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,33 +1,21 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
import Dashboard from './views/Dashboard.vue';
|
import Dashboard from './views/Dashboard.vue';
|
||||||
import ContactList from './views/ContactList.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 = [
|
const routes = [
|
||||||
{
|
{ path: '/', name: 'dashboard', component: Dashboard },
|
||||||
path: '/',
|
{ path: '/contacts', name: 'contacts', component: ContactList },
|
||||||
name: 'Dashboard',
|
{ path: '/companies', name: 'companies', component: CompanyList },
|
||||||
component: Dashboard
|
{ path: '/deals', name: 'deals', component: DealList },
|
||||||
},
|
{ path: '/users', name: 'users', component: UserManagement, meta: { requiresAdmin: true } },
|
||||||
{
|
|
||||||
path: '/contacts',
|
|
||||||
name: 'ContactList',
|
|
||||||
component: ContactList
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/companies',
|
|
||||||
name: 'CompanyList',
|
|
||||||
component: () => import('./views/CompanyList.vue')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/deals',
|
|
||||||
name: 'DealList',
|
|
||||||
component: () => import('./views/DealList.vue')
|
|
||||||
}
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes
|
routes,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@ -43,13 +43,36 @@ import Card from 'primevue/card';
|
|||||||
.dashboard {
|
.dashboard {
|
||||||
h2 {
|
h2 {
|
||||||
margin-bottom: 1rem;
|
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 {
|
.dashboard-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
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;
|
gap: 1.5rem;
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</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;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
background: #f9fafb;
|
background: #f9fafb;
|
||||||
|
color: #1f2937;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
@ -17,8 +19,126 @@ body {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* PrimeVue Theme Overrides */
|
/* PrimeVue Component Styling Fixes */
|
||||||
:root {
|
.p-card {
|
||||||
--primary-color: #2563eb;
|
background: white;
|
||||||
--primary-color-text: #ffffff;
|
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
|
# add more service definitions when explicit configuration is needed
|
||||||
# please note that last definitions always *replace* previous ones
|
# 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;
|
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 App\Repository\UserRepository;
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||||
use Symfony\Component\Security\Core\User\UserInterface;
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
use Symfony\Component\Serializer\Annotation\Groups;
|
||||||
|
|
||||||
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
||||||
#[ORM\Table(name: 'users')]
|
#[ORM\Table(name: 'users')]
|
||||||
#[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_EMAIL', fields: ['email'])]
|
#[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
|
class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||||
{
|
{
|
||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
|
#[Groups(['user:read'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 180)]
|
#[ORM\Column(length: 180)]
|
||||||
|
#[Groups(['user:read', 'user:write'])]
|
||||||
private ?string $email = null;
|
private ?string $email = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 100)]
|
#[ORM\Column(length: 100)]
|
||||||
|
#[Groups(['user:read', 'user:write'])]
|
||||||
private ?string $firstName = null;
|
private ?string $firstName = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 100)]
|
#[ORM\Column(length: 100)]
|
||||||
|
#[Groups(['user:read', 'user:write'])]
|
||||||
private ?string $lastName = null;
|
private ?string $lastName = null;
|
||||||
|
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
|
#[Groups(['user:read', 'user:write'])]
|
||||||
private bool $isActive = true;
|
private bool $isActive = true;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var list<string> The user roles (Symfony standard roles for basic access control)
|
* @var list<string> The user roles (Symfony standard roles for basic access control)
|
||||||
*/
|
*/
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
|
#[Groups(['user:read', 'user:write'])]
|
||||||
private array $roles = [];
|
private array $roles = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -42,6 +66,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
*/
|
*/
|
||||||
#[ORM\ManyToMany(targetEntity: Role::class, inversedBy: 'users')]
|
#[ORM\ManyToMany(targetEntity: Role::class, inversedBy: 'users')]
|
||||||
#[ORM\JoinTable(name: 'user_roles')]
|
#[ORM\JoinTable(name: 'user_roles')]
|
||||||
|
#[Groups(['user:read', 'user:write'])]
|
||||||
private Collection $userRoles;
|
private Collection $userRoles;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -50,10 +75,18 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
private ?string $password = null;
|
private ?string $password = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain password for API write operations (not persisted)
|
||||||
|
*/
|
||||||
|
#[Groups(['user:write'])]
|
||||||
|
private ?string $plainPassword = null;
|
||||||
|
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
|
#[Groups(['user:read'])]
|
||||||
private ?\DateTimeImmutable $createdAt = null;
|
private ?\DateTimeImmutable $createdAt = null;
|
||||||
|
|
||||||
#[ORM\Column(nullable: true)]
|
#[ORM\Column(nullable: true)]
|
||||||
|
#[Groups(['user:read'])]
|
||||||
private ?\DateTimeImmutable $lastLoginAt = null;
|
private ?\DateTimeImmutable $lastLoginAt = null;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
@ -126,10 +159,22 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getPlainPassword(): ?string
|
||||||
|
{
|
||||||
|
return $this->plainPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPlainPassword(?string $plainPassword): static
|
||||||
|
{
|
||||||
|
$this->plainPassword = $plainPassword;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
#[\Deprecated]
|
#[\Deprecated]
|
||||||
public function eraseCredentials(): void
|
public function eraseCredentials(): void
|
||||||
{
|
{
|
||||||
// @deprecated, to be removed when upgrading to Symfony 8
|
// @deprecated, to be removed when upgrading to Symfony 8
|
||||||
|
$this->plainPassword = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getFirstName(): ?string
|
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