diff --git a/SETTINGS.md b/SETTINGS.md
new file mode 100644
index 0000000..e48ed76
--- /dev/null
+++ b/SETTINGS.md
@@ -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
+```
diff --git a/assets/js/App.vue b/assets/js/App.vue
index 5e71181..909cedd 100644
--- a/assets/js/App.vue
+++ b/assets/js/App.vue
@@ -27,12 +27,25 @@
Deals
-
- Benutzerverwaltung
-
-
- Rollenverwaltung
-
+
+
+
@@ -64,6 +77,7 @@ const authStore = useAuthStore();
authStore.initializeFromElement(document.getElementById('app'));
const mobileMenuOpen = ref(false);
+const adminMenuOpen = ref(false);
const toggleMobileMenu = () => {
mobileMenuOpen.value = !mobileMenuOpen.value;
@@ -71,6 +85,15 @@ const toggleMobileMenu = () => {
const closeMobileMenu = () => {
mobileMenuOpen.value = false;
+ adminMenuOpen.value = false;
+};
+
+const toggleAdminMenu = () => {
+ adminMenuOpen.value = !adminMenuOpen.value;
+};
+
+const handleAdminMenuClick = () => {
+ closeMobileMenu();
};
@@ -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 {
display: flex;
align-items: center;
diff --git a/assets/js/router.js b/assets/js/router.js
index 1f2fd03..9720a25 100644
--- a/assets/js/router.js
+++ b/assets/js/router.js
@@ -5,6 +5,7 @@ import CompanyList from './views/CompanyList.vue';
import DealList from './views/DealList.vue';
import UserManagement from './views/UserManagement.vue';
import RoleManagement from './views/RoleManagement.vue';
+import SettingsManagement from './views/SettingsManagement.vue';
const routes = [
{ path: '/', name: 'dashboard', component: Dashboard },
@@ -13,6 +14,7 @@ const routes = [
{ path: '/deals', name: 'deals', component: DealList },
{ path: '/users', name: 'users', component: UserManagement, meta: { requiresAdmin: true } },
{ path: '/roles', name: 'roles', component: RoleManagement, meta: { requiresAdmin: true } },
+ { path: '/settings', name: 'settings', component: SettingsManagement, meta: { requiresAdmin: true } },
];
const router = createRouter({
diff --git a/assets/js/views/SettingsManagement.vue b/assets/js/views/SettingsManagement.vue
new file mode 100644
index 0000000..533abc9
--- /dev/null
+++ b/assets/js/views/SettingsManagement.vue
@@ -0,0 +1,264 @@
+
+
+
Systemeinstellungen
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/js/views/UserManagement.vue b/assets/js/views/UserManagement.vue
index 7cbbb6c..2f89aa3 100644
--- a/assets/js/views/UserManagement.vue
+++ b/assets/js/views/UserManagement.vue
@@ -395,7 +395,27 @@ const saveUser = async () => {
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({
severity: 'success',
@@ -407,7 +427,12 @@ const saveUser = async () => {
dialogVisible.value = false;
await fetchUsers();
} 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 {
saving.value = false;
}
@@ -426,13 +451,33 @@ const deleteUser = async () => {
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 });
deleteDialogVisible.value = false;
await fetchUsers();
} 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 {
deleting.value = false;
}
diff --git a/config/packages/security.yaml b/config/packages/security.yaml
index af0cfe9..20f9981 100644
--- a/config/packages/security.yaml
+++ b/config/packages/security.yaml
@@ -17,12 +17,8 @@ security:
lazy: true
provider: app_user_provider
custom_authenticators:
+ - App\Security\LoginFormAuthenticator
- App\Security\PocketIdAuthenticator
- form_login:
- login_path: app_login
- check_path: app_login
- enable_csrf: true
- default_target_path: /
logout:
path: app_logout
target: app_login
diff --git a/migrations/Version20251108164116.php b/migrations/Version20251108164116.php
new file mode 100644
index 0000000..f500cb0
--- /dev/null
+++ b/migrations/Version20251108164116.php
@@ -0,0 +1,36 @@
+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');
+ }
+}
diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php
index ff5af96..91cc91e 100644
--- a/src/Controller/SecurityController.php
+++ b/src/Controller/SecurityController.php
@@ -2,6 +2,7 @@
namespace App\Controller;
+use App\Service\SettingsService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -9,6 +10,10 @@ use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
class SecurityController extends AbstractController
{
+ public function __construct(
+ private SettingsService $settingsService
+ ) {}
+
#[Route(path: '/login', name: 'app_login')]
public function login(AuthenticationUtils $authenticationUtils): Response
{
@@ -23,9 +28,13 @@ class SecurityController extends AbstractController
// last username entered by the user
$lastUsername = $authenticationUtils->getLastUsername();
+ // Check if password login is allowed
+ $allowPasswordLogin = $this->settingsService->isPasswordLoginAllowed();
+
return $this->render('security/login.html.twig', [
'last_username' => $lastUsername,
'error' => $error,
+ 'allow_password_login' => $allowPasswordLogin,
]);
}
diff --git a/src/Controller/SettingsController.php b/src/Controller/SettingsController.php
new file mode 100644
index 0000000..8d1f101
--- /dev/null
+++ b/src/Controller/SettingsController.php
@@ -0,0 +1,54 @@
+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);
+ }
+ }
+}
diff --git a/src/Entity/Setting.php b/src/Entity/Setting.php
new file mode 100644
index 0000000..147cc47
--- /dev/null
+++ b/src/Entity/Setting.php
@@ -0,0 +1,127 @@
+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;
+ }
+}
diff --git a/src/Entity/User.php b/src/Entity/User.php
index 9f7c9bc..7255e14 100644
--- a/src/Entity/User.php
+++ b/src/Entity/User.php
@@ -9,12 +9,14 @@ use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use ApiPlatform\Metadata\Delete;
use App\Repository\UserRepository;
+use App\Validator\PasswordMinLength;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Annotation\Groups;
+use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: 'users')]
@@ -40,6 +42,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(length: 180)]
#[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;
#[ORM\Column(length: 100)]
@@ -79,6 +83,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* Plain password for API write operations (not persisted)
*/
#[Groups(['user:write'])]
+ #[Assert\NotBlank(message: 'Das Passwort darf nicht leer sein')]
+ #[PasswordMinLength]
private ?string $plainPassword = null;
#[ORM\Column]
diff --git a/src/EventListener/DuplicateEmailExceptionListener.php b/src/EventListener/DuplicateEmailExceptionListener.php
new file mode 100644
index 0000000..479c241
--- /dev/null
+++ b/src/EventListener/DuplicateEmailExceptionListener.php
@@ -0,0 +1,48 @@
+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);
+ }
+}
diff --git a/src/Repository/SettingRepository.php b/src/Repository/SettingRepository.php
new file mode 100644
index 0000000..9e1bcfb
--- /dev/null
+++ b/src/Repository/SettingRepository.php
@@ -0,0 +1,58 @@
+
+ */
+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;
+ }
+}
diff --git a/src/Security/LoginFormAuthenticator.php b/src/Security/LoginFormAuthenticator.php
new file mode 100644
index 0000000..738c46b
--- /dev/null
+++ b/src/Security/LoginFormAuthenticator.php
@@ -0,0 +1,70 @@
+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);
+ }
+}
diff --git a/src/Service/SettingsService.php b/src/Service/SettingsService.php
new file mode 100644
index 0000000..f4da1ce
--- /dev/null
+++ b/src/Service/SettingsService.php
@@ -0,0 +1,90 @@
+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']);
+ }
+ }
+}
diff --git a/src/Validator/PasswordMinLength.php b/src/Validator/PasswordMinLength.php
new file mode 100644
index 0000000..23fce12
--- /dev/null
+++ b/src/Validator/PasswordMinLength.php
@@ -0,0 +1,18 @@
+settingsService->getPasswordMinLength();
+
+ if (mb_strlen($value) < $minLength) {
+ $this->context->buildViolation($constraint->message)
+ ->setParameter('{{ min_length }}', (string) $minLength)
+ ->addViolation();
+ }
+ }
+}
diff --git a/templates/security/login.html.twig b/templates/security/login.html.twig
index d31758a..0721fb8 100644
--- a/templates/security/login.html.twig
+++ b/templates/security/login.html.twig
@@ -166,50 +166,56 @@
zum Dashboard
{% else %}
-