feat: Add settings management for password policies and login options
- Introduced a new SettingsManagement view for administrators to manage system settings. - Added routes and components for settings management, including minimum password length and password login options. - Implemented a SettingsService to handle retrieval and updating of settings. - Created a new Setting entity and repository for database interactions. - Added validation for password length using a custom PasswordMinLength validator. - Updated SecurityController to check if password login is allowed. - Enhanced UserManagement view to provide detailed error messages on save and delete operations. - Implemented a DuplicateEmailExceptionListener to handle unique constraint violations for email addresses. - Updated security configuration to include the new LoginFormAuthenticator. - Created API endpoints for fetching and updating settings, secured with ROLE_ADMIN.
This commit is contained in:
parent
9122cd2cc1
commit
47b5dd1c23
214
SETTINGS.md
Normal file
214
SETTINGS.md
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
# System-Einstellungen
|
||||||
|
|
||||||
|
Die System-Einstellungen erlauben es Administratoren, sicherheitsrelevante Konfigurationen für das CRM-System vorzunehmen.
|
||||||
|
|
||||||
|
## Zugriff
|
||||||
|
|
||||||
|
Die Einstellungsseite ist nur für Benutzer mit der Rolle `ROLE_ADMIN` zugänglich und kann über die Navigation unter **Einstellungen** erreicht werden.
|
||||||
|
|
||||||
|
URL: `/settings`
|
||||||
|
|
||||||
|
## Verfügbare Einstellungen
|
||||||
|
|
||||||
|
### 1. Mindestlänge für Passwörter
|
||||||
|
|
||||||
|
**Einstellung:** `security.password_min_length`
|
||||||
|
**Typ:** Integer (4-128)
|
||||||
|
**Standard:** 8 Zeichen
|
||||||
|
|
||||||
|
Legt die Mindestanzahl an Zeichen fest, die ein Passwort haben muss. Diese Einstellung wird beim Erstellen und Aktualisieren von Benutzern über die API validiert.
|
||||||
|
|
||||||
|
**Verwendung im Code:**
|
||||||
|
```php
|
||||||
|
$minLength = $settingsService->getPasswordMinLength();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validator:**
|
||||||
|
Der `PasswordMinLength`-Validator prüft automatisch bei der Benutzerregistrierung und beim Passwort-Update, ob das Passwort die konfigurierte Mindestlänge erfüllt.
|
||||||
|
|
||||||
|
### 2. Login mit E-Mail und Passwort
|
||||||
|
|
||||||
|
**Einstellung:** `security.allow_password_login`
|
||||||
|
**Typ:** Boolean
|
||||||
|
**Standard:** true (erlaubt)
|
||||||
|
|
||||||
|
Steuert, ob sich Benutzer mit E-Mail und Passwort anmelden können. Wenn diese Einstellung deaktiviert ist, müssen Benutzer alternative Anmeldemethoden wie OIDC (Pocket-ID) verwenden.
|
||||||
|
|
||||||
|
**Verwendung im Code:**
|
||||||
|
```php
|
||||||
|
$isAllowed = $settingsService->isPasswordLoginAllowed();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wichtig:**
|
||||||
|
- Stellen Sie sicher, dass mindestens eine alternative Login-Methode (z.B. OIDC) konfiguriert ist, bevor Sie den Passwort-Login deaktivieren
|
||||||
|
- Die Login-Seite zeigt automatisch nur die verfügbaren Anmeldemethoden an
|
||||||
|
- Der `LoginFormAuthenticator` blockiert Passwort-Login-Versuche, wenn die Einstellung deaktiviert ist
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### GET /api/settings
|
||||||
|
Lädt alle Einstellungen
|
||||||
|
|
||||||
|
**Berechtigung:** ROLE_ADMIN
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"settings": {
|
||||||
|
"passwordMinLength": 8,
|
||||||
|
"allowPasswordLogin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### PUT /api/settings
|
||||||
|
Aktualisiert Einstellungen
|
||||||
|
|
||||||
|
**Berechtigung:** ROLE_ADMIN
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"settings": {
|
||||||
|
"passwordMinLength": 12,
|
||||||
|
"allowPasswordLogin": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"settings": {
|
||||||
|
"passwordMinLength": 12,
|
||||||
|
"allowPasswordLogin": false
|
||||||
|
},
|
||||||
|
"message": "Settings updated successfully"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Datenbank-Schema
|
||||||
|
|
||||||
|
Die Einstellungen werden in der Tabelle `settings` gespeichert:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE settings (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
setting_key VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
setting_value LONGTEXT,
|
||||||
|
setting_type VARCHAR(50) NOT NULL,
|
||||||
|
description VARCHAR(255),
|
||||||
|
updated_at DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Unterstützte Typen:**
|
||||||
|
- `string`: Textwerte
|
||||||
|
- `integer`: Ganzzahlen
|
||||||
|
- `boolean`: true/false (gespeichert als '1'/'0')
|
||||||
|
- `float`: Dezimalzahlen
|
||||||
|
- `json`: JSON-Daten
|
||||||
|
|
||||||
|
## Verwendung im Code
|
||||||
|
|
||||||
|
### SettingsService verwenden
|
||||||
|
|
||||||
|
```php
|
||||||
|
use App\Service\SettingsService;
|
||||||
|
|
||||||
|
class MyController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private SettingsService $settingsService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function myAction(): Response
|
||||||
|
{
|
||||||
|
// Einzelne Einstellung lesen
|
||||||
|
$minLength = $this->settingsService->getPasswordMinLength();
|
||||||
|
$isAllowed = $this->settingsService->isPasswordLoginAllowed();
|
||||||
|
|
||||||
|
// Alle Einstellungen abrufen
|
||||||
|
$allSettings = $this->settingsService->getAllSettings();
|
||||||
|
|
||||||
|
// Einstellung setzen
|
||||||
|
$this->settingsService->setPasswordMinLength(10);
|
||||||
|
$this->settingsService->setPasswordLoginAllowed(false);
|
||||||
|
|
||||||
|
// Mehrere Einstellungen auf einmal aktualisieren
|
||||||
|
$this->settingsService->updateSettings([
|
||||||
|
'passwordMinLength' => 12,
|
||||||
|
'allowPasswordLogin' => true
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Neue Einstellungen hinzufügen
|
||||||
|
|
||||||
|
1. **Konstante in SettingsService definieren:**
|
||||||
|
```php
|
||||||
|
public const MY_NEW_SETTING = 'category.my_new_setting';
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Getter/Setter hinzufügen:**
|
||||||
|
```php
|
||||||
|
public function getMyNewSetting(): mixed
|
||||||
|
{
|
||||||
|
return $this->settingRepository->getValue(self::MY_NEW_SETTING, $defaultValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMyNewSetting(mixed $value): void
|
||||||
|
{
|
||||||
|
$this->settingRepository->setValue(
|
||||||
|
self::MY_NEW_SETTING,
|
||||||
|
$value,
|
||||||
|
'string', // oder 'boolean', 'integer', etc.
|
||||||
|
'Description of my setting'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **In getAllSettings() und updateSettings() ergänzen:**
|
||||||
|
```php
|
||||||
|
public function getAllSettings(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// ... existing settings
|
||||||
|
'myNewSetting' => $this->getMyNewSetting(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateSettings(array $settings): void
|
||||||
|
{
|
||||||
|
// ... existing code
|
||||||
|
|
||||||
|
if (isset($settings['myNewSetting'])) {
|
||||||
|
$this->setMyNewSetting($settings['myNewSetting']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Vue-Komponente aktualisieren:**
|
||||||
|
Fügen Sie das neue Feld in `assets/js/views/SettingsManagement.vue` hinzu.
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
- Alle Einstellungs-Endpoints sind durch `#[IsGranted('ROLE_ADMIN')]` geschützt
|
||||||
|
- Der `LoginFormAuthenticator` prüft vor jedem Login, ob Passwort-Login erlaubt ist
|
||||||
|
- Der `PasswordMinLength`-Validator validiert Passwörter gegen die konfigurierte Mindestlänge
|
||||||
|
- Einstellungen werden automatisch bei jeder Änderung mit Zeitstempel versehen
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
Die Tabelle wird durch `Version20251108164116` erstellt und mit Standardwerten initialisiert:
|
||||||
|
- `security.password_min_length`: 8
|
||||||
|
- `security.allow_password_login`: true (1)
|
||||||
|
|
||||||
|
Um die Migration auszuführen:
|
||||||
|
```bash
|
||||||
|
php bin/console doctrine:migrations:migrate
|
||||||
|
```
|
||||||
@ -27,12 +27,25 @@
|
|||||||
<RouterLink to="/deals" @click="closeMobileMenu">
|
<RouterLink to="/deals" @click="closeMobileMenu">
|
||||||
<i class="pi pi-chart-line"></i> Deals
|
<i class="pi pi-chart-line"></i> Deals
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink to="/users" v-if="authStore.isAdmin" @click="closeMobileMenu">
|
|
||||||
<i class="pi pi-user-edit"></i> Benutzerverwaltung
|
<!-- Admin Menu -->
|
||||||
</RouterLink>
|
<div class="nav-dropdown" v-if="authStore.isAdmin">
|
||||||
<RouterLink to="/roles" v-if="authStore.isAdmin" @click="closeMobileMenu">
|
<a href="#" class="nav-dropdown-toggle" @click.prevent="toggleAdminMenu" :class="{ active: adminMenuOpen }">
|
||||||
<i class="pi pi-shield"></i> Rollenverwaltung
|
<i class="pi pi-shield"></i> Admin
|
||||||
</RouterLink>
|
<i class="pi pi-chevron-down dropdown-icon" :class="{ rotated: adminMenuOpen }"></i>
|
||||||
|
</a>
|
||||||
|
<div class="nav-dropdown-menu" v-show="adminMenuOpen">
|
||||||
|
<RouterLink to="/users" @click="handleAdminMenuClick">
|
||||||
|
<i class="pi pi-user-edit"></i> Benutzerverwaltung
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink to="/roles" @click="handleAdminMenuClick">
|
||||||
|
<i class="pi pi-shield"></i> Rollenverwaltung
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink to="/settings" @click="handleAdminMenuClick">
|
||||||
|
<i class="pi pi-cog"></i> Einstellungen
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="user-info" v-if="authStore.isAuthenticated">
|
<div class="user-info" v-if="authStore.isAuthenticated">
|
||||||
@ -64,6 +77,7 @@ const authStore = useAuthStore();
|
|||||||
authStore.initializeFromElement(document.getElementById('app'));
|
authStore.initializeFromElement(document.getElementById('app'));
|
||||||
|
|
||||||
const mobileMenuOpen = ref(false);
|
const mobileMenuOpen = ref(false);
|
||||||
|
const adminMenuOpen = ref(false);
|
||||||
|
|
||||||
const toggleMobileMenu = () => {
|
const toggleMobileMenu = () => {
|
||||||
mobileMenuOpen.value = !mobileMenuOpen.value;
|
mobileMenuOpen.value = !mobileMenuOpen.value;
|
||||||
@ -71,6 +85,15 @@ const toggleMobileMenu = () => {
|
|||||||
|
|
||||||
const closeMobileMenu = () => {
|
const closeMobileMenu = () => {
|
||||||
mobileMenuOpen.value = false;
|
mobileMenuOpen.value = false;
|
||||||
|
adminMenuOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAdminMenu = () => {
|
||||||
|
adminMenuOpen.value = !adminMenuOpen.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdminMenuClick = () => {
|
||||||
|
closeMobileMenu();
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -251,6 +274,97 @@ const closeMobileMenu = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-dropdown {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.nav-dropdown-toggle {
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-icon {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
|
||||||
|
&.rotated {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-menu {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding-left: 1rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
min-width: 220px;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.router-link-active {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.user-info {
|
.user-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import CompanyList from './views/CompanyList.vue';
|
|||||||
import DealList from './views/DealList.vue';
|
import DealList from './views/DealList.vue';
|
||||||
import UserManagement from './views/UserManagement.vue';
|
import UserManagement from './views/UserManagement.vue';
|
||||||
import RoleManagement from './views/RoleManagement.vue';
|
import RoleManagement from './views/RoleManagement.vue';
|
||||||
|
import SettingsManagement from './views/SettingsManagement.vue';
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: '/', name: 'dashboard', component: Dashboard },
|
{ path: '/', name: 'dashboard', component: Dashboard },
|
||||||
@ -13,6 +14,7 @@ const routes = [
|
|||||||
{ path: '/deals', name: 'deals', component: DealList },
|
{ path: '/deals', name: 'deals', component: DealList },
|
||||||
{ path: '/users', name: 'users', component: UserManagement, meta: { requiresAdmin: true } },
|
{ path: '/users', name: 'users', component: UserManagement, meta: { requiresAdmin: true } },
|
||||||
{ path: '/roles', name: 'roles', component: RoleManagement, meta: { requiresAdmin: true } },
|
{ path: '/roles', name: 'roles', component: RoleManagement, meta: { requiresAdmin: true } },
|
||||||
|
{ path: '/settings', name: 'settings', component: SettingsManagement, meta: { requiresAdmin: true } },
|
||||||
];
|
];
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
|
|||||||
264
assets/js/views/SettingsManagement.vue
Normal file
264
assets/js/views/SettingsManagement.vue
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
<template>
|
||||||
|
<div class="settings-management">
|
||||||
|
<h1>Systemeinstellungen</h1>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>Sicherheitseinstellungen</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<form @submit.prevent="saveSettings" v-if="!loading">
|
||||||
|
<!-- Password Minimum Length -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="passwordMinLength">
|
||||||
|
<i class="pi pi-lock"></i>
|
||||||
|
Mindestlänge für Passwörter
|
||||||
|
</label>
|
||||||
|
<InputNumber
|
||||||
|
id="passwordMinLength"
|
||||||
|
v-model="settings.passwordMinLength"
|
||||||
|
:min="4"
|
||||||
|
:max="128"
|
||||||
|
:showButtons="true"
|
||||||
|
suffix=" Zeichen"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<small class="form-text">
|
||||||
|
Legt die Mindestanzahl an Zeichen fest, die ein Passwort haben muss (4-128 Zeichen).
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Allow Password Login -->
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="flex align-items-center">
|
||||||
|
<Checkbox
|
||||||
|
id="allowPasswordLogin"
|
||||||
|
v-model="settings.allowPasswordLogin"
|
||||||
|
:binary="true"
|
||||||
|
/>
|
||||||
|
<label for="allowPasswordLogin" class="ml-2 cursor-pointer">
|
||||||
|
<i class="pi pi-sign-in"></i>
|
||||||
|
Login mit E-Mail und Passwort erlauben
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<small class="form-text">
|
||||||
|
Wenn deaktiviert, können sich Benutzer nur über alternative Methoden (z.B. OIDC) anmelden.
|
||||||
|
</small>
|
||||||
|
|
||||||
|
<Message v-if="!settings.allowPasswordLogin" severity="warn" class="mt-2" :closable="false">
|
||||||
|
<strong>Achtung:</strong> Stellen Sie sicher, dass mindestens eine alternative Login-Methode (z.B. OIDC)
|
||||||
|
konfiguriert ist, bevor Sie den Passwort-Login deaktivieren.
|
||||||
|
</Message>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="flex gap-2 mt-4">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
label="Einstellungen speichern"
|
||||||
|
icon="pi pi-check"
|
||||||
|
:loading="saving"
|
||||||
|
class="p-button-success"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
label="Zurücksetzen"
|
||||||
|
icon="pi pi-refresh"
|
||||||
|
@click="loadSettings"
|
||||||
|
:disabled="saving"
|
||||||
|
class="p-button-secondary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div v-else class="text-center py-4">
|
||||||
|
<ProgressSpinner />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success Toast -->
|
||||||
|
<Toast />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useToast } from 'primevue/usetoast'
|
||||||
|
import InputNumber from 'primevue/inputnumber'
|
||||||
|
import Checkbox from 'primevue/checkbox'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import Message from 'primevue/message'
|
||||||
|
import Toast from 'primevue/toast'
|
||||||
|
import ProgressSpinner from 'primevue/progressspinner'
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
const settings = ref({
|
||||||
|
passwordMinLength: 8,
|
||||||
|
allowPasswordLogin: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadSettings = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings', {
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load settings')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
settings.value = { ...data.settings }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading settings:', error)
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Fehler',
|
||||||
|
detail: 'Einstellungen konnten nicht geladen werden',
|
||||||
|
life: 3000
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveSettings = async () => {
|
||||||
|
// Validation
|
||||||
|
if (settings.value.passwordMinLength < 4 || settings.value.passwordMinLength > 128) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Validierungsfehler',
|
||||||
|
detail: 'Passwort-Mindestlänge muss zwischen 4 und 128 Zeichen liegen',
|
||||||
|
life: 3000
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
settings: settings.value
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json()
|
||||||
|
throw new Error(errorData.error || 'Failed to save settings')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
settings.value = { ...data.settings }
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Erfolg',
|
||||||
|
detail: 'Einstellungen wurden erfolgreich gespeichert',
|
||||||
|
life: 3000
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving settings:', error)
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Fehler',
|
||||||
|
detail: error.message || 'Einstellungen konnten nicht gespeichert werden',
|
||||||
|
life: 3000
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadSettings()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.settings-management {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label i {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-text {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.settings-management {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -395,7 +395,27 @@ const saveUser = async () => {
|
|||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Fehler beim Speichern');
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => null);
|
||||||
|
let errorMessage = 'Benutzer konnte nicht gespeichert werden';
|
||||||
|
|
||||||
|
// Extract validation errors from API Platform response
|
||||||
|
if (errorData) {
|
||||||
|
if (errorData['hydra:description']) {
|
||||||
|
errorMessage = errorData['hydra:description'];
|
||||||
|
} else if (errorData.violations && errorData.violations.length > 0) {
|
||||||
|
// Format validation errors
|
||||||
|
const violations = errorData.violations.map(v => v.message).join(', ');
|
||||||
|
errorMessage = `Validierungsfehler: ${violations}`;
|
||||||
|
} else if (errorData.detail) {
|
||||||
|
errorMessage = errorData.detail;
|
||||||
|
} else if (errorData.message) {
|
||||||
|
errorMessage = errorData.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
@ -407,7 +427,12 @@ const saveUser = async () => {
|
|||||||
dialogVisible.value = false;
|
dialogVisible.value = false;
|
||||||
await fetchUsers();
|
await fetchUsers();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Benutzer konnte nicht gespeichert werden', life: 3000 });
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Fehler',
|
||||||
|
detail: error.message || 'Benutzer konnte nicht gespeichert werden',
|
||||||
|
life: 5000
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
}
|
}
|
||||||
@ -426,13 +451,33 @@ const deleteUser = async () => {
|
|||||||
credentials: 'same-origin'
|
credentials: 'same-origin'
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Fehler beim Löschen');
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => null);
|
||||||
|
let errorMessage = 'Benutzer konnte nicht gelöscht werden';
|
||||||
|
|
||||||
|
if (errorData) {
|
||||||
|
if (errorData['hydra:description']) {
|
||||||
|
errorMessage = errorData['hydra:description'];
|
||||||
|
} else if (errorData.detail) {
|
||||||
|
errorMessage = errorData.detail;
|
||||||
|
} else if (errorData.message) {
|
||||||
|
errorMessage = errorData.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
toast.add({ severity: 'success', summary: 'Erfolg', detail: 'Benutzer wurde gelöscht', life: 3000 });
|
toast.add({ severity: 'success', summary: 'Erfolg', detail: 'Benutzer wurde gelöscht', life: 3000 });
|
||||||
deleteDialogVisible.value = false;
|
deleteDialogVisible.value = false;
|
||||||
await fetchUsers();
|
await fetchUsers();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Benutzer konnte nicht gelöscht werden', life: 3000 });
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Fehler',
|
||||||
|
detail: error.message || 'Benutzer konnte nicht gelöscht werden',
|
||||||
|
life: 5000
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
deleting.value = false;
|
deleting.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,12 +17,8 @@ security:
|
|||||||
lazy: true
|
lazy: true
|
||||||
provider: app_user_provider
|
provider: app_user_provider
|
||||||
custom_authenticators:
|
custom_authenticators:
|
||||||
|
- App\Security\LoginFormAuthenticator
|
||||||
- App\Security\PocketIdAuthenticator
|
- App\Security\PocketIdAuthenticator
|
||||||
form_login:
|
|
||||||
login_path: app_login
|
|
||||||
check_path: app_login
|
|
||||||
enable_csrf: true
|
|
||||||
default_target_path: /
|
|
||||||
logout:
|
logout:
|
||||||
path: app_logout
|
path: app_logout
|
||||||
target: app_login
|
target: app_login
|
||||||
|
|||||||
36
migrations/Version20251108164116.php
Normal file
36
migrations/Version20251108164116.php
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20251108164116 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('CREATE TABLE settings (id INT AUTO_INCREMENT NOT NULL, setting_key VARCHAR(100) NOT NULL, setting_value LONGTEXT DEFAULT NULL, setting_type VARCHAR(50) NOT NULL, description VARCHAR(255) DEFAULT NULL, updated_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_SETTING_KEY (setting_key), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||||
|
|
||||||
|
// Insert default settings
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$this->addSql("INSERT INTO settings (setting_key, setting_value, setting_type, description, updated_at) VALUES ('security.password_min_length', '8', 'integer', 'Minimum password length required', '$now')");
|
||||||
|
$this->addSql("INSERT INTO settings (setting_key, setting_value, setting_type, description, updated_at) VALUES ('security.allow_password_login', '1', 'boolean', 'Allow login with email and password', '$now')");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('DROP TABLE settings');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Service\SettingsService;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
@ -9,6 +10,10 @@ use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
|
|||||||
|
|
||||||
class SecurityController extends AbstractController
|
class SecurityController extends AbstractController
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private SettingsService $settingsService
|
||||||
|
) {}
|
||||||
|
|
||||||
#[Route(path: '/login', name: 'app_login')]
|
#[Route(path: '/login', name: 'app_login')]
|
||||||
public function login(AuthenticationUtils $authenticationUtils): Response
|
public function login(AuthenticationUtils $authenticationUtils): Response
|
||||||
{
|
{
|
||||||
@ -23,9 +28,13 @@ class SecurityController extends AbstractController
|
|||||||
// last username entered by the user
|
// last username entered by the user
|
||||||
$lastUsername = $authenticationUtils->getLastUsername();
|
$lastUsername = $authenticationUtils->getLastUsername();
|
||||||
|
|
||||||
|
// Check if password login is allowed
|
||||||
|
$allowPasswordLogin = $this->settingsService->isPasswordLoginAllowed();
|
||||||
|
|
||||||
return $this->render('security/login.html.twig', [
|
return $this->render('security/login.html.twig', [
|
||||||
'last_username' => $lastUsername,
|
'last_username' => $lastUsername,
|
||||||
'error' => $error,
|
'error' => $error,
|
||||||
|
'allow_password_login' => $allowPasswordLogin,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
54
src/Controller/SettingsController.php
Normal file
54
src/Controller/SettingsController.php
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Service\SettingsService;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
#[Route('/api/settings', name: 'api_settings_')]
|
||||||
|
#[IsGranted('ROLE_ADMIN')]
|
||||||
|
class SettingsController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private SettingsService $settingsService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
#[Route('', name: 'get', methods: ['GET'])]
|
||||||
|
public function getSettings(): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->json([
|
||||||
|
'settings' => $this->settingsService->getAllSettings()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('', name: 'update', methods: ['PUT', 'PATCH'])]
|
||||||
|
public function updateSettings(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = json_decode($request->getContent(), true);
|
||||||
|
|
||||||
|
if (!isset($data['settings'])) {
|
||||||
|
return $this->json([
|
||||||
|
'error' => 'Missing settings data'
|
||||||
|
], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->settingsService->updateSettings($data['settings']);
|
||||||
|
|
||||||
|
return $this->json([
|
||||||
|
'success' => true,
|
||||||
|
'settings' => $this->settingsService->getAllSettings(),
|
||||||
|
'message' => 'Settings updated successfully'
|
||||||
|
]);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
return $this->json([
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
127
src/Entity/Setting.php
Normal file
127
src/Entity/Setting.php
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\SettingRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: SettingRepository::class)]
|
||||||
|
#[ORM\Table(name: 'settings')]
|
||||||
|
#[ORM\UniqueConstraint(name: 'UNIQ_SETTING_KEY', columns: ['setting_key'])]
|
||||||
|
class Setting
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 100)]
|
||||||
|
private ?string $settingKey = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
|
private ?string $settingValue = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 50)]
|
||||||
|
private ?string $settingType = 'string';
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
private ?string $description = null;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?\DateTimeImmutable $updatedAt = null;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSettingKey(): ?string
|
||||||
|
{
|
||||||
|
return $this->settingKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSettingKey(string $settingKey): static
|
||||||
|
{
|
||||||
|
$this->settingKey = $settingKey;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSettingValue(): ?string
|
||||||
|
{
|
||||||
|
return $this->settingValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSettingValue(?string $settingValue): static
|
||||||
|
{
|
||||||
|
$this->settingValue = $settingValue;
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSettingType(): ?string
|
||||||
|
{
|
||||||
|
return $this->settingType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSettingType(string $settingType): static
|
||||||
|
{
|
||||||
|
$this->settingType = $settingType;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDescription(): ?string
|
||||||
|
{
|
||||||
|
return $this->description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDescription(?string $description): static
|
||||||
|
{
|
||||||
|
$this->description = $description;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUpdatedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUpdatedAt(\DateTimeImmutable $updatedAt): static
|
||||||
|
{
|
||||||
|
$this->updatedAt = $updatedAt;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the typed value
|
||||||
|
*/
|
||||||
|
public function getValue(): mixed
|
||||||
|
{
|
||||||
|
return match($this->settingType) {
|
||||||
|
'boolean' => filter_var($this->settingValue, FILTER_VALIDATE_BOOLEAN),
|
||||||
|
'integer' => (int) $this->settingValue,
|
||||||
|
'float' => (float) $this->settingValue,
|
||||||
|
'json' => json_decode($this->settingValue, true),
|
||||||
|
default => $this->settingValue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the typed value
|
||||||
|
*/
|
||||||
|
public function setValue(mixed $value): static
|
||||||
|
{
|
||||||
|
$this->settingValue = match($this->settingType) {
|
||||||
|
'boolean' => $value ? '1' : '0',
|
||||||
|
'integer', 'float' => (string) $value,
|
||||||
|
'json' => json_encode($value),
|
||||||
|
default => (string) $value,
|
||||||
|
};
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,12 +9,14 @@ use ApiPlatform\Metadata\Post;
|
|||||||
use ApiPlatform\Metadata\Put;
|
use ApiPlatform\Metadata\Put;
|
||||||
use ApiPlatform\Metadata\Delete;
|
use ApiPlatform\Metadata\Delete;
|
||||||
use App\Repository\UserRepository;
|
use App\Repository\UserRepository;
|
||||||
|
use App\Validator\PasswordMinLength;
|
||||||
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;
|
use Symfony\Component\Serializer\Annotation\Groups;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
||||||
#[ORM\Table(name: 'users')]
|
#[ORM\Table(name: 'users')]
|
||||||
@ -40,6 +42,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
|
|
||||||
#[ORM\Column(length: 180)]
|
#[ORM\Column(length: 180)]
|
||||||
#[Groups(['user:read', 'user:write'])]
|
#[Groups(['user:read', 'user:write'])]
|
||||||
|
#[Assert\NotBlank(message: 'Die E-Mail-Adresse darf nicht leer sein')]
|
||||||
|
#[Assert\Email(message: 'Bitte geben Sie eine gültige E-Mail-Adresse ein')]
|
||||||
private ?string $email = null;
|
private ?string $email = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 100)]
|
#[ORM\Column(length: 100)]
|
||||||
@ -79,6 +83,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
* Plain password for API write operations (not persisted)
|
* Plain password for API write operations (not persisted)
|
||||||
*/
|
*/
|
||||||
#[Groups(['user:write'])]
|
#[Groups(['user:write'])]
|
||||||
|
#[Assert\NotBlank(message: 'Das Passwort darf nicht leer sein')]
|
||||||
|
#[PasswordMinLength]
|
||||||
private ?string $plainPassword = null;
|
private ?string $plainPassword = null;
|
||||||
|
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
|
|||||||
48
src/EventListener/DuplicateEmailExceptionListener.php
Normal file
48
src/EventListener/DuplicateEmailExceptionListener.php
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\EventListener;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||||
|
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||||
|
use Symfony\Component\HttpKernel\KernelEvents;
|
||||||
|
|
||||||
|
#[AsEventListener(event: KernelEvents::EXCEPTION, priority: 0)]
|
||||||
|
class DuplicateEmailExceptionListener
|
||||||
|
{
|
||||||
|
public function __invoke(ExceptionEvent $event): void
|
||||||
|
{
|
||||||
|
$exception = $event->getThrowable();
|
||||||
|
|
||||||
|
// Check if it's a unique constraint violation
|
||||||
|
if (!$exception instanceof UniqueConstraintViolationException) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the error is related to the email field
|
||||||
|
$message = $exception->getMessage();
|
||||||
|
if (stripos($message, 'UNIQ_IDENTIFIER_EMAIL') === false &&
|
||||||
|
stripos($message, 'email') === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a user-friendly error response
|
||||||
|
$response = new JsonResponse([
|
||||||
|
'@context' => '/api/contexts/ConstraintViolationList',
|
||||||
|
'@type' => 'ConstraintViolationList',
|
||||||
|
'hydra:title' => 'An error occurred',
|
||||||
|
'hydra:description' => 'Die E-Mail-Adresse wird bereits von einem anderen Benutzer verwendet',
|
||||||
|
'violations' => [
|
||||||
|
[
|
||||||
|
'propertyPath' => 'email',
|
||||||
|
'message' => 'Die E-Mail-Adresse wird bereits von einem anderen Benutzer verwendet',
|
||||||
|
'code' => 'DUPLICATE_EMAIL'
|
||||||
|
]
|
||||||
|
]
|
||||||
|
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
|
||||||
|
$event->setResponse($response);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/Repository/SettingRepository.php
Normal file
58
src/Repository/SettingRepository.php
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Setting;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<Setting>
|
||||||
|
*/
|
||||||
|
class SettingRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Setting::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a setting by its key
|
||||||
|
*/
|
||||||
|
public function findByKey(string $key): ?Setting
|
||||||
|
{
|
||||||
|
return $this->findOneBy(['settingKey' => $key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a setting value by key with default fallback
|
||||||
|
*/
|
||||||
|
public function getValue(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
$setting = $this->findByKey($key);
|
||||||
|
return $setting ? $setting->getValue() : $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a setting value by key (creates if not exists)
|
||||||
|
*/
|
||||||
|
public function setValue(string $key, mixed $value, string $type = 'string', ?string $description = null): Setting
|
||||||
|
{
|
||||||
|
$setting = $this->findByKey($key);
|
||||||
|
|
||||||
|
if (!$setting) {
|
||||||
|
$setting = new Setting();
|
||||||
|
$setting->setSettingKey($key);
|
||||||
|
$setting->setSettingType($type);
|
||||||
|
if ($description) {
|
||||||
|
$setting->setDescription($description);
|
||||||
|
}
|
||||||
|
$this->getEntityManager()->persist($setting);
|
||||||
|
}
|
||||||
|
|
||||||
|
$setting->setValue($value);
|
||||||
|
$this->getEntityManager()->flush();
|
||||||
|
|
||||||
|
return $setting;
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/Security/LoginFormAuthenticator.php
Normal file
70
src/Security/LoginFormAuthenticator.php
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Security;
|
||||||
|
|
||||||
|
use App\Service\SettingsService;
|
||||||
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\RouterInterface;
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||||
|
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
||||||
|
use Symfony\Component\Security\Http\SecurityRequestAttributes;
|
||||||
|
use Symfony\Component\Security\Http\Util\TargetPathTrait;
|
||||||
|
|
||||||
|
class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
|
||||||
|
{
|
||||||
|
use TargetPathTrait;
|
||||||
|
|
||||||
|
public const LOGIN_ROUTE = 'app_login';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private RouterInterface $router,
|
||||||
|
private SettingsService $settingsService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function authenticate(Request $request): Passport
|
||||||
|
{
|
||||||
|
// Check if password login is allowed
|
||||||
|
if (!$this->settingsService->isPasswordLoginAllowed()) {
|
||||||
|
throw new CustomUserMessageAuthenticationException(
|
||||||
|
'Der Login mit E-Mail und Passwort ist derzeit deaktiviert. Bitte verwenden Sie eine alternative Anmeldemethode.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$email = $request->request->get('email', '');
|
||||||
|
$password = $request->request->get('password', '');
|
||||||
|
|
||||||
|
$request->getSession()->set(SecurityRequestAttributes::LAST_USERNAME, $email);
|
||||||
|
|
||||||
|
return new Passport(
|
||||||
|
new UserBadge($email),
|
||||||
|
new PasswordCredentials($password),
|
||||||
|
[
|
||||||
|
new CsrfTokenBadge('authenticate', $request->request->get('_csrf_token')),
|
||||||
|
new RememberMeBadge(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||||
|
{
|
||||||
|
if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) {
|
||||||
|
return new RedirectResponse($targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RedirectResponse($this->router->generate('app_dashboard'));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getLoginUrl(Request $request): string
|
||||||
|
{
|
||||||
|
return $this->router->generate(self::LOGIN_ROUTE);
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/Service/SettingsService.php
Normal file
90
src/Service/SettingsService.php
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Repository\SettingRepository;
|
||||||
|
|
||||||
|
class SettingsService
|
||||||
|
{
|
||||||
|
// Setting Keys
|
||||||
|
public const PASSWORD_MIN_LENGTH = 'security.password_min_length';
|
||||||
|
public const ALLOW_PASSWORD_LOGIN = 'security.allow_password_login';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private SettingRepository $settingRepository
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get minimum password length (default: 8)
|
||||||
|
*/
|
||||||
|
public function getPasswordMinLength(): int
|
||||||
|
{
|
||||||
|
return (int) $this->settingRepository->getValue(self::PASSWORD_MIN_LENGTH, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set minimum password length
|
||||||
|
*/
|
||||||
|
public function setPasswordMinLength(int $length): void
|
||||||
|
{
|
||||||
|
if ($length < 4) {
|
||||||
|
throw new \InvalidArgumentException('Password minimum length must be at least 4 characters');
|
||||||
|
}
|
||||||
|
if ($length > 128) {
|
||||||
|
throw new \InvalidArgumentException('Password minimum length cannot exceed 128 characters');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->settingRepository->setValue(
|
||||||
|
self::PASSWORD_MIN_LENGTH,
|
||||||
|
$length,
|
||||||
|
'integer',
|
||||||
|
'Minimum password length required'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if password login is allowed (default: true)
|
||||||
|
*/
|
||||||
|
public function isPasswordLoginAllowed(): bool
|
||||||
|
{
|
||||||
|
return (bool) $this->settingRepository->getValue(self::ALLOW_PASSWORD_LOGIN, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set whether password login is allowed
|
||||||
|
*/
|
||||||
|
public function setPasswordLoginAllowed(bool $allowed): void
|
||||||
|
{
|
||||||
|
$this->settingRepository->setValue(
|
||||||
|
self::ALLOW_PASSWORD_LOGIN,
|
||||||
|
$allowed,
|
||||||
|
'boolean',
|
||||||
|
'Allow login with email and password'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all settings as array
|
||||||
|
*/
|
||||||
|
public function getAllSettings(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'passwordMinLength' => $this->getPasswordMinLength(),
|
||||||
|
'allowPasswordLogin' => $this->isPasswordLoginAllowed(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update multiple settings at once
|
||||||
|
*/
|
||||||
|
public function updateSettings(array $settings): void
|
||||||
|
{
|
||||||
|
if (isset($settings['passwordMinLength'])) {
|
||||||
|
$this->setPasswordMinLength((int) $settings['passwordMinLength']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($settings['allowPasswordLogin'])) {
|
||||||
|
$this->setPasswordLoginAllowed((bool) $settings['allowPasswordLogin']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/Validator/PasswordMinLength.php
Normal file
18
src/Validator/PasswordMinLength.php
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Validator;
|
||||||
|
|
||||||
|
use Symfony\Component\Validator\Constraint;
|
||||||
|
|
||||||
|
#[\Attribute]
|
||||||
|
class PasswordMinLength extends Constraint
|
||||||
|
{
|
||||||
|
public string $message = 'Das Passwort muss mindestens {{ min_length }} Zeichen lang sein.';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
array $groups = null,
|
||||||
|
mixed $payload = null
|
||||||
|
) {
|
||||||
|
parent::__construct([], $groups, $payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/Validator/PasswordMinLengthValidator.php
Normal file
40
src/Validator/PasswordMinLengthValidator.php
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Validator;
|
||||||
|
|
||||||
|
use App\Service\SettingsService;
|
||||||
|
use Symfony\Component\Validator\Constraint;
|
||||||
|
use Symfony\Component\Validator\ConstraintValidator;
|
||||||
|
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||||
|
use Symfony\Component\Validator\Exception\UnexpectedValueException;
|
||||||
|
|
||||||
|
class PasswordMinLengthValidator extends ConstraintValidator
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private SettingsService $settingsService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function validate(mixed $value, Constraint $constraint): void
|
||||||
|
{
|
||||||
|
if (!$constraint instanceof PasswordMinLength) {
|
||||||
|
throw new UnexpectedTypeException($constraint, PasswordMinLength::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty passwords are handled by NotBlank constraint
|
||||||
|
if (null === $value || '' === $value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_string($value)) {
|
||||||
|
throw new UnexpectedValueException($value, 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
$minLength = $this->settingsService->getPasswordMinLength();
|
||||||
|
|
||||||
|
if (mb_strlen($value) < $minLength) {
|
||||||
|
$this->context->buildViolation($constraint->message)
|
||||||
|
->setParameter('{{ min_length }}', (string) $minLength)
|
||||||
|
->addViolation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -166,50 +166,56 @@
|
|||||||
<a href="/">zum Dashboard</a>
|
<a href="/">zum Dashboard</a>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form method="post">
|
{% if allow_password_login %}
|
||||||
<div class="form-group">
|
<form method="post">
|
||||||
<label for="username">E-Mail-Adresse</label>
|
<div class="form-group">
|
||||||
<input
|
<label for="username">E-Mail-Adresse</label>
|
||||||
type="email"
|
<input
|
||||||
value="{{ last_username }}"
|
type="email"
|
||||||
name="_username"
|
value="{{ last_username }}"
|
||||||
id="username"
|
name="email"
|
||||||
class="form-control"
|
id="username"
|
||||||
autocomplete="email"
|
class="form-control"
|
||||||
placeholder="ihre@email.de"
|
autocomplete="email"
|
||||||
required
|
placeholder="ihre@email.de"
|
||||||
autofocus
|
required
|
||||||
>
|
autofocus
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Passwort</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
id="password"
|
||||||
|
class="form-control"
|
||||||
|
autocomplete="current-password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
|
||||||
|
|
||||||
|
<div class="remember-me">
|
||||||
|
<input type="checkbox" name="_remember_me" id="_remember_me">
|
||||||
|
<label for="_remember_me">Angemeldet bleiben</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn-login" type="submit">
|
||||||
|
Anmelden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 1.5rem 0; color: #9ca3af; font-size: 0.9rem;">
|
||||||
|
oder
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
<div class="form-group">
|
<div class="alert alert-info">
|
||||||
<label for="password">Passwort</label>
|
Der Login mit E-Mail und Passwort ist derzeit deaktiviert. Bitte verwenden Sie eine alternative Anmeldemethode.
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="_password"
|
|
||||||
id="password"
|
|
||||||
class="form-control"
|
|
||||||
autocomplete="current-password"
|
|
||||||
placeholder="••••••••"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
|
|
||||||
|
|
||||||
<div class="remember-me">
|
|
||||||
<input type="checkbox" name="_remember_me" id="_remember_me">
|
|
||||||
<label for="_remember_me">Angemeldet bleiben</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="btn-login" type="submit">
|
|
||||||
Anmelden
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div style="text-align: center; margin: 1.5rem 0; color: #9ca3af; font-size: 0.9rem;">
|
|
||||||
oder
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a href="{{ path('connect_pocketid_start') }}" style="text-decoration: none;">
|
<a href="{{ path('connect_pocketid_start') }}" style="text-decoration: none;">
|
||||||
<button type="button" class="btn-login" style="background: #10b981; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
|
<button type="button" class="btn-login" style="background: #10b981; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
|
||||||
@ -221,11 +227,13 @@
|
|||||||
</button>
|
</button>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="test-credentials">
|
{% if allow_password_login %}
|
||||||
<h4>🔐 Test-Zugangsdaten (Development):</h4>
|
<div class="test-credentials">
|
||||||
<div><strong>Administrator:</strong> <code>admin@mycrm.local</code> / <code>admin123</code></div>
|
<h4>🔐 Test-Zugangsdaten (Development):</h4>
|
||||||
<div><strong>Vertrieb:</strong> <code>sales@mycrm.local</code> / <code>sales123</code></div>
|
<div><strong>Administrator:</strong> <code>admin@mycrm.local</code> / <code>admin123</code></div>
|
||||||
</div>
|
<div><strong>Vertrieb:</strong> <code>sales@mycrm.local</code> / <code>sales123</code></div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user