| @@ -60,7 +60,53 @@ | |||
| } | |||
| .login-form__field--password { | |||
| // Platz für "vergessen?" Link, falls später hinzukommt | |||
| flex-wrap: wrap; | |||
| gap: $space-2 $space-3; | |||
| } | |||
| .login-form__forgot { | |||
| font-size: $font-size-sm; | |||
| color: $color-text-muted; | |||
| text-decoration: none; | |||
| white-space: nowrap; | |||
| &:hover { | |||
| color: $color-primary; | |||
| text-decoration: underline; | |||
| } | |||
| } | |||
| // ─── Footer-Link (z. B. "Zurück zur Anmeldung") ─────────────────────────────── | |||
| .login-form__footer { | |||
| text-align: center; | |||
| margin-top: $space-6; | |||
| } | |||
| .login-form__link { | |||
| font-size: $font-size-sm; | |||
| color: $color-text-muted; | |||
| text-decoration: none; | |||
| &:hover { | |||
| color: $color-primary; | |||
| text-decoration: underline; | |||
| } | |||
| } | |||
| // ─── Info-Text (nach E-Mail-Versand) ────────────────────────────────────────── | |||
| .login-card__info { | |||
| font-size: $font-size-base; | |||
| color: $color-text-base; | |||
| text-align: center; | |||
| margin-bottom: $space-6; | |||
| line-height: 1.6; | |||
| } | |||
| .login-card__sub { | |||
| font-size: $font-size-sm; | |||
| color: $color-text-muted; | |||
| text-align: center; | |||
| margin-bottom: $space-6; | |||
| } | |||
| // ─── "Angemeldet bleiben" ───────────────────────────────────────────────────── | |||
| @@ -51,6 +51,8 @@ security: | |||
| - { path: ^/api/register, roles: PUBLIC_ACCESS } | |||
| - { path: ^/verify/, roles: PUBLIC_ACCESS } | |||
| - { path: ^/invite/, roles: PUBLIC_ACCESS } | |||
| - { path: ^/forgot-password, roles: PUBLIC_ACCESS } | |||
| - { path: ^/reset-password/, roles: PUBLIC_ACCESS } | |||
| - { path: ^/$, roles: PUBLIC_ACCESS } | |||
| - { path: ^/, roles: ROLE_USER } | |||
| @@ -0,0 +1,35 @@ | |||
| <?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 Version20260524154917 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 password_reset_token (id INT AUTO_INCREMENT NOT NULL, token VARCHAR(64) NOT NULL, created_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, user_id INT NOT NULL, account_id INT NOT NULL, UNIQUE INDEX UNIQ_6B7BA4B65F37A13B (token), INDEX IDX_6B7BA4B6A76ED395 (user_id), INDEX IDX_6B7BA4B69B6B5FBA (account_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); | |||
| $this->addSql('ALTER TABLE password_reset_token ADD CONSTRAINT FK_6B7BA4B6A76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id)'); | |||
| $this->addSql('ALTER TABLE password_reset_token ADD CONSTRAINT FK_6B7BA4B69B6B5FBA FOREIGN KEY (account_id) REFERENCES account (id)'); | |||
| } | |||
| public function down(Schema $schema): void | |||
| { | |||
| // this down() migration is auto-generated, please modify it to your needs | |||
| $this->addSql('ALTER TABLE password_reset_token DROP FOREIGN KEY FK_6B7BA4B6A76ED395'); | |||
| $this->addSql('ALTER TABLE password_reset_token DROP FOREIGN KEY FK_6B7BA4B69B6B5FBA'); | |||
| $this->addSql('DROP TABLE password_reset_token'); | |||
| } | |||
| } | |||
| @@ -0,0 +1,175 @@ | |||
| <?php | |||
| // src/Controller/PasswordResetController.php | |||
| namespace App\Controller; | |||
| use App\Entity\Central\PasswordResetToken; | |||
| use App\Repository\Central\PasswordResetTokenRepository; | |||
| use App\Repository\Central\UserRepository; | |||
| use App\Service\TenantContext; | |||
| use Doctrine\ORM\EntityManagerInterface; | |||
| use Symfony\Bridge\Twig\Mime\TemplatedEmail; | |||
| use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | |||
| use Symfony\Bundle\SecurityBundle\Security; | |||
| use Symfony\Component\HttpFoundation\Request; | |||
| use Symfony\Component\HttpFoundation\Response; | |||
| use Symfony\Component\Mailer\MailerInterface; | |||
| use Symfony\Component\Mime\Address; | |||
| use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; | |||
| use Symfony\Component\Routing\Attribute\Route; | |||
| use Symfony\Component\Routing\Generator\UrlGeneratorInterface; | |||
| class PasswordResetController extends AbstractController | |||
| { | |||
| public function __construct( | |||
| private readonly EntityManagerInterface $em, | |||
| private readonly UserRepository $userRepo, | |||
| private readonly PasswordResetTokenRepository $resetTokenRepo, | |||
| private readonly TenantContext $tenantContext, | |||
| private readonly MailerInterface $mailer, | |||
| private readonly UserPasswordHasherInterface $passwordHasher, | |||
| private readonly Security $security, | |||
| private readonly UrlGeneratorInterface $urlGenerator, | |||
| ) {} | |||
| #[Route('/forgot-password', name: 'app_forgot_password', methods: ['GET', 'POST'])] | |||
| public function forgot(Request $request): Response | |||
| { | |||
| $account = $this->tenantContext->getAccount(); | |||
| if ($account === null) { | |||
| return $this->redirectToRoute('app_home'); | |||
| } | |||
| $sent = false; | |||
| $error = null; | |||
| if ($request->isMethod('POST')) { | |||
| $email = trim($request->request->get('email', '')); | |||
| if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { | |||
| $error = 'invalid_email'; | |||
| } else { | |||
| $user = $this->userRepo->findOneBy(['email' => $email]); | |||
| // Nur wenn User existiert UND zu diesem Account gehört | |||
| if ($user !== null && $this->userBelongsToAccount($user, $account)) { | |||
| // Alten Token für diesen User+Account entfernen | |||
| $existing = $this->resetTokenRepo->findOneBy([ | |||
| 'user' => $user, | |||
| 'account' => $account, | |||
| ]); | |||
| if ($existing !== null) { | |||
| $this->em->remove($existing); | |||
| $this->em->flush(); | |||
| } | |||
| $token = new PasswordResetToken(); | |||
| $token->setToken(bin2hex(random_bytes(32))); | |||
| $token->setUser($user); | |||
| $token->setAccount($account); | |||
| $this->em->persist($token); | |||
| $this->em->flush(); | |||
| $this->sendResetMail($token); | |||
| } | |||
| // Immer Erfolgsmeldung – kein User-Enumeration | |||
| $sent = true; | |||
| } | |||
| } | |||
| return $this->render('security/forgot_password.html.twig', [ | |||
| 'accountName' => $account->getName(), | |||
| 'sent' => $sent, | |||
| 'error' => $error, | |||
| ]); | |||
| } | |||
| #[Route('/reset-password/{token}', name: 'app_reset_password', methods: ['GET', 'POST'])] | |||
| public function reset(string $token, Request $request): Response | |||
| { | |||
| $resetToken = $this->resetTokenRepo->findOneBy(['token' => $token]); | |||
| if ($resetToken === null) { | |||
| return $this->render('security/reset_password.html.twig', [ | |||
| 'accountName' => $this->tenantContext->getAccount()?->getName() ?? '', | |||
| 'invalid' => true, | |||
| 'expired' => false, | |||
| 'error' => null, | |||
| ]); | |||
| } | |||
| if ($resetToken->isExpired()) { | |||
| $this->em->remove($resetToken); | |||
| $this->em->flush(); | |||
| return $this->render('security/reset_password.html.twig', [ | |||
| 'accountName' => $this->tenantContext->getAccount()?->getName() ?? '', | |||
| 'invalid' => false, | |||
| 'expired' => true, | |||
| 'error' => null, | |||
| ]); | |||
| } | |||
| $error = null; | |||
| if ($request->isMethod('POST')) { | |||
| $password = $request->request->get('password', ''); | |||
| $passwordRepeat = $request->request->get('passwordRepeat', ''); | |||
| if (strlen($password) < 8) { | |||
| $error = 'too_short'; | |||
| } elseif ($password !== $passwordRepeat) { | |||
| $error = 'mismatch'; | |||
| } else { | |||
| $user = $resetToken->getUser(); | |||
| $user->setPassword($this->passwordHasher->hashPassword($user, $password)); | |||
| $this->em->remove($resetToken); | |||
| $this->em->flush(); | |||
| $this->security->login($user, 'form_login', 'main'); | |||
| return $this->redirectToRoute('timetracking_week'); | |||
| } | |||
| } | |||
| return $this->render('security/reset_password.html.twig', [ | |||
| 'accountName' => $resetToken->getAccount()->getName(), | |||
| 'invalid' => false, | |||
| 'expired' => false, | |||
| 'error' => $error, | |||
| ]); | |||
| } | |||
| private function userBelongsToAccount( | |||
| \App\Entity\Central\User $user, | |||
| \App\Entity\Central\Account $account, | |||
| ): bool { | |||
| return $this->em->getRepository(\App\Entity\Central\AccountUser::class) | |||
| ->findOneBy(['user' => $user, 'account' => $account]) !== null; | |||
| } | |||
| private function sendResetMail(PasswordResetToken $token): void | |||
| { | |||
| $resetUrl = $this->urlGenerator->generate( | |||
| 'app_reset_password', | |||
| ['token' => $token->getToken()], | |||
| UrlGeneratorInterface::ABSOLUTE_URL, | |||
| ); | |||
| $user = $token->getUser(); | |||
| $email = (new TemplatedEmail()) | |||
| ->to(new Address($user->getEmail(), $user->getFullName())) | |||
| ->subject('Passwort zurücksetzen – ' . $token->getAccount()->getName()) | |||
| ->htmlTemplate('email/password_reset.html.twig') | |||
| ->context([ | |||
| 'token' => $token, | |||
| 'resetUrl' => $resetUrl, | |||
| ]); | |||
| $this->mailer->send($email); | |||
| } | |||
| } | |||
| @@ -0,0 +1,52 @@ | |||
| <?php | |||
| // src/Entity/Central/PasswordResetToken.php | |||
| namespace App\Entity\Central; | |||
| use App\Repository\Central\PasswordResetTokenRepository; | |||
| use Doctrine\ORM\Mapping as ORM; | |||
| #[ORM\Entity(repositoryClass: PasswordResetTokenRepository::class)] | |||
| #[ORM\Table(name: 'password_reset_token')] | |||
| class PasswordResetToken | |||
| { | |||
| #[ORM\Id] | |||
| #[ORM\GeneratedValue] | |||
| #[ORM\Column] | |||
| private ?int $id = null; | |||
| #[ORM\Column(length: 64, unique: true)] | |||
| private string $token = ''; | |||
| #[ORM\ManyToOne(targetEntity: User::class)] | |||
| #[ORM\JoinColumn(nullable: false)] | |||
| private ?User $user = null; | |||
| #[ORM\ManyToOne(targetEntity: Account::class)] | |||
| #[ORM\JoinColumn(nullable: false)] | |||
| private ?Account $account = null; | |||
| #[ORM\Column] | |||
| private \DateTimeImmutable $createdAt; | |||
| #[ORM\Column] | |||
| private \DateTimeImmutable $expiresAt; | |||
| public function __construct() | |||
| { | |||
| $this->createdAt = new \DateTimeImmutable(); | |||
| $this->expiresAt = new \DateTimeImmutable('+1 hour'); | |||
| } | |||
| public function isExpired(): bool { return $this->expiresAt < new \DateTimeImmutable(); } | |||
| public function getId(): ?int { return $this->id; } | |||
| public function getToken(): string { return $this->token; } | |||
| public function setToken(string $token): static { $this->token = $token; return $this; } | |||
| public function getUser(): ?User { return $this->user; } | |||
| public function setUser(?User $user): static { $this->user = $user; return $this; } | |||
| public function getAccount(): ?Account { return $this->account; } | |||
| public function setAccount(?Account $account): static { $this->account = $account; return $this; } | |||
| public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } | |||
| public function getExpiresAt(): \DateTimeImmutable { return $this->expiresAt; } | |||
| } | |||
| @@ -0,0 +1,16 @@ | |||
| <?php | |||
| // src/Repository/Central/PasswordResetTokenRepository.php | |||
| namespace App\Repository\Central; | |||
| use App\Entity\Central\PasswordResetToken; | |||
| use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; | |||
| use Doctrine\Persistence\ManagerRegistry; | |||
| class PasswordResetTokenRepository extends ServiceEntityRepository | |||
| { | |||
| public function __construct(ManagerRegistry $registry) | |||
| { | |||
| parent::__construct($registry, PasswordResetToken::class); | |||
| } | |||
| } | |||
| @@ -0,0 +1,32 @@ | |||
| {# templates/email/password_reset.html.twig #} | |||
| <!DOCTYPE html> | |||
| <html lang="de"> | |||
| <head><meta charset="UTF-8"></head> | |||
| <body style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; background: #dce9f5; margin: 0; padding: 40px 20px;"> | |||
| <div style="max-width: 540px; margin: 0 auto; background: #fff; border-radius: 16px; padding: 40px; box-shadow: 0 2px 12px rgba(0,60,120,0.08);"> | |||
| <p style="font-size: 1.2rem; font-weight: 700; color: #1a2a3a; margin: 0 0 8px;">{{ token.account.name }}</p> | |||
| <hr style="border: none; border-top: 1px solid #d0d8e0; margin: 0 0 32px;"> | |||
| <p style="color: #3a4a5a; margin: 0 0 16px;">{{ 'app.email.password_reset.greeting'|trans({'%name%': token.user.firstName}) }}</p> | |||
| <p style="color: #3a4a5a; margin: 0 0 24px;">{{ 'app.email.password_reset.body'|trans }}</p> | |||
| <div style="text-align: center; margin: 32px 0;"> | |||
| <a href="{{ resetUrl }}" | |||
| style="display: inline-block; background: #f0a500; color: #fff; font-weight: 700; | |||
| text-decoration: none; padding: 14px 40px; border-radius: 100px; font-size: 1rem;"> | |||
| {{ 'app.email.password_reset.btn'|trans }} | |||
| </a> | |||
| </div> | |||
| <p style="color: #7a8a9a; font-size: 0.8rem; margin: 24px 0 0;"> | |||
| {{ 'app.email.password_reset.expiry'|trans({'%expires%': token.expiresAt|date('d.m.Y H:i')}) }}<br> | |||
| {{ 'app.email.password_reset.ignore'|trans }} | |||
| </p> | |||
| <p style="color: #aab8c6; font-size: 0.75rem; margin: 8px 0 0; word-break: break-all;"> | |||
| {{ resetUrl }} | |||
| </p> | |||
| </div> | |||
| </body> | |||
| </html> | |||
| @@ -0,0 +1,61 @@ | |||
| {# templates/security/forgot_password.html.twig #} | |||
| <!DOCTYPE html> | |||
| <html lang="de"> | |||
| <head> | |||
| <meta charset="UTF-8"> | |||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |||
| <title>{{ 'app.forgot_password.page_title'|trans }}</title> | |||
| {{ encore_entry_link_tags('app') }} | |||
| </head> | |||
| <body class="login-body"> | |||
| <div class="login-card"> | |||
| <div class="login-card__title">{{ accountName }}</div> | |||
| {% if sent %} | |||
| <p class="login-card__info">{{ 'app.forgot_password.sent_info'|trans }}</p> | |||
| <div class="login-form__actions"> | |||
| <a href="{{ path('app_login') }}" class="btn btn-primary login-form__submit">{{ 'app.forgot_password.btn_back_to_login'|trans }}</a> | |||
| </div> | |||
| {% else %} | |||
| {% if error == 'invalid_email' %} | |||
| <div class="login-card__error">{{ 'app.forgot_password.error_invalid_email'|trans }}</div> | |||
| {% endif %} | |||
| <p class="login-card__sub">{{ 'app.forgot_password.subtitle'|trans }}</p> | |||
| <form class="login-form" method="post"> | |||
| <div class="login-form__grid"> | |||
| <label class="login-form__label" for="email">{{ 'app.login.label_email'|trans }}</label> | |||
| <div class="login-form__field"> | |||
| <input type="email" | |||
| id="email" | |||
| name="email" | |||
| class="input" | |||
| autocomplete="email" | |||
| autofocus | |||
| required /> | |||
| </div> | |||
| </div> | |||
| <div class="login-form__actions"> | |||
| <button type="submit" class="btn btn-primary login-form__submit">{{ 'app.forgot_password.btn_submit'|trans }}</button> | |||
| </div> | |||
| </form> | |||
| <div class="login-form__footer"> | |||
| <a href="{{ path('app_login') }}" class="login-form__link">{{ 'app.forgot_password.link_back'|trans }}</a> | |||
| </div> | |||
| {% endif %} | |||
| </div> | |||
| </body> | |||
| </html> | |||
| @@ -43,6 +43,7 @@ | |||
| class="input" | |||
| autocomplete="current-password" | |||
| required /> | |||
| <a href="{{ path('app_forgot_password') }}" class="login-form__forgot">{{ 'app.login.forgot_password'|trans }}</a> | |||
| </div> | |||
| </div> | |||
| @@ -0,0 +1,77 @@ | |||
| {# templates/security/reset_password.html.twig #} | |||
| <!DOCTYPE html> | |||
| <html lang="de"> | |||
| <head> | |||
| <meta charset="UTF-8"> | |||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |||
| <title>{{ 'app.reset_password.page_title'|trans }}</title> | |||
| {{ encore_entry_link_tags('app') }} | |||
| </head> | |||
| <body class="login-body"> | |||
| <div class="login-card"> | |||
| <div class="login-card__title">{{ accountName }}</div> | |||
| {% if invalid %} | |||
| <div class="login-card__error">{{ 'app.reset_password.error_invalid'|trans }}</div> | |||
| <div class="login-form__footer"> | |||
| <a href="{{ path('app_login') }}" class="login-form__link">{{ 'app.forgot_password.link_back'|trans }}</a> | |||
| </div> | |||
| {% elseif expired %} | |||
| <div class="login-card__error">{{ 'app.reset_password.error_expired'|trans }}</div> | |||
| <div class="login-form__footer"> | |||
| <a href="{{ path('app_forgot_password') }}" class="login-form__link">{{ 'app.reset_password.link_retry'|trans }}</a> | |||
| </div> | |||
| {% else %} | |||
| {% if error == 'too_short' %} | |||
| <div class="login-card__error">{{ 'app.reset_password.error_too_short'|trans }}</div> | |||
| {% elseif error == 'mismatch' %} | |||
| <div class="login-card__error">{{ 'app.reset_password.error_mismatch'|trans }}</div> | |||
| {% endif %} | |||
| <form class="login-form" method="post"> | |||
| <div class="login-form__grid"> | |||
| <label class="login-form__label" for="password">{{ 'app.set_password.label_password'|trans }}</label> | |||
| <div class="login-form__field"> | |||
| <input type="password" | |||
| id="password" | |||
| name="password" | |||
| class="input" | |||
| autocomplete="new-password" | |||
| autofocus | |||
| required | |||
| minlength="8" /> | |||
| </div> | |||
| <label class="login-form__label" for="passwordRepeat">{{ 'app.set_password.label_password_repeat'|trans }}</label> | |||
| <div class="login-form__field"> | |||
| <input type="password" | |||
| id="passwordRepeat" | |||
| name="passwordRepeat" | |||
| class="input" | |||
| autocomplete="new-password" | |||
| required /> | |||
| </div> | |||
| </div> | |||
| <div class="login-form__actions"> | |||
| <button type="submit" class="btn btn-primary login-form__submit">{{ 'app.reset_password.btn_submit'|trans }}</button> | |||
| </div> | |||
| </form> | |||
| {% endif %} | |||
| </div> | |||
| </body> | |||
| </html> | |||
| @@ -85,12 +85,31 @@ app: | |||
| btn_unlock: "Abrechnung aufheben" | |||
| invoiced_title: "Abgerechnet – Bearbeiten nicht möglich" | |||
| forgot_password: | |||
| page_title: "Passwort vergessen – spawntree" | |||
| subtitle: "Gib deine E-Mail ein und wir schicken dir einen Reset-Link." | |||
| btn_submit: "Reset-Link senden" | |||
| btn_back_to_login: "Zur Anmeldung" | |||
| link_back: "Zurück zur Anmeldung" | |||
| sent_info: "Falls ein Konto mit dieser E-Mail existiert, haben wir dir einen Link geschickt. Prüfe auch deinen Spam-Ordner." | |||
| error_invalid_email: "Bitte eine gültige E-Mail-Adresse eingeben." | |||
| reset_password: | |||
| page_title: "Neues Passwort – spawntree" | |||
| btn_submit: "Passwort speichern & anmelden" | |||
| error_too_short: "Das Passwort muss mindestens 8 Zeichen haben." | |||
| error_mismatch: "Die Passwörter stimmen nicht überein." | |||
| error_invalid: "Dieser Link ist ungültig." | |||
| error_expired: "Dieser Link ist abgelaufen (gültig 1 Stunde). Bitte fordere einen neuen an." | |||
| link_retry: "Neuen Reset-Link anfordern" | |||
| login: | |||
| page_title: "Anmelden – spawntree" | |||
| label_email: "E-Mail" | |||
| label_password: "Passwort" | |||
| remember_me: "Angemeldet bleiben" | |||
| btn_submit: "Anmelden" | |||
| forgot_password: "Passwort vergessen?" | |||
| register: | |||
| page_title: "Registrieren – spawntree Timetracker" | |||
| @@ -149,6 +168,13 @@ app: | |||
| col_email: "E-Mail" | |||
| col_date: "Datum" | |||
| password_reset: | |||
| greeting: "Hallo %name%," | |||
| body: "du hast ein Zurücksetzen deines Passworts angefordert. Klicke auf den Button, um ein neues Passwort festzulegen." | |||
| btn: "Passwort zurücksetzen" | |||
| expiry: "Der Link ist 1 Stunde gültig (bis %expires% Uhr)." | |||
| ignore: "Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren." | |||
| welcome: | |||
| greeting: "Hallo %name%," | |||
| body: "dein Konto für %company% ist jetzt aktiv. Los geht's!" | |||