Procházet zdrojové kódy

forgot password

master
FlorianEisenmenger před 1 týdnem
rodič
revize
41f8b687d1
11 změnil soubory, kde provedl 524 přidání a 1 odebrání
  1. +47
    -1
      httpdocs/assets/styles/components/_login.scss
  2. +2
    -0
      httpdocs/config/packages/security.yaml
  3. +35
    -0
      httpdocs/migrations/central/Version20260524154917.php
  4. +175
    -0
      httpdocs/src/Controller/PasswordResetController.php
  5. +52
    -0
      httpdocs/src/Entity/Central/PasswordResetToken.php
  6. +16
    -0
      httpdocs/src/Repository/Central/PasswordResetTokenRepository.php
  7. +32
    -0
      httpdocs/templates/email/password_reset.html.twig
  8. +61
    -0
      httpdocs/templates/security/forgot_password.html.twig
  9. +1
    -0
      httpdocs/templates/security/login.html.twig
  10. +77
    -0
      httpdocs/templates/security/reset_password.html.twig
  11. +26
    -0
      httpdocs/translations/messages.de.yaml

+ 47
- 1
httpdocs/assets/styles/components/_login.scss Zobrazit soubor

@@ -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" ─────────────────────────────────────────────────────


+ 2
- 0
httpdocs/config/packages/security.yaml Zobrazit soubor

@@ -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 }



+ 35
- 0
httpdocs/migrations/central/Version20260524154917.php Zobrazit soubor

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

+ 175
- 0
httpdocs/src/Controller/PasswordResetController.php Zobrazit soubor

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

+ 52
- 0
httpdocs/src/Entity/Central/PasswordResetToken.php Zobrazit soubor

@@ -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; }
}

+ 16
- 0
httpdocs/src/Repository/Central/PasswordResetTokenRepository.php Zobrazit soubor

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

+ 32
- 0
httpdocs/templates/email/password_reset.html.twig Zobrazit soubor

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

+ 61
- 0
httpdocs/templates/security/forgot_password.html.twig Zobrazit soubor

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

+ 1
- 0
httpdocs/templates/security/login.html.twig Zobrazit soubor

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


+ 77
- 0
httpdocs/templates/security/reset_password.html.twig Zobrazit soubor

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

+ 26
- 0
httpdocs/translations/messages.de.yaml Zobrazit soubor

@@ -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!"


Načítá se…
Zrušit
Uložit