diff --git a/httpdocs/assets/styles/components/_login.scss b/httpdocs/assets/styles/components/_login.scss index 8eff774..85536ba 100644 --- a/httpdocs/assets/styles/components/_login.scss +++ b/httpdocs/assets/styles/components/_login.scss @@ -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" ───────────────────────────────────────────────────── diff --git a/httpdocs/config/packages/security.yaml b/httpdocs/config/packages/security.yaml index 63be903..3040cf8 100644 --- a/httpdocs/config/packages/security.yaml +++ b/httpdocs/config/packages/security.yaml @@ -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 } diff --git a/httpdocs/migrations/central/Version20260524154917.php b/httpdocs/migrations/central/Version20260524154917.php new file mode 100644 index 0000000..03dd625 --- /dev/null +++ b/httpdocs/migrations/central/Version20260524154917.php @@ -0,0 +1,35 @@ +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'); + } +} diff --git a/httpdocs/src/Controller/PasswordResetController.php b/httpdocs/src/Controller/PasswordResetController.php new file mode 100644 index 0000000..a997ea8 --- /dev/null +++ b/httpdocs/src/Controller/PasswordResetController.php @@ -0,0 +1,175 @@ +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); + } +} \ No newline at end of file diff --git a/httpdocs/src/Entity/Central/PasswordResetToken.php b/httpdocs/src/Entity/Central/PasswordResetToken.php new file mode 100644 index 0000000..428d387 --- /dev/null +++ b/httpdocs/src/Entity/Central/PasswordResetToken.php @@ -0,0 +1,52 @@ +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; } +} \ No newline at end of file diff --git a/httpdocs/src/Repository/Central/PasswordResetTokenRepository.php b/httpdocs/src/Repository/Central/PasswordResetTokenRepository.php new file mode 100644 index 0000000..15cd6fa --- /dev/null +++ b/httpdocs/src/Repository/Central/PasswordResetTokenRepository.php @@ -0,0 +1,16 @@ + + + + +
+ +

{{ token.account.name }}

+
+ +

{{ 'app.email.password_reset.greeting'|trans({'%name%': token.user.firstName}) }}

+

{{ 'app.email.password_reset.body'|trans }}

+ +
+ + {{ 'app.email.password_reset.btn'|trans }} + +
+ +

+ {{ 'app.email.password_reset.expiry'|trans({'%expires%': token.expiresAt|date('d.m.Y H:i')}) }}
+ {{ 'app.email.password_reset.ignore'|trans }} +

+

+ {{ resetUrl }} +

+ +
+ + \ No newline at end of file diff --git a/httpdocs/templates/security/forgot_password.html.twig b/httpdocs/templates/security/forgot_password.html.twig new file mode 100644 index 0000000..c20ddf0 --- /dev/null +++ b/httpdocs/templates/security/forgot_password.html.twig @@ -0,0 +1,61 @@ +{# templates/security/forgot_password.html.twig #} + + + + + + {{ 'app.forgot_password.page_title'|trans }} + {{ encore_entry_link_tags('app') }} + + + +
+ +
{{ accountName }}
+ + {% if sent %} + +

{{ 'app.forgot_password.sent_info'|trans }}

+
+ +
+ + {% else %} + + {% if error == 'invalid_email' %} +
{{ 'app.forgot_password.error_invalid_email'|trans }}
+ {% endif %} + +

{{ 'app.forgot_password.subtitle'|trans }}

+ +
+ + + + + +
+ + + + {% endif %} + +
+ + + \ No newline at end of file diff --git a/httpdocs/templates/security/login.html.twig b/httpdocs/templates/security/login.html.twig index e44dbc2..5d027b4 100644 --- a/httpdocs/templates/security/login.html.twig +++ b/httpdocs/templates/security/login.html.twig @@ -43,6 +43,7 @@ class="input" autocomplete="current-password" required /> + {{ 'app.login.forgot_password'|trans }} diff --git a/httpdocs/templates/security/reset_password.html.twig b/httpdocs/templates/security/reset_password.html.twig new file mode 100644 index 0000000..36d63fa --- /dev/null +++ b/httpdocs/templates/security/reset_password.html.twig @@ -0,0 +1,77 @@ +{# templates/security/reset_password.html.twig #} + + + + + + {{ 'app.reset_password.page_title'|trans }} + {{ encore_entry_link_tags('app') }} + + + +
+ +
{{ accountName }}
+ + {% if invalid %} + +
{{ 'app.reset_password.error_invalid'|trans }}
+ + + {% elseif expired %} + +
{{ 'app.reset_password.error_expired'|trans }}
+ + + {% else %} + + {% if error == 'too_short' %} +
{{ 'app.reset_password.error_too_short'|trans }}
+ {% elseif error == 'mismatch' %} +
{{ 'app.reset_password.error_mismatch'|trans }}
+ {% endif %} + +
+ + + + + +
+ + {% endif %} + +
+ + + \ No newline at end of file diff --git a/httpdocs/translations/messages.de.yaml b/httpdocs/translations/messages.de.yaml index 7626593..16e8bb7 100644 --- a/httpdocs/translations/messages.de.yaml +++ b/httpdocs/translations/messages.de.yaml @@ -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!"