feat: Implement password setup functionality with email notifications and token management
This commit is contained in:
parent
47b5dd1c23
commit
cd3eb6afed
3
.env
3
.env
@ -17,6 +17,7 @@
|
||||
###> symfony/framework-bundle ###
|
||||
APP_ENV=dev
|
||||
APP_SECRET=83df005f029c92c8e01026218f588371
|
||||
APP_URL=http://localhost:8000
|
||||
###< symfony/framework-bundle ###
|
||||
|
||||
###> symfony/routing ###
|
||||
@ -43,7 +44,7 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
|
||||
###< symfony/messenger ###
|
||||
|
||||
###> symfony/mailer ###
|
||||
MAILER_DSN=null://null
|
||||
MAILER_DSN=smtp://o.schwarten@osdata.org:pOlygon089@linus.osdata.org:587
|
||||
###< symfony/mailer ###
|
||||
|
||||
###> nelmio/cors-bundle ###
|
||||
|
||||
@ -120,7 +120,9 @@
|
||||
</div>
|
||||
|
||||
<div class="form-field" v-if="!isEditMode || showPasswordField">
|
||||
<label for="password">{{ isEditMode ? 'Neues Passwort' : 'Passwort' }} {{ isEditMode ? '' : '*' }}</label>
|
||||
<label for="password">
|
||||
{{ isEditMode ? 'Neues Passwort' : 'Passwort (optional)' }}
|
||||
</label>
|
||||
<Password
|
||||
id="password"
|
||||
v-model="formData.plainPassword"
|
||||
@ -129,6 +131,11 @@
|
||||
:feedback="false"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<small class="form-text">
|
||||
<template v-if="!isEditMode">
|
||||
Wenn Sie kein Passwort vergeben, erhält der Benutzer eine E-Mail mit einem Link zum Einrichten des Passworts.
|
||||
</template>
|
||||
</small>
|
||||
<small class="p-error" v-if="errors.plainPassword">{{ errors.plainPassword }}</small>
|
||||
<Button
|
||||
v-if="isEditMode && !showPasswordField"
|
||||
@ -362,9 +369,7 @@ const validateForm = () => {
|
||||
if (!formData.value.firstName) errors.value.firstName = 'Vorname ist erforderlich';
|
||||
if (!formData.value.lastName) errors.value.lastName = 'Nachname ist erforderlich';
|
||||
if (!formData.value.email) errors.value.email = 'E-Mail ist erforderlich';
|
||||
if (!isEditMode.value && !formData.value.plainPassword) {
|
||||
errors.value.plainPassword = 'Passwort ist erforderlich';
|
||||
}
|
||||
// Password is optional for new users - they will receive setup email
|
||||
|
||||
return Object.keys(errors.value).length === 0;
|
||||
};
|
||||
@ -708,5 +713,13 @@ onMounted(() => {
|
||||
font-size: 0.875rem;
|
||||
color: var(--p-text-muted-color);
|
||||
}
|
||||
|
||||
.form-text {
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--p-text-muted-color);
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -13,7 +13,7 @@ framework:
|
||||
max_retries: 3
|
||||
multiplier: 2
|
||||
failed: 'doctrine://default?queue_name=failed'
|
||||
# sync: 'sync://'
|
||||
sync: 'sync://'
|
||||
|
||||
default_bus: messenger.bus.default
|
||||
|
||||
@ -21,7 +21,7 @@ framework:
|
||||
messenger.bus.default: []
|
||||
|
||||
routing:
|
||||
Symfony\Component\Mailer\Messenger\SendEmailMessage: async
|
||||
Symfony\Component\Mailer\Messenger\SendEmailMessage: sync
|
||||
Symfony\Component\Notifier\Message\ChatMessage: async
|
||||
Symfony\Component\Notifier\Message\SmsMessage: async
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
# Put parameters here that don't need to change on each machine where the app is deployed
|
||||
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||
parameters:
|
||||
app.url: '%env(APP_URL)%'
|
||||
|
||||
services:
|
||||
# default configuration for services in *this* file
|
||||
@ -28,3 +29,14 @@ services:
|
||||
decorates: 'api_platform.doctrine.orm.state.persist_processor'
|
||||
arguments:
|
||||
$processor: '@.inner'
|
||||
|
||||
# Password Setup Service with APP_URL parameter
|
||||
App\Service\PasswordSetupService:
|
||||
arguments:
|
||||
$appUrl: '%app.url%'
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
# Mailer Logger with project directory
|
||||
App\EventListener\MailerLoggerListener:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
31
migrations/Version20251108170818.php
Normal file
31
migrations/Version20251108170818.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?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 Version20251108170818 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('ALTER TABLE users ADD password_setup_token VARCHAR(255) DEFAULT NULL, ADD password_setup_token_expires_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\'');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE users DROP password_setup_token, DROP password_setup_token_expires_at');
|
||||
}
|
||||
}
|
||||
69
src/Controller/PasswordSetupController.php
Normal file
69
src/Controller/PasswordSetupController.php
Normal file
@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Service\PasswordSetupService;
|
||||
use App\Service\SettingsService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class PasswordSetupController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private PasswordSetupService $passwordSetupService,
|
||||
private SettingsService $settingsService,
|
||||
private UserPasswordHasherInterface $passwordHasher,
|
||||
private EntityManagerInterface $entityManager
|
||||
) {}
|
||||
|
||||
#[Route('/password-setup/{token}', name: 'app_password_setup', methods: ['GET', 'POST'])]
|
||||
public function passwordSetup(string $token, Request $request): Response
|
||||
{
|
||||
$user = $this->passwordSetupService->getUserByToken($token);
|
||||
|
||||
if (!$user) {
|
||||
return $this->render('security/password_setup_invalid.html.twig');
|
||||
}
|
||||
|
||||
$error = null;
|
||||
$minLength = $this->settingsService->getPasswordMinLength();
|
||||
|
||||
if ($request->isMethod('POST')) {
|
||||
$password = $request->request->get('password');
|
||||
$passwordConfirm = $request->request->get('password_confirm');
|
||||
|
||||
// Validate password
|
||||
if (empty($password)) {
|
||||
$error = 'Das Passwort darf nicht leer sein';
|
||||
} elseif (mb_strlen($password) < $minLength) {
|
||||
$error = "Das Passwort muss mindestens {$minLength} Zeichen lang sein";
|
||||
} elseif ($password !== $passwordConfirm) {
|
||||
$error = 'Die Passwörter stimmen nicht überein';
|
||||
} else {
|
||||
// Hash and save password
|
||||
$hashedPassword = $this->passwordHasher->hashPassword($user, $password);
|
||||
$user->setPassword($hashedPassword);
|
||||
$user->setPasswordSetupToken(null);
|
||||
$user->setPasswordSetupTokenExpiresAt(null);
|
||||
$user->setIsActive(true);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->redirectToRoute('app_login', [
|
||||
'password_set' => 1
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('security/password_setup.html.twig', [
|
||||
'user' => $user,
|
||||
'token' => $token,
|
||||
'error' => $error,
|
||||
'min_length' => $minLength
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -83,10 +83,15 @@ 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(nullable: true)]
|
||||
private ?string $passwordSetupToken = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?\DateTimeImmutable $passwordSetupTokenExpiresAt = null;
|
||||
|
||||
#[ORM\Column]
|
||||
#[Groups(['user:read'])]
|
||||
private ?\DateTimeImmutable $createdAt = null;
|
||||
@ -297,6 +302,36 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPasswordSetupToken(): ?string
|
||||
{
|
||||
return $this->passwordSetupToken;
|
||||
}
|
||||
|
||||
public function setPasswordSetupToken(?string $passwordSetupToken): static
|
||||
{
|
||||
$this->passwordSetupToken = $passwordSetupToken;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPasswordSetupTokenExpiresAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->passwordSetupTokenExpiresAt;
|
||||
}
|
||||
|
||||
public function setPasswordSetupTokenExpiresAt(?\DateTimeImmutable $passwordSetupTokenExpiresAt): static
|
||||
{
|
||||
$this->passwordSetupTokenExpiresAt = $passwordSetupTokenExpiresAt;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isPasswordSetupTokenValid(): bool
|
||||
{
|
||||
if (!$this->passwordSetupToken || !$this->passwordSetupTokenExpiresAt) {
|
||||
return false;
|
||||
}
|
||||
return $this->passwordSetupTokenExpiresAt > new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->getFullName();
|
||||
|
||||
183
src/EventListener/MailerLoggerListener.php
Normal file
183
src/EventListener/MailerLoggerListener.php
Normal file
@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventListener;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\Mailer\Event\FailedMessageEvent;
|
||||
use Symfony\Component\Mailer\Event\MessageEvent;
|
||||
use Symfony\Component\Mailer\Event\SentMessageEvent;
|
||||
use Symfony\Component\Mime\Address;
|
||||
use Symfony\Component\Mime\Email;
|
||||
use Symfony\Component\Mime\RawMessage;
|
||||
|
||||
#[AsEventListener(event: MessageEvent::class, priority: 100)]
|
||||
#[AsEventListener(event: SentMessageEvent::class, priority: 100)]
|
||||
#[AsEventListener(event: FailedMessageEvent::class, priority: 100)]
|
||||
class MailerLoggerListener
|
||||
{
|
||||
private string $logFile;
|
||||
|
||||
public function __construct(
|
||||
private LoggerInterface $logger,
|
||||
string $projectDir
|
||||
) {
|
||||
$this->logFile = $projectDir . '/var/log/mail.log';
|
||||
|
||||
// Create log directory if it doesn't exist
|
||||
$logDir = dirname($this->logFile);
|
||||
if (!is_dir($logDir)) {
|
||||
mkdir($logDir, 0777, true);
|
||||
}
|
||||
}
|
||||
|
||||
public function __invoke(MessageEvent|SentMessageEvent|FailedMessageEvent $event): void
|
||||
{
|
||||
if ($event instanceof FailedMessageEvent) {
|
||||
$this->logFailedEmail($event);
|
||||
} elseif ($event instanceof SentMessageEvent) {
|
||||
$this->logSentEmail($event);
|
||||
} elseif ($event instanceof MessageEvent) {
|
||||
$this->logQueuedEmail($event);
|
||||
}
|
||||
}
|
||||
|
||||
private function logQueuedEmail(MessageEvent $event): void
|
||||
{
|
||||
$message = $event->getMessage();
|
||||
|
||||
if (!$message instanceof Email) {
|
||||
return;
|
||||
}
|
||||
|
||||
$timestamp = date('Y-m-d H:i:s');
|
||||
$from = $this->formatAddresses($message->getFrom());
|
||||
$to = $this->formatAddresses($message->getTo());
|
||||
$subject = $message->getSubject();
|
||||
|
||||
$logEntry = sprintf(
|
||||
"[%s] Email queued\n" .
|
||||
" From: %s\n" .
|
||||
" To: %s\n" .
|
||||
" Subject: %s\n" .
|
||||
" ------------------------\n\n",
|
||||
$timestamp,
|
||||
$from,
|
||||
$to,
|
||||
$subject
|
||||
);
|
||||
|
||||
file_put_contents($this->logFile, $logEntry, FILE_APPEND);
|
||||
|
||||
$this->logger->info('Email queued', [
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'subject' => $subject
|
||||
]);
|
||||
}
|
||||
|
||||
private function logSentEmail(SentMessageEvent $event): void
|
||||
{
|
||||
try {
|
||||
$rawMessage = $event->getMessage()->getMessage();
|
||||
|
||||
if (!$rawMessage instanceof Email) {
|
||||
return;
|
||||
}
|
||||
|
||||
$timestamp = date('Y-m-d H:i:s');
|
||||
$from = $this->formatAddresses($rawMessage->getFrom());
|
||||
$to = $this->formatAddresses($rawMessage->getTo());
|
||||
$subject = $rawMessage->getSubject();
|
||||
|
||||
$logEntry = sprintf(
|
||||
"[%s] ✓ Email successfully sent\n" .
|
||||
" From: %s\n" .
|
||||
" To: %s\n" .
|
||||
" Subject: %s\n" .
|
||||
" ------------------------\n\n",
|
||||
$timestamp,
|
||||
$from,
|
||||
$to,
|
||||
$subject
|
||||
);
|
||||
|
||||
file_put_contents($this->logFile, $logEntry, FILE_APPEND);
|
||||
|
||||
$this->logger->info('Email successfully sent', [
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'subject' => $subject
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// Silently fail if we can't extract message details
|
||||
}
|
||||
}
|
||||
|
||||
private function logFailedEmail(FailedMessageEvent $event): void
|
||||
{
|
||||
try {
|
||||
$rawMessage = $event->getMessage()->getMessage();
|
||||
|
||||
if (!$rawMessage instanceof Email) {
|
||||
return;
|
||||
}
|
||||
|
||||
$timestamp = date('Y-m-d H:i:s');
|
||||
$from = $this->formatAddresses($rawMessage->getFrom());
|
||||
$to = $this->formatAddresses($rawMessage->getTo());
|
||||
$subject = $rawMessage->getSubject();
|
||||
$error = $event->getError();
|
||||
$errorMessage = $error ? $error->getMessage() : 'Unknown error';
|
||||
|
||||
$logEntry = sprintf(
|
||||
"[%s] ✗ Email FAILED\n" .
|
||||
" From: %s\n" .
|
||||
" To: %s\n" .
|
||||
" Subject: %s\n" .
|
||||
" Error: %s\n" .
|
||||
" ------------------------\n\n",
|
||||
$timestamp,
|
||||
$from,
|
||||
$to,
|
||||
$subject,
|
||||
$errorMessage
|
||||
);
|
||||
|
||||
file_put_contents($this->logFile, $logEntry, FILE_APPEND);
|
||||
|
||||
$this->logger->error('Email failed to send', [
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'subject' => $subject,
|
||||
'error' => $errorMessage
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// Log generic error if we can't extract message details
|
||||
$timestamp = date('Y-m-d H:i:s');
|
||||
$error = $event->getError();
|
||||
$errorMessage = $error ? $error->getMessage() : 'Unknown error';
|
||||
|
||||
$logEntry = sprintf(
|
||||
"[%s] ✗ Email FAILED\n" .
|
||||
" Error: %s\n" .
|
||||
" ------------------------\n\n",
|
||||
$timestamp,
|
||||
$errorMessage
|
||||
);
|
||||
|
||||
file_put_contents($this->logFile, $logEntry, FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Address[] $addresses
|
||||
*/
|
||||
private function formatAddresses(array $addresses): string
|
||||
{
|
||||
return implode(', ', array_map(
|
||||
fn(Address $addr) => sprintf('%s <%s>', $addr->getName(), $addr->getAddress()),
|
||||
$addresses
|
||||
));
|
||||
}
|
||||
}
|
||||
203
src/Service/PasswordSetupService.php
Normal file
203
src/Service/PasswordSetupService.php
Normal file
@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Component\Mime\Email;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
class PasswordSetupService
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private MailerInterface $mailer,
|
||||
private UrlGeneratorInterface $urlGenerator,
|
||||
private string $appUrl,
|
||||
private string $projectDir
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Generate a secure token and send setup email
|
||||
*/
|
||||
public function sendPasswordSetupEmail(User $user): void
|
||||
{
|
||||
$logFile = $this->projectDir . '/var/log/mail.log';
|
||||
|
||||
try {
|
||||
// Generate secure random token
|
||||
$token = bin2hex(random_bytes(32));
|
||||
|
||||
// Set token and expiration (24 hours)
|
||||
$user->setPasswordSetupToken($token);
|
||||
$user->setPasswordSetupTokenExpiresAt(
|
||||
new \DateTimeImmutable('+24 hours')
|
||||
);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
// Generate setup URL
|
||||
$setupUrl = $this->appUrl . '/password-setup/' . $token;
|
||||
|
||||
// Send email
|
||||
$email = (new Email())
|
||||
->from('noreply@mycrm.local')
|
||||
->to($user->getEmail())
|
||||
->subject('Willkommen bei myCRM - Passwort einrichten')
|
||||
->html($this->getEmailTemplate($user, $setupUrl));
|
||||
|
||||
$this->mailer->send($email);
|
||||
|
||||
// Log success
|
||||
$logEntry = sprintf(
|
||||
"[%s] Password setup email queued successfully\n" .
|
||||
" User: %s %s (%s)\n" .
|
||||
" Token expires: %s\n" .
|
||||
" Setup URL: %s\n" .
|
||||
" ------------------------\n\n",
|
||||
date('Y-m-d H:i:s'),
|
||||
$user->getFirstName(),
|
||||
$user->getLastName(),
|
||||
$user->getEmail(),
|
||||
$user->getPasswordSetupTokenExpiresAt()->format('Y-m-d H:i:s'),
|
||||
$setupUrl
|
||||
);
|
||||
file_put_contents($logFile, $logEntry, FILE_APPEND);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Log error
|
||||
$logEntry = sprintf(
|
||||
"[%s] ✗ Failed to send password setup email\n" .
|
||||
" User: %s %s (%s)\n" .
|
||||
" Error: %s\n" .
|
||||
" ------------------------\n\n",
|
||||
date('Y-m-d H:i:s'),
|
||||
$user->getFirstName(),
|
||||
$user->getLastName(),
|
||||
$user->getEmail(),
|
||||
$e->getMessage()
|
||||
);
|
||||
file_put_contents($logFile, $logEntry, FILE_APPEND);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate token and return user
|
||||
*/
|
||||
public function getUserByToken(string $token): ?User
|
||||
{
|
||||
$user = $this->entityManager->getRepository(User::class)
|
||||
->findOneBy(['passwordSetupToken' => $token]);
|
||||
|
||||
if (!$user || !$user->isPasswordSetupTokenValid()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set password and clear token
|
||||
*/
|
||||
public function setPasswordFromToken(string $token, string $plainPassword): bool
|
||||
{
|
||||
$user = $this->getUserByToken($token);
|
||||
|
||||
if (!$user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Password will be hashed by UserStateProcessor
|
||||
$user->setPlainPassword($plainPassword);
|
||||
$user->setPasswordSetupToken(null);
|
||||
$user->setPasswordSetupTokenExpiresAt(null);
|
||||
$user->setIsActive(true); // Activate user when password is set
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function getEmailTemplate(User $user, string $setupUrl): string
|
||||
{
|
||||
return <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
.content {
|
||||
background: #f9f9f9;
|
||||
padding: 30px;
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
background: #2563eb;
|
||||
color: white;
|
||||
padding: 12px 30px;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #ddd;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>📊 Willkommen bei myCRM</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hallo {$user->getFirstName()},</p>
|
||||
|
||||
<p>Ihr Administrator hat einen Account für Sie in myCRM erstellt.</p>
|
||||
|
||||
<p><strong>E-Mail:</strong> {$user->getEmail()}</p>
|
||||
|
||||
<p>Bitte klicken Sie auf den folgenden Link, um Ihr Passwort einzurichten und Ihren Account zu aktivieren:</p>
|
||||
|
||||
<div style="text-align: center;">
|
||||
<a href="{$setupUrl}" class="button">Passwort einrichten</a>
|
||||
</div>
|
||||
|
||||
<p>Oder kopieren Sie diesen Link in Ihren Browser:</p>
|
||||
<p style="word-break: break-all; background: white; padding: 10px; border-radius: 4px;">
|
||||
{$setupUrl}
|
||||
</p>
|
||||
|
||||
<p><strong>Wichtig:</strong> Dieser Link ist 24 Stunden lang gültig.</p>
|
||||
|
||||
<p>Sollten Sie Probleme haben, wenden Sie sich bitte an Ihren Administrator.</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Dies ist eine automatisch generierte E-Mail. Bitte antworten Sie nicht auf diese Nachricht.</p>
|
||||
<p>© " . date('Y') . " myCRM. Alle Rechte vorbehalten.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
}
|
||||
}
|
||||
@ -5,13 +5,15 @@ namespace App\State;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Entity\User;
|
||||
use App\Service\PasswordSetupService;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
class UserPasswordHasher implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private ProcessorInterface $processor,
|
||||
private UserPasswordHasherInterface $passwordHasher
|
||||
private UserPasswordHasherInterface $passwordHasher,
|
||||
private PasswordSetupService $passwordSetupService
|
||||
) {
|
||||
}
|
||||
|
||||
@ -21,16 +23,37 @@ class UserPasswordHasher implements ProcessorInterface
|
||||
return $this->processor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
$isNewUser = !$data->getId();
|
||||
$hasPlainPassword = !empty($data->getPlainPassword());
|
||||
|
||||
// Hash plain password if provided
|
||||
if ($data->getPlainPassword()) {
|
||||
if ($hasPlainPassword) {
|
||||
$hashedPassword = $this->passwordHasher->hashPassword(
|
||||
$data,
|
||||
$data->getPlainPassword()
|
||||
);
|
||||
$data->setPassword($hashedPassword);
|
||||
$data->eraseCredentials();
|
||||
} elseif ($isNewUser) {
|
||||
// New user without password - set temporary random password
|
||||
$tempPassword = bin2hex(random_bytes(16));
|
||||
$hashedPassword = $this->passwordHasher->hashPassword($data, $tempPassword);
|
||||
$data->setPassword($hashedPassword);
|
||||
}
|
||||
|
||||
return $this->processor->process($data, $operation, $uriVariables, $context);
|
||||
// Process the user
|
||||
$result = $this->processor->process($data, $operation, $uriVariables, $context);
|
||||
|
||||
// Send password setup email for new users without password
|
||||
if ($isNewUser && !$hasPlainPassword) {
|
||||
try {
|
||||
$this->passwordSetupService->sendPasswordSetupEmail($data);
|
||||
} catch (\Exception $e) {
|
||||
// Log error but don't fail user creation
|
||||
error_log('Failed to send password setup email: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
@ -159,6 +159,13 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if app.request.query.get('password_set') %}
|
||||
<div class="alert alert-info">
|
||||
<strong>✓ Passwort erfolgreich eingerichtet!</strong><br>
|
||||
Sie können sich jetzt mit Ihrer E-Mail-Adresse und dem neu gesetzten Passwort anmelden.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if app.user %}
|
||||
<div class="alert alert-info">
|
||||
Sie sind bereits angemeldet als <strong>{{ app.user.userIdentifier }}</strong>.
|
||||
|
||||
176
templates/security/password_setup.html.twig
Normal file
176
templates/security/password_setup.html.twig
Normal file
@ -0,0 +1,176 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Passwort einrichten - myCRM</title>
|
||||
{{ encore_entry_link_tags('app') }}
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.setup-container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
padding: 3rem;
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
}
|
||||
.setup-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.setup-header h1 {
|
||||
margin: 0;
|
||||
color: #2563eb;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.setup-header p {
|
||||
margin: 0.5rem 0 0;
|
||||
color: #6b7280;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.user-info {
|
||||
background: #f3f4f6;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.user-info p {
|
||||
margin: 0.25rem 0;
|
||||
color: #374151;
|
||||
}
|
||||
.user-info strong {
|
||||
color: #1f2937;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
.btn-setup {
|
||||
width: 100%;
|
||||
padding: 0.875rem;
|
||||
background: #2563eb;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.btn-setup:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
.btn-setup:active {
|
||||
background: #1e40af;
|
||||
}
|
||||
.alert {
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.alert-danger {
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
.alert-info {
|
||||
background: #eff6ff;
|
||||
color: #1e40af;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
.password-hint {
|
||||
font-size: 0.85rem;
|
||||
color: #6b7280;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="setup-container">
|
||||
<div class="setup-header">
|
||||
<h1>📊 myCRM</h1>
|
||||
<p>Passwort einrichten</p>
|
||||
</div>
|
||||
|
||||
<div class="user-info">
|
||||
<p><strong>Willkommen, {{ user.firstName }} {{ user.lastName }}!</strong></p>
|
||||
<p>E-Mail: {{ user.email }}</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-danger">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="alert alert-info">
|
||||
Bitte wählen Sie ein sicheres Passwort für Ihren Account.
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
<div class="form-group">
|
||||
<label for="password">Neues Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="password"
|
||||
class="form-control"
|
||||
placeholder="Mindestens {{ min_length }} Zeichen"
|
||||
required
|
||||
autofocus
|
||||
minlength="{{ min_length }}"
|
||||
>
|
||||
<div class="password-hint">
|
||||
Mindestens {{ min_length }} Zeichen erforderlich
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password_confirm">Passwort bestätigen</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password_confirm"
|
||||
id="password_confirm"
|
||||
class="form-control"
|
||||
placeholder="Passwort wiederholen"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<button class="btn-setup" type="submit">
|
||||
Passwort speichern und Account aktivieren
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
70
templates/security/password_setup_invalid.html.twig
Normal file
70
templates/security/password_setup_invalid.html.twig
Normal file
@ -0,0 +1,70 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Ungültiger Link - myCRM</title>
|
||||
{{ encore_entry_link_tags('app') }}
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.error-container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
padding: 3rem;
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
text-align: center;
|
||||
}
|
||||
.error-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
h1 {
|
||||
color: #dc2626;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
p {
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.btn-login {
|
||||
display: inline-block;
|
||||
padding: 0.875rem 2rem;
|
||||
background: #2563eb;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.btn-login:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-container">
|
||||
<div class="error-icon">⚠️</div>
|
||||
<h1>Link ungültig oder abgelaufen</h1>
|
||||
<p>
|
||||
Der Link zum Einrichten Ihres Passworts ist ungültig oder bereits abgelaufen.
|
||||
Links sind 24 Stunden lang gültig.
|
||||
</p>
|
||||
<p>
|
||||
Bitte wenden Sie sich an Ihren Administrator, um einen neuen Link anzufordern.
|
||||
</p>
|
||||
<a href="{{ path('app_login') }}" class="btn-login">Zur Anmeldeseite</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
x
Reference in New Issue
Block a user