| @@ -60,7 +60,53 @@ | |||||
| } | } | ||||
| .login-form__field--password { | .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" ───────────────────────────────────────────────────── | // ─── "Angemeldet bleiben" ───────────────────────────────────────────────────── | ||||
| @@ -51,6 +51,8 @@ security: | |||||
| - { path: ^/api/register, roles: PUBLIC_ACCESS } | - { path: ^/api/register, roles: PUBLIC_ACCESS } | ||||
| - { path: ^/verify/, roles: PUBLIC_ACCESS } | - { path: ^/verify/, roles: PUBLIC_ACCESS } | ||||
| - { path: ^/invite/, 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: PUBLIC_ACCESS } | ||||
| - { path: ^/, roles: ROLE_USER } | - { 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" | class="input" | ||||
| autocomplete="current-password" | autocomplete="current-password" | ||||
| required /> | required /> | ||||
| <a href="{{ path('app_forgot_password') }}" class="login-form__forgot">{{ 'app.login.forgot_password'|trans }}</a> | |||||
| </div> | </div> | ||||
| </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" | btn_unlock: "Abrechnung aufheben" | ||||
| invoiced_title: "Abgerechnet – Bearbeiten nicht möglich" | 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: | login: | ||||
| page_title: "Anmelden – spawntree" | page_title: "Anmelden – spawntree" | ||||
| label_email: "E-Mail" | label_email: "E-Mail" | ||||
| label_password: "Passwort" | label_password: "Passwort" | ||||
| remember_me: "Angemeldet bleiben" | remember_me: "Angemeldet bleiben" | ||||
| btn_submit: "Anmelden" | btn_submit: "Anmelden" | ||||
| forgot_password: "Passwort vergessen?" | |||||
| register: | register: | ||||
| page_title: "Registrieren – spawntree Timetracker" | page_title: "Registrieren – spawntree Timetracker" | ||||
| @@ -149,6 +168,13 @@ app: | |||||
| col_email: "E-Mail" | col_email: "E-Mail" | ||||
| col_date: "Datum" | 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: | welcome: | ||||
| greeting: "Hallo %name%," | greeting: "Hallo %name%," | ||||
| body: "dein Konto für %company% ist jetzt aktiv. Los geht's!" | body: "dein Konto für %company% ist jetzt aktiv. Los geht's!" | ||||