feat: Implement password setup functionality with email notifications and token management

This commit is contained in:
olli 2025-11-08 18:45:43 +01:00
parent 47b5dd1c23
commit cd3eb6afed
13 changed files with 834 additions and 11 deletions

3
.env
View File

@ -17,6 +17,7 @@
###> symfony/framework-bundle ### ###> symfony/framework-bundle ###
APP_ENV=dev APP_ENV=dev
APP_SECRET=83df005f029c92c8e01026218f588371 APP_SECRET=83df005f029c92c8e01026218f588371
APP_URL=http://localhost:8000
###< symfony/framework-bundle ### ###< symfony/framework-bundle ###
###> symfony/routing ### ###> symfony/routing ###
@ -43,7 +44,7 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###< symfony/messenger ### ###< symfony/messenger ###
###> symfony/mailer ### ###> symfony/mailer ###
MAILER_DSN=null://null MAILER_DSN=smtp://o.schwarten@osdata.org:pOlygon089@linus.osdata.org:587
###< symfony/mailer ### ###< symfony/mailer ###
###> nelmio/cors-bundle ### ###> nelmio/cors-bundle ###

View File

@ -120,7 +120,9 @@
</div> </div>
<div class="form-field" v-if="!isEditMode || showPasswordField"> <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 <Password
id="password" id="password"
v-model="formData.plainPassword" v-model="formData.plainPassword"
@ -129,6 +131,11 @@
:feedback="false" :feedback="false"
placeholder="••••••••" 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> <small class="p-error" v-if="errors.plainPassword">{{ errors.plainPassword }}</small>
<Button <Button
v-if="isEditMode && !showPasswordField" v-if="isEditMode && !showPasswordField"
@ -362,9 +369,7 @@ const validateForm = () => {
if (!formData.value.firstName) errors.value.firstName = 'Vorname ist erforderlich'; if (!formData.value.firstName) errors.value.firstName = 'Vorname ist erforderlich';
if (!formData.value.lastName) errors.value.lastName = 'Nachname ist erforderlich'; if (!formData.value.lastName) errors.value.lastName = 'Nachname ist erforderlich';
if (!formData.value.email) errors.value.email = 'E-Mail ist erforderlich'; if (!formData.value.email) errors.value.email = 'E-Mail ist erforderlich';
if (!isEditMode.value && !formData.value.plainPassword) { // Password is optional for new users - they will receive setup email
errors.value.plainPassword = 'Passwort ist erforderlich';
}
return Object.keys(errors.value).length === 0; return Object.keys(errors.value).length === 0;
}; };
@ -708,5 +713,13 @@ onMounted(() => {
font-size: 0.875rem; font-size: 0.875rem;
color: var(--p-text-muted-color); 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> </style>

View File

@ -13,7 +13,7 @@ framework:
max_retries: 3 max_retries: 3
multiplier: 2 multiplier: 2
failed: 'doctrine://default?queue_name=failed' failed: 'doctrine://default?queue_name=failed'
# sync: 'sync://' sync: 'sync://'
default_bus: messenger.bus.default default_bus: messenger.bus.default
@ -21,7 +21,7 @@ framework:
messenger.bus.default: [] messenger.bus.default: []
routing: routing:
Symfony\Component\Mailer\Messenger\SendEmailMessage: async Symfony\Component\Mailer\Messenger\SendEmailMessage: sync
Symfony\Component\Notifier\Message\ChatMessage: async Symfony\Component\Notifier\Message\ChatMessage: async
Symfony\Component\Notifier\Message\SmsMessage: async Symfony\Component\Notifier\Message\SmsMessage: async

View File

@ -4,6 +4,7 @@
# Put parameters here that don't need to change on each machine where the app is deployed # 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 # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters: parameters:
app.url: '%env(APP_URL)%'
services: services:
# default configuration for services in *this* file # default configuration for services in *this* file
@ -28,3 +29,14 @@ services:
decorates: 'api_platform.doctrine.orm.state.persist_processor' decorates: 'api_platform.doctrine.orm.state.persist_processor'
arguments: arguments:
$processor: '@.inner' $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%'

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

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

View File

@ -83,10 +83,15 @@ 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] #[PasswordMinLength]
private ?string $plainPassword = null; private ?string $plainPassword = null;
#[ORM\Column(nullable: true)]
private ?string $passwordSetupToken = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $passwordSetupTokenExpiresAt = null;
#[ORM\Column] #[ORM\Column]
#[Groups(['user:read'])] #[Groups(['user:read'])]
private ?\DateTimeImmutable $createdAt = null; private ?\DateTimeImmutable $createdAt = null;
@ -297,6 +302,36 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this; 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 public function __toString(): string
{ {
return $this->getFullName(); return $this->getFullName();

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

View 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>&copy; " . date('Y') . " myCRM. Alle Rechte vorbehalten.</p>
</div>
</body>
</html>
HTML;
}
}

View File

@ -5,13 +5,15 @@ namespace App\State;
use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProcessorInterface;
use App\Entity\User; use App\Entity\User;
use App\Service\PasswordSetupService;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
class UserPasswordHasher implements ProcessorInterface class UserPasswordHasher implements ProcessorInterface
{ {
public function __construct( public function __construct(
private ProcessorInterface $processor, 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); return $this->processor->process($data, $operation, $uriVariables, $context);
} }
$isNewUser = !$data->getId();
$hasPlainPassword = !empty($data->getPlainPassword());
// Hash plain password if provided // Hash plain password if provided
if ($data->getPlainPassword()) { if ($hasPlainPassword) {
$hashedPassword = $this->passwordHasher->hashPassword( $hashedPassword = $this->passwordHasher->hashPassword(
$data, $data,
$data->getPlainPassword() $data->getPlainPassword()
); );
$data->setPassword($hashedPassword); $data->setPassword($hashedPassword);
$data->eraseCredentials(); $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;
} }
} }

View File

@ -159,6 +159,13 @@
</div> </div>
{% endif %} {% 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 %} {% if app.user %}
<div class="alert alert-info"> <div class="alert alert-info">
Sie sind bereits angemeldet als <strong>{{ app.user.userIdentifier }}</strong>. Sie sind bereits angemeldet als <strong>{{ app.user.userIdentifier }}</strong>.

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

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