<?php
namespace Bidcoz\Bundle\FrontendBundle\Controller;
use Bidcoz\Bundle\CoreBundle\Controller\CoreController;
use Bidcoz\Bundle\CoreBundle\Entity\Campaign;
use Bidcoz\Bundle\CoreBundle\Entity\Organization;
use FOS\UserBundle\Event\FilterUserResponseEvent;
use FOS\UserBundle\Event\FormEvent;
use FOS\UserBundle\Event\GetResponseUserEvent;
use FOS\UserBundle\FOSUserEvents;
use FOS\UserBundle\Util\TokenGeneratorInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Security;
/**
* CAU-350 Part 2: the in-campaign password-reset variant.
*
* Mirrors LoginController's org/campaign scoping (`/{orgSlug}/...` and
* `/{orgSlug}/{campaignSlug}/...`) so a donor who starts a reset from inside a
* campaign is never ejected to the generic `/resetting` flow — the campaign context
* is carried through the request page, the emailed link, and the "return to sign in"
* link. The reset LOGIC is identical to the standalone
* {@see \Bidcoz\Bundle\UserBundle\Controller\ResettingController}; only the surrounding
* links and the emailed URL differ, so the two stay behaviourally in step.
*
* "resetting" is a reserved slug (see Constants), so these literal paths are never
* mistaken for a campaign slug by the campaign catch-all routes.
*
* @Route("/{orgSlug}")
*/
class CampaignResettingController extends CoreController
{
/**
* @Route("/resetting/request", name="organization_resetting_request", methods={"GET"})
*/
public function organizationRequest(Request $request, Organization $organization)
{
return $this->doRequest($request, $organization);
}
/**
* @Route("/{campaignSlug}/resetting/request", name="campaign_resetting_request", methods={"GET"})
*/
public function campaignRequest(Request $request, Organization $organization, Campaign $campaign)
{
return $this->doRequest($request, $organization, $campaign);
}
/**
* @Route("/resetting/send-email", name="organization_resetting_send_email", methods={"POST"})
*/
public function organizationSendEmail(Request $request, Organization $organization, TokenGeneratorInterface $tokenGenerator)
{
return $this->doSendEmail($request, $tokenGenerator, $organization);
}
/**
* @Route("/{campaignSlug}/resetting/send-email", name="campaign_resetting_send_email", methods={"POST"})
*/
public function campaignSendEmail(Request $request, Organization $organization, Campaign $campaign, TokenGeneratorInterface $tokenGenerator)
{
return $this->doSendEmail($request, $tokenGenerator, $organization, $campaign);
}
/**
* @Route("/resetting/check-email", name="organization_resetting_check_email", methods={"GET"})
*/
public function organizationCheckEmail(Request $request, Organization $organization)
{
return $this->doCheckEmail($request, $organization);
}
/**
* @Route("/{campaignSlug}/resetting/check-email", name="campaign_resetting_check_email", methods={"GET"})
*/
public function campaignCheckEmail(Request $request, Organization $organization, Campaign $campaign)
{
return $this->doCheckEmail($request, $organization, $campaign);
}
/**
* @Route("/resetting/reset/{token}", name="organization_resetting_reset")
*/
public function organizationReset(Request $request, Organization $organization, string $token)
{
return $this->doReset($request, $token, $organization);
}
/**
* @Route("/{campaignSlug}/resetting/reset/{token}", name="campaign_resetting_reset")
*/
public function campaignReset(Request $request, Organization $organization, Campaign $campaign, string $token)
{
return $this->doReset($request, $token, $organization, $campaign);
}
protected function doRequest(Request $request, Organization $organization, ?Campaign $campaign = null): Response
{
$session = $request->getSession();
$lastUsername = (null === $session) ? '' : $session->get(Security::LAST_USERNAME);
return $this->render('@FOSUser/Resetting/request.html.twig', [
'last_username' => $lastUsername,
'organization' => $organization,
'campaign' => $campaign,
'send_email_action' => $this->scopedUrl('resetting_send_email', $organization, $campaign),
'sign_in_path' => $this->loginUrl($organization, $campaign),
]);
}
/**
* Mirrors FOSUser's sendEmailAction, but builds an org/campaign-scoped reset link
* and sends it through EmailManager (mechanism (a) — the standalone FOSMailer is
* left untouched). CAU-338 non-enumeration is preserved: the response is the same
* redirect to check-email whether or not an account exists.
*/
protected function doSendEmail(Request $request, TokenGeneratorInterface $tokenGenerator, Organization $organization, ?Campaign $campaign = null): Response
{
$username = $request->request->get('username');
$user = $this->getUserManager()->findUserByUsernameOrEmail($username);
$retryTtl = (int) $this->getParameter('fos_user.resetting.retry_ttl');
if (null !== $user && !$user->isPasswordRequestNonExpired($retryTtl)) {
if (null === $user->getConfirmationToken()) {
$user->setConfirmationToken($tokenGenerator->generateToken());
}
$resetUrl = $this->scopedUrl(
'resetting_reset',
$organization,
$campaign,
['token' => $user->getConfirmationToken()],
RouterInterface::ABSOLUTE_URL
);
$this->getEmailManager()->sendEmail($organization, $user, 'user.reset_password', [
'user' => $user,
'confirmationUrl' => $resetUrl,
]);
$user->setPasswordRequestedAt(new \DateTime());
$this->getUserManager()->updateUser($user);
}
return new RedirectResponse(
$this->scopedUrl('resetting_check_email', $organization, $campaign, ['username' => $username])
);
}
protected function doCheckEmail(Request $request, Organization $organization, ?Campaign $campaign = null): Response
{
$username = $request->query->get('username');
// NO user lookup here, deliberately — same enumeration guard as the standalone
// flow (CAU-338). An empty username means the visitor did not arrive from the
// send-email step, so bounce back to the (scoped) request page.
if (empty($username)) {
return new RedirectResponse($this->scopedUrl('resetting_request', $organization, $campaign));
}
return $this->render('@FOSUser/Resetting/check_email.html.twig', [
'tokenLifetime' => ceil($this->getParameter('fos_user.resetting.token_ttl') / 3600),
'organization' => $organization,
'campaign' => $campaign,
'sign_in_path' => $this->loginUrl($organization, $campaign),
]);
}
protected function doReset(Request $request, string $token, Organization $organization, ?Campaign $campaign = null): Response
{
$formFactory = $this->get('fos_user.resetting.form.factory');
$userManager = $this->getUserManager();
$dispatcher = $this->getEventDispatcher();
$user = $userManager->findUserByConfirmationToken($token);
if (null === $user) {
// Expired, already used, or never valid — deliberately indistinguishable
// (CAU-349). 410 Gone, token never echoed back.
return $this->render('@FOSUser/Resetting/invalid_token.html.twig', [
'organization' => $organization,
'campaign' => $campaign,
'request_path' => $this->scopedUrl('resetting_request', $organization, $campaign),
'sign_in_path' => $this->loginUrl($organization, $campaign),
], new Response('', Response::HTTP_GONE));
}
$event = new GetResponseUserEvent($user, $request);
$dispatcher->dispatch($event, FOSUserEvents::RESETTING_RESET_INITIALIZE);
if (null !== $event->getResponse()) {
return $event->getResponse();
}
$form = $formFactory->createForm();
$form->setData($user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$event = new FormEvent($form, $request);
$dispatcher->dispatch($event, FOSUserEvents::RESETTING_RESET_SUCCESS);
$userManager->updateUser($user);
if (null === $response = $event->getResponse()) {
// Land back inside the campaign/org the reset was started from.
$response = new RedirectResponse($this->homeUrl($organization, $campaign));
}
$dispatcher->dispatch(new FilterUserResponseEvent($user, $request, $response), FOSUserEvents::RESETTING_RESET_COMPLETED);
return $response;
}
return $this->render('@FOSUser/Resetting/reset.html.twig', [
'token' => $token,
'form' => $form->createView(),
'organization' => $organization,
'campaign' => $campaign,
'reset_action' => $this->scopedUrl('resetting_reset', $organization, $campaign, ['token' => $token]),
'sign_in_path' => $this->loginUrl($organization, $campaign),
]);
}
/**
* Generate a scoped resetting URL, picking the organization_* or campaign_*
* route by whether a campaign is in play.
*/
private function scopedUrl(string $suffix, Organization $organization, ?Campaign $campaign, array $extra = [], int $referenceType = RouterInterface::ABSOLUTE_PATH): string
{
$prefix = null === $campaign ? 'organization_' : 'campaign_';
return $this->getRouter()->generate($prefix.$suffix, $this->routeParams($organization, $campaign, $extra), $referenceType);
}
private function loginUrl(Organization $organization, ?Campaign $campaign): string
{
$route = null === $campaign ? 'organization_login_user' : 'campaign_login_user';
return $this->getRouter()->generate($route, $this->routeParams($organization, $campaign));
}
private function homeUrl(Organization $organization, ?Campaign $campaign): string
{
$route = null === $campaign ? 'organization_home' : 'campaign_home';
return $this->getRouter()->generate($route, $this->routeParams($organization, $campaign));
}
private function routeParams(Organization $organization, ?Campaign $campaign, array $extra = []): array
{
$params = array_merge(['orgSlug' => $organization->getSlug()], $extra);
if (null !== $campaign) {
$params['campaignSlug'] = $campaign->getSlug();
}
return $params;
}
}