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:
olli 2025-11-08 18:01:09 +01:00
parent 9122cd2cc1
commit 47b5dd1c23
18 changed files with 1261 additions and 62 deletions

214
SETTINGS.md Normal file
View 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
```

View File

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

View File

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

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

View File

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

View File

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

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

View File

@ -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,
]); ]);
} }

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

View File

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

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

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

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

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

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

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

View File

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