<?php declare(strict_types=1);
namespace App\StartPlatz\Bundle\UserBundle\Controller;
use App\StartPlatz\Bundle\LogBundle\Entity\MemberlogRepository;
use App\StartPlatz\Bundle\LogBundle\Service\CronjobLogger;
use App\StartPlatz\Bundle\MemberBundle\Entity\Member;
use App\StartPlatz\Bundle\StartupBundle\Entity\Application;
use App\StartPlatz\Bundle\StartupBundle\Entity\Batch;
use App\StartPlatz\Bundle\UserBundle\Entity\LoginTokenRepository;
use App\StartPlatz\Bundle\UserBundle\Entity\UserRepository;
use App\StartPlatz\Bundle\UserBundle\Form\SetPasswordFormType;
use App\StartPlatz\Bundle\WebsiteBundle\Utility\Utility;
use App\StartPlatz\Bundle\ApiBundle\Service\WebhookDispatcher;
use Doctrine\DBAL\Driver\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
use App\StartPlatz\Bundle\MailBundle\Service\MailService;
use Symfony\Component\Routing\Annotation\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
use App\StartPlatz\Bundle\FeedbackBundle\CallbackService;
use App\StartPlatz\Bundle\UserBundle\LoginService;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Validation;
use App\StartPlatz\Bundle\UserBundle\Entity\User;
use App\StartPlatz\Bundle\UserBundle\Form\LoginFormType;
use App\StartPlatz\Bundle\UserBundle\Form\LostPasswordFormType;
use App\StartPlatz\Bundle\UserBundle\Form\RegistrationFormType;
use App\StartPlatz\Bundle\UserBundle\Security\LoginLink\Token;
use Startplatz\Bundle\WordpressIntegrationBundle\Annotation\WordpressResponse;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
use Symfony\Component\Security\Core\Security as SymfonySecurity;
class AuthenticationController extends AbstractController
{
public function __construct(
private readonly SessionInterface $session,
private readonly RouterInterface $router,
private readonly UserPasswordEncoderInterface $encoder,
private readonly MailService $mailService,
private readonly CallbackService $callbackService,
private readonly Connection $connection,
private readonly WebhookDispatcher $webhookDispatcher,
private readonly LoginTokenRepository $loginTokenRepository,
private readonly MemberlogRepository $memberlogRepository,
private readonly CronjobLogger $cronjobLogger,
private readonly LoginService $loginService,
private readonly EntityManagerInterface $entityManager
) {
}
#[Route('/usfb/{md5}', name: 'unsubscribe_user_bulkmail')]
public function unsubscribeUserBulkmailAction($md5)
{
$em = $this->entityManager;
if (!$user = $em->getRepository(User::class)->findOneBy(['salt' => $md5])) {
return new Response('sorry, not found ');
}
if (!$user->getisOnBlacklist()) {
$member = $em->getRepository(Member::class)->find($user->getMemberId());
$em->getRepository(Member::class)->addTag('blacklist', $member, $user->getEmail(), $logText = 'User has unsubscribed');
}
$redirect = '/x/feed';
$hash = Token::createHash($user->getEmail(), $redirect);
$this->session->getFlashBag()->add('notice', 'SUCCESS you have been unsubscribed ');
return $this->redirect($this->generateUrl('login_email_check', [
'email' => $user->getEmail(),
'hash' => $hash,
'redirect' => $redirect,
]));
}
#[Route('/authentication/magic-link', name: 'login_magic_link')]
public function magicLinkAction(Request $request)
{
// Prüfe die Anfragemethode
if (!$request->isMethod('POST')) {
return new Response('Invalid request.', 405);
}
$em = $this->entityManager;
$email = strtolower((string) $request->get('email'));
/** @var Member $member */
if (!$member = $em->getRepository(Member::class)->findOneBy(['email' => $email])) {
return new Response('', 400);
}
// Get user for token-based magic link
$user = $this->getUserRepository()->findOneByEmail($email);
if (!$user) {
return new Response('', 400);
}
if ($redirect = $request->request->get('targetPath')) {
if ($redirect[0] != "/") {
$redirect = "/" . $redirect;
}
} elseif ($redirect = $request->get('targetPath')) {
## do nothing
} else {
$redirect = "/x/home";
}
// Create token-based magic link (one-time use, 4 hours validity)
$loginToken = $this->loginTokenRepository->createToken($user, 240, $redirect);
$loginLink = $this->generateUrl('login_token_check', ['token' => $loginToken->getToken()]);
$loginLink = $request->getSchemeAndHttpHost() . $loginLink;
// Render email from HTML template (full control over content)
$mailText = $this->renderView(
'@StartPlatzUserBundle/Mails/magicLink.html.twig',
[
'firstName' => $member->getFirstName(),
'magicLink' => $loginLink,
]
);
// Send via n8n webhook (Hetzner server - free & better deliverability)
$payload = [
'memberEmail' => $member->getEmail(),
'mailText' => $mailText,
'mailSubject' => 'Dein Login-Link / Your login link',
'fromEmail' => 'info@startplatz.de',
'fromName' => 'STARTPLATZ',
'bodyType' => 'html',
];
$this->webhookDispatcher->dispatch('email_notification', $payload, 'AuthenticationController::magicLinkAction', $member->getId());
return new Response('SUCCESS Magic Link sent to ' . $email);
}
#[Route('/authentication/magic-link-defense', name: 'login_magic_defense_link')]
/**
* @IsGranted("ROLE_USER")
*/
public function magicLinkDefenseAction(Request $request)
{
$em = $this->entityManager;
$email = strtolower((string) $request->get('email'));
/** @var Member $member */
if (!$member = $em->getRepository(Member::class)->findOneBy(['email' => $email])) {
return new Response('', 400);
}
// Get user for token-based magic link
$user = $this->getUserRepository()->findOneByEmail($email);
if (!$user) {
return new Response('', 400);
}
if ($redirect = $request->request->get('targetPath')) {
if ($redirect[0] != "/") {
$redirect = "/" . $redirect;
}
} elseif ($redirect = $request->get('targetPath')) {
## do nothing
} else {
$redirect = "/x/home";
}
// Create token-based magic link (one-time use, 4 hours validity)
$loginToken = $this->loginTokenRepository->createToken($user, 240, $redirect);
$loginLink = $this->generateUrl('login_token_check', ['token' => $loginToken->getToken()]);
$loginLink = $request->getSchemeAndHttpHost() . $loginLink;
// Render email from HTML template (full control over content)
$mailText = $this->renderView(
'@StartPlatzUserBundle/Mails/magicLink.html.twig',
[
'firstName' => $member->getFirstName(),
'magicLink' => $loginLink,
]
);
// Send via n8n webhook (Hetzner server - free & better deliverability)
$payload = [
'memberEmail' => $member->getEmail(),
'mailText' => $mailText,
'mailSubject' => 'Dein Login-Link / Your login link',
'fromEmail' => 'info@startplatz.de',
'fromName' => 'STARTPLATZ',
'bodyType' => 'html',
];
$this->webhookDispatcher->dispatch('email_notification', $payload, 'AuthenticationController::magicLinkDefenseAction', $member->getId());
return new Response('SUCCESS Magic Link sent to ' . $email);
}
/* Gerrit stash membership 11.4.23
/**
* @Route("/x/membership/finalize/{account}/{productNumber}/{customerHash}", name="x_membership_booked")
*
public function newMembershipLogin(Request $request, $customerHash, $account, $productNumber = 0)
{
if (!$redirect = json_decode(base64_decode($request->get('redirect')))) {
$redirect = json_decode(json_encode(array('path' => 'x_membership_first-steps', 'parameters' => array('productNumber' => $productNumber, 'account' => $account))));
}
$redirectUrl = $this->generateUrl($redirect->path, (array)$redirect->parameters);
//logged in
if ($user = $this->getUser()) {
if (!ctype_digit($user->getPassword())) $this->redirect($redirectUrl);
$form = $this->createSetPasswordForm($redirectUrl);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em = $this->entityManager;
$user = $this->getUser();
$data = $form->getData();
$password = $data['new_password'];
$newPasswordEncoded = $this->getUserPasswordEncoder()->encodePassword($user, $password);
$user->setPassword($newPasswordEncoded);
$em->getRepository(User::class)->add($user);
return $this->redirect($redirectUrl);
}
//logged in, set password
return $this->render('@StartPlatzAlphaBundle/Default/new.membership.login.html.twig', array(
'setPasswordForm' => $form->createView(),
'setPassword' => true,
'redirect' => base64_encode(json_encode($redirect)),
));
}
//Not logged in, ask for email to send link.
return $this->render('@StartPlatzAlphaBundle/Default/new.membership.login.html.twig', array(
'setPassword' => false,
'targetPath' => $this->generateUrl($redirect->path, (array)$redirect->parameters),
));
}
*/
#[Route('/login/', name: 'login')]
public function loginAction(Request $request)
{
if ($targetPath = $request->query->get('targetPath')) {
$parts = parse_url((string) $targetPath);
$redirect = $parts['path'];
} elseif ($targetPath = $request->getSession()->get('_security.secured_area.target_path')) {
$parts = parse_url((string) $targetPath);
$redirect = $parts['path'];
} else {
$redirect = $this->generateUrl('x_home');
}
return $this->showLogin($redirect, $request);
}
protected function showLogin($redirect, $request, $registrationFormData = [], $forms = [])
{
$redirectRouteName = null;
$session = $request->getSession();
try {
$routingParameter = $this->router->match($redirect);
$redirectRouteName = $routingParameter['_route'];
} catch (Exception) {
}
if ($this->getUser()) {
return $this->redirect($redirect ?: $this->generateUrl('x_home'));
}
if ($request->attributes->has(SymfonySecurity::AUTHENTICATION_ERROR)) {
$error = $request->attributes->get(SymfonySecurity::AUTHENTICATION_ERROR);
} else {
$error = $session->get(SymfonySecurity::AUTHENTICATION_ERROR);
$session->remove(SymfonySecurity::AUTHENTICATION_ERROR);
}
if ($error) {
$session->getFlashBag()->add('notice', $this->renderView('@StartPlatzUserBundle/Authentication/loginErrorFlash.html.twig'));
}
if (!array_key_exists('registrationForm', $forms)) {
$forms['registrationForm'] = $this->createRegistrationForm($redirect, $registrationFormData);
}
if (!array_key_exists('pwlostForm', $forms)) {
$forms['pwlostForm'] = $this->createPwlostForm($redirect, $registrationFormData);
}
$template = "@StartPlatzUserBundle/Authentication/login.html.twig";
return $this->render($template, ['redirectRouteName' => $redirectRouteName, 'redirect' => $redirect, 'loginForm' => $this->createLoginForm($redirect)->createView(), 'registrationForm' => $forms['registrationForm']->createView(), 'pwlostForm' => $forms['pwlostForm']->createView()]);
}
protected function createRegistrationForm($redirect = null, $data = [])
{
if ($redirect) {
$data['redirect'] = $redirect;
}
return $this->createForm(
RegistrationFormType::class,
$data
);
}
protected function createPwlostForm($redirect = null, $data = [])
{
if ($redirect) {
$data['redirect'] = $redirect;
}
return $this->createForm(
LostPasswordFormType::class,
$data
);
}
protected function createLoginForm($redirect)
{
return $this->createForm(
LoginFormType::class,
['target_path' => $redirect]
);
}
protected function createSetPasswordForm($redirect)
{
return $this->createForm(
SetPasswordFormType::class,
['target_path' => $redirect]
);
}
#[Route('/login/password/check', name: 'login_password_check')]
public function loginPasswordCheckAction(): Response
{
// This route should be intercepted by the FormLoginAuthenticator
return $this->redirectToRoute('login');
}
/** @return UserRepository */
protected function getUserRepository()
{
return $this->entityManager->getRepository(User::class);
}
/**
* @return UserPasswordEncoderInterface
*/
protected function getUserPasswordEncoder()
{
return $this->encoder;
}
protected function createEmailHash($salt, $email)
{
return Token::createHash($salt, $email);
}
#[Route('/login/link/create/{redirect}', name: 'login_link_create')]
/**
* @IsGranted("ROLE_USER")
*/
public function loginLinkCreateAction(Request $request, $redirect)
{
$url = $this->generateUrl($redirect);
$this->generateLoginLink($url);
$request->getSession()->getFlashBag()->add('notice', 'loginlink zu ' . $url . ' erstellt');
return $this->redirect($this->generateUrl('community_home'));
}
protected function generateLoginLink($redirect = '/x')
{
/** @var User $user */
$user = $this->getUser();
$rendered = $this->renderView(
'@StartPlatzUserBundle/Mails/sendLoginLink.txt.twig',
['email' => $user->getEmail(), 'name' => $user->getName(), 'hash' => Token::createHash($user->getEmail(), $redirect), 'redirect' => $redirect]
);
$this->mailService->send('login-link for startplatz.de', 'info@startplatz.de', 'Startplatz - Webseite', $user->getEmail(), null, $rendered, false);
return true;
}
#[Route('/login/link/lg/create/{redirect}', name: 'login_link_create_lg')]
public function createLoginLinkLg(Request $request, $redirect)
{
$url = $this->generateUrl($redirect);
$this->generateLoginLinkLg($url);
$request->getSession()->getFlashBag()->add('notice', 'loginlink zu ' . $url . ' erstellt');
return $this->redirect($this->generateUrl('community_home'));
}
protected function generateLoginLinkLg($redirect = '/crm/contacts')
{
$email = 'lorenz.graef@startplatz.de';
$rendered = $this->renderView(
'@StartPlatzUserBundle/Mails/sendLoginLink.txt.twig',
['email' => $email, 'name' => 'lorenz', 'hash' => Token::createHash($email, $redirect), 'redirect' => $redirect]
);
$this->mailService->send('login-link for startplatz.de', 'info@startplatz.de', 'Startplatz - Webseite', $email, null, $rendered, false);
return true;
}
#[Route('/lost-pw/', name: 'lost_pw', methods: ['POST'])]
/**
* @Template("@StartPlatzUserBundle/Authentication/login.html.twig")
*/
public function lostPwAction(Request $request)
{
$form = $this->createPwlostForm();
$form->handleRequest($request);
$data = $form->getData();
if ($form->isSubmitted() && $form->isValid()) {
$reset = $this->resetPassword($data);
if ($reset) {
$request->getSession()->getFlashBag()->add('notice', $this->renderView('@StartPlatzUserBundle/Authentication/passwordEmailFlash.html.twig', $data));
return $this->redirect($this->generateUrl('login'));
} else {
$request->getSession()->getFlashBag()->add('notice', $this->renderView('@StartPlatzUserBundle/Authentication/passwordEmailErrorFlash.html.twig', $data));
return $this->showLogin($data['redirect'], $request, $data, ['pwlostForm' => $form]);
}
} else {
return $this->showLogin($data['redirect'], $request, $data, ['pwlostForm' => $form]);
}
}
protected function resetPassword($data)
{
$password = substr(base_convert(sha1(uniqid((string)random_int(0, mt_getrandmax()), true)), 16, 36), 0, 8);
/** @var User $user */
if (!($user = $this->getUserRepository()->loadUser($data))) {
return false;
}
$user->setPassword($this->getUserPasswordEncoder()->encodePassword($user, $password));
$this->getUserRepository()->add($user);
//$password= $user->getPassword();
$rendered = $this->renderView(
'@StartPlatzUserBundle/Mails/login-password.txt.twig',
['email' => $user->getEmail(), 'name' => $user->getName(), 'hash' => Token::createHash($data['email'], $data['redirect']), 'redirect' => $data['redirect'], 'password' => $password]
);
$this->mailService->send('Dein Passwort für startplatz.de!', 'info@startplatz.de', 'Startplatz - Webseite', $data['email'], null, $rendered, false);
return true;
}
#[Route('/profile/send-new-password/', name: 'user_profile_send_new_password')]
/**
* @IsGranted("ROLE_USER")
*/
public function sendNewPasswordAction(Request $request)
{
$user = $this->getUser();
$data['email'] = $user->getEmail();
$data['redirect'] = '/profile/set-password/';
$reset = $this->resetPassword($data);
if ($reset) {
$request->getSession()->getFlashBag()->add('notice', $this->renderView('@StartPlatzUserBundle/Authentication/passwordEmailFlash.html.twig', $data));
} else {
$request->getSession()->getFlashBag()->add('notice', $this->renderView('@StartPlatzUserBundle/Authentication/passwordEmailErrorFlash.html.twig', $data));
}
return $this->redirect($this->generateUrl('user_profil_change_password'));
}
#[Route('/login/link/check/{email}/{hash}/to{redirect}', name: 'login_email_check', requirements: ['redirect' => '.+'])]
public function loginLinkCheckAction(Request $request, $redirect = '/', $hash = "")
{
if ($user = $this->getUser()) {
$em = $this->entityManager;
$em->getRepository(User::class)->writeActivity($user);
if ($batches = $em->getRepository(Batch::class)->findByExtended(['settings' => 'validateEmail'])) {
foreach ($batches as $batch) {
$batchId = $batch->getId();
if ($application = $em->getRepository(Application::class)->findOneBy(['batchId' => $batchId, 'memberId'=>$user->getMemberId()])) {
$application->setHasEmailValidated(true);
$em->persist($application);
$em->flush();
}
}
}
if ($request->get('action') == 'setPassword') {
return $this->redirect($this->generateUrl('x_home', ['hash' => $hash]));
}
return $this->redirect($redirect);
} else {
$this->session->getFlashBag()->add('notice', 'ERROR: no user found');
return $this->redirect($this->generateUrl('x_home'));
}
}
/**
* Token-based login link (new system - one-time use)
*
* GET: Shows confirmation page (protects against email security scanners)
* POST: Authenticator has validated token, complete login and redirect
* GET with ?app=1: Mobile app SSO bypass, authenticator handles directly
*/
#[Route('/login/token/{token}', name: 'login_token_check', methods: ['GET', 'POST'])]
public function loginTokenCheckAction(Request $request, string $token): Response
{
// POST or GET with ?app=1: Authenticator has already validated and consumed the token
if ($request->isMethod('POST') || $request->query->get('app') === '1') {
return $this->handleTokenLoginSuccess($request, $token);
}
// GET without ?app=1: Show confirmation page (scanner protection)
return $this->showTokenConfirmationPage($token);
}
/**
* Show the login confirmation page for token-based magic links
*
* This page protects against email security scanners (Microsoft SafeLinks, etc.)
* that automatically click links in emails. The scanner will see this page but
* won't click the "Log in" button, preserving the token for the actual user.
*/
private function showTokenConfirmationPage(string $token): Response
{
// Check if token exists and is valid (without consuming it)
$loginToken = $this->loginTokenRepository->findValidToken($token);
if (!$loginToken) {
$this->session->getFlashBag()->add('notice', 'ERROR: Login-Link ist ungültig oder abgelaufen.');
return $this->redirect($this->generateUrl('login'));
}
return $this->render('@StartPlatzUserBundle/Authentication/loginTokenConfirm.html.twig', [
'token' => $token,
]);
}
/**
* Handle successful token-based login (after authenticator validation)
*/
private function handleTokenLoginSuccess(Request $request, string $token): Response
{
if ($user = $this->getUser()) {
$em = $this->entityManager;
$em->getRepository(User::class)->writeActivity($user);
// Get redirect from token (stored in database)
$loginToken = $this->loginTokenRepository->findOneBy(['token' => $token]);
$redirect = $loginToken?->getRedirect() ?? '/x/home';
// Log the magic link login
if ($memberId = $user->getMemberId()) {
$member = $em->getRepository(Member::class)->find($memberId);
if ($member) {
$this->memberlogRepository->setMemberLog(
'magic_link_login',
$member,
null, // team
'system',
sprintf(
'Magic Link Login (one-time token) - Redirect: %s, Token-ID: %d, IP: %s',
$redirect,
$loginToken?->getId() ?? 0,
$request->getClientIp() ?? 'unknown'
)
);
}
}
// Validate email for batches (same logic as legacy route)
if ($batches = $em->getRepository(Batch::class)->findByExtended(['settings' => 'validateEmail'])) {
foreach ($batches as $batch) {
$batchId = $batch->getId();
if ($application = $em->getRepository(Application::class)->findOneBy(['batchId' => $batchId, 'memberId' => $user->getMemberId()])) {
$application->setHasEmailValidated(true);
$em->persist($application);
$em->flush();
}
}
}
return $this->redirect($redirect);
}
$this->session->getFlashBag()->add('notice', 'ERROR: Login-Link ist ungültig oder abgelaufen.');
return $this->redirect($this->generateUrl('login'));
}
#[Route('/login/confirm/{email}/{hash}/to{redirect}', name: 'login_confirm_email', requirements: ['redirect' => '.+'])]
public function confirmLinkCheckAction(Request $request, $email, $hash, $redirect = '/login')
{
$em = $this->entityManager;
$action = $request->get('action');
if (!$user = $this->getUserRepository()->findUserByConfirm($hash)) {
if ($this->getUserRepository()->findOneBy(['email' => $email])) {
$this->session->getFlashBag()->add('notice', 'ERROR email already confirmed. Please login.');
} else {
$this->session->getFlashBag()->add('notice', 'ERROR not matching any user');
}
return $this->redirect('/logout');
}
if (!$user->getIsEmailConfirmed()) {
$user->setIsEmailConfirmed(true);
$user->setEmail($user->getToConfirmEmail());
$user->setConfirmEmail(null);
$user->setToConfirmEmail(null);
$this->getUserRepository()->add($user);
if ($memberId = $user->getMemberId()) {
$em->getRepository(Member::class)->changeEmailByMemberId($memberId, $user->getEmail(), $user->getEmail());
}
}
return $this->redirect($this->getLoginLink($user->getEmail(), $redirect, $action));
}
private function getLoginLink($email, $redirect = '/x', $action = null)
{
$hash = Token::createHash($email, $redirect);
if ($action) {
$loginLink = $this->generateUrl('login_email_check', ['email' => $email, 'hash' => $hash, 'redirect' => $redirect, 'action' => $action]);
} else {
$loginLink = $this->generateUrl('login_email_check', ['email' => $email, 'hash' => $hash, 'redirect' => $redirect]);
}
return $loginLink;
}
#[Route('/logout/', name: 'logout')]
public function logoutAction(): void
{
}
/**
* @Template
*/
public function loginStatusAction()
{
return [];
}
/**
* Cleanup expired login tokens (for FastCron)
*
* Should be called daily to clean up expired tokens from the database.
* Tokens that are expired or already used are deleted.
*/
#[Route('/api/cron/cleanup-login-tokens', name: 'cron_cleanup_login_tokens')]
public function cleanupLoginTokensAction(): Response
{
$log = $this->cronjobLogger->start('cleanup:login-tokens', 'Delete expired/used login tokens', 'fastcron');
try {
$deleted = $this->loginTokenRepository->cleanupExpiredTokens();
$this->cronjobLogger->success($log, "Deleted {$deleted} expired/used login tokens", $deleted);
return new Response(json_encode([
'success' => true,
'deleted' => $deleted,
'timestamp' => date('Y-m-d H:i:s'),
]), 200, ['Content-Type' => 'application/json']);
} catch (\Exception $e) {
$this->cronjobLogger->fail($log, $e->getMessage());
return new Response(json_encode([
'success' => false,
'error' => $e->getMessage(),
]), 500, ['Content-Type' => 'application/json']);
}
}
// =========================================================================
// PASSWORD RESET FLOW (Clean Implementation)
// =========================================================================
/**
* Request a password reset magic link (AJAX endpoint)
*
* Creates a magic link that redirects to the password-set page after login.
* Can be called by logged-in users (from profile) or by admins for other users.
* Sends email via n8n webhook (Hetzner server) for better deliverability.
*/
#[Route('/password-reset/request', name: 'password_reset_request', methods: ['GET', 'POST'])]
/**
* @IsGranted("ROLE_USER")
*/
public function passwordResetRequestAction(Request $request): Response
{
$em = $this->entityManager;
$email = strtolower((string) $request->get('email'));
/** @var Member $member */
if (!$member = $em->getRepository(Member::class)->findOneBy(['email' => $email])) {
return new Response('ERROR User not found', 400);
}
$user = $this->getUserRepository()->findOneByEmail($email);
if (!$user) {
return new Response('ERROR User not found', 400);
}
// Redirect to standalone password-set page after login
$redirect = '/password-reset/set';
// Create token-based magic link (one-time use, 4 hours validity)
$loginToken = $this->loginTokenRepository->createToken($user, 240, $redirect);
$loginLink = $this->generateUrl('login_token_check', ['token' => $loginToken->getToken()]);
$loginLink = $request->getSchemeAndHttpHost() . $loginLink;
// Render email body from HTML template (full control over content and formatting)
$mailText = $this->renderView(
'@StartPlatzUserBundle/Mails/passwordReset.html.twig',
[
'firstName' => $member->getFirstName(),
'magicLink' => $loginLink,
]
);
// Send via n8n webhook (Hetzner server - better deliverability than Mailchimp)
$payload = [
'memberEmail' => $member->getEmail(),
'mailText' => $mailText,
'mailSubject' => 'Passwort zurücksetzen / Reset your password',
'fromEmail' => 'info@startplatz.de',
'fromName' => 'STARTPLATZ',
'bodyType' => 'html',
];
$this->webhookDispatcher->dispatch(
'email_notification',
$payload,
'AuthenticationController::passwordResetRequestAction',
$member->getId()
);
return new Response('SUCCESS Password reset link sent to ' . $email);
}
/**
* Standalone password reset page (after magic link login)
*
* Shows a simple form to set a new password. No old password required.
*/
#[Route('/password-reset/set', name: 'password_reset_set')]
/**
* @IsGranted("ROLE_USER")
*/
public function passwordResetSetAction(Request $request): Response
{
/** @var User $user */
$user = $this->getUser();
$em = $this->entityManager;
$member = $em->getRepository(Member::class)->findOneBy(['email' => $user->getEmail()]);
if (!$member) {
$this->session->getFlashBag()->add('notice', 'ERROR Member not found');
return $this->redirect($this->generateUrl('login'));
}
return $this->render('@StartPlatzUserBundle/PasswordReset/set-password.html.twig', [
'user' => $user,
'member' => $member,
]);
}
/**
* Process password reset form submission
*/
#[Route('/password-reset/set/submit', name: 'password_reset_submit', methods: ['POST'])]
/**
* @IsGranted("ROLE_USER")
*/
public function passwordResetSubmitAction(Request $request): Response
{
/** @var User $user */
$user = $this->getUser();
$em = $this->entityManager;
$newPassword = $request->request->get('newPassword');
$newPasswordConfirm = $request->request->get('newPasswordConfirm');
// Validation
if (empty($newPassword)) {
$this->session->getFlashBag()->add('notice', 'ERROR Please enter a new password.');
return $this->redirect($this->generateUrl('password_reset_set'));
}
if (strlen($newPassword) < 6) {
$this->session->getFlashBag()->add('notice', 'ERROR Password must be at least 6 characters.');
return $this->redirect($this->generateUrl('password_reset_set'));
}
if ($newPassword !== $newPasswordConfirm) {
$this->session->getFlashBag()->add('notice', 'ERROR Passwords do not match.');
return $this->redirect($this->generateUrl('password_reset_set'));
}
// Set new password
$newPasswordEncoded = $this->encoder->encodePassword($user, $newPassword);
$user->setPassword($newPasswordEncoded);
$em->getRepository(User::class)->add($user);
// Log the password change
$member = $em->getRepository(Member::class)->findOneBy(['email' => $user->getEmail()]);
if ($member) {
$this->memberlogRepository->setMemberLog(
'password_reset',
$member,
null,
'user',
sprintf('Password reset completed via magic link, IP: %s', $request->getClientIp() ?? 'unknown')
);
}
$this->session->getFlashBag()->add('notice', 'SUCCESS Your password has been changed successfully.');
return $this->redirect($this->generateUrl('x_home'));
}
/**
* Legacy magic link endpoint (migrated from GuestBundle).
*/
#[Route('/guest/magic-link', name: 'guest_magic-link')]
public function legacyMagicLinkAction(Request $request)
{
$em = $this->entityManager;
$data = $request->request->all();
$redirect = Utility::getRedirectTarget($request);
$targetPath = $data['targetPath'];
if (!$this->validateLegacyMagicLinkEmail($data)) {
return $this->redirect($this->generateUrl($redirect->path, (array) $redirect->parameters));
}
if (!$member = $em->getRepository(Member::class)->findOneBy(['email' => $data['email']])) {
$this->session->getFlashBag()->add('notice', 'ERROR ' . "ERROR - we could not find you in our database");
return $this->redirect($this->generateUrl($redirect->path, (array) $redirect->parameters));
}
$loginLink = $this->loginService->getLoginLinkByTargetPath($member->getEmail(), $targetPath);
$mailSubject = "STARTPLATZ Magic Link";
$mailText = $this->renderView(
'@StartPlatzUserBundle/Mails/send.magic-link.txt.twig',
[
'member' => $member,
'loginLink' => $loginLink,
]
);
$this->callbackService->sendAlertMailPerZapier("{$member->getEmail()}", $mailText, $mailSubject, "support@startplatz.de");
$this->session->getFlashBag()->add('notice', 'SUCCESS ' . "We have sent a magic link. Please check your inbox!");
return $this->redirect($this->generateUrl($redirect->path, (array) $redirect->parameters));
}
private function validateLegacyMagicLinkEmail($data): bool
{
if (!($data['email'])) {
$this->session->getFlashBag()->add('notice', 'ERROR Please fill in a valid email');
return false;
}
$validator = Validation::createValidator();
$input['email'] = $data['email'];
$constraint = new Assert\Collection([
'email' => new Assert\Email(),
]);
if ($violations = $validator->validate($input, $constraint)) {
$error = false;
foreach ($violations as $violation) {
$this->session->getFlashBag()->add('notice', 'ERROR ' . $violation->getMessage());
$error = true;
}
if ($error) {
return false;
}
}
return true;
}
}