From e2fbf7a5b5a344ffb395d4b2fa3ff02177596b9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Mike=C5=A1?= Date: Sat, 25 Jul 2026 00:47:35 +0200 Subject: [PATCH 1/9] Native auth 2c-II: registration, verification, reset, change password/email Builds the rest of the user-facing native auth flows on top of the domain layer from 2a (issue #147). Everything ships dark: registration is gated on `native_registration`, the login page (and its cutover modal) on `native_login`, both OFF. - Registration: RegisterUser handler creates UserAccount (msp|) + Player in one transaction. Rejects an email that already belongs to a *Player*, not just to a user_account - in window A that collision is the user's own Auth0 account, and a second account would make the Stage B import skip their identity and strand their profile. - Welcome screen + verification email both carry the sign-in-link rescue: a window-A registrant who logs out meets the Auth0 form, which has no identity for them. - Email verification UI: anonymous /verify-email consumes the stateless token, resend button on profile settings (rate limited per account). - Password reset UI + email: /password-reset asks, /password-reset/{token} consumes. Uniform response for unknown/throttled addresses; the token page sends Referrer-Policy: no-referrer instead of parking the token in a session (keeps anonymous pages cookie-free, #164). - Native change password + change email, both requiring the current password. The edit-profile card follows the account class in the session, not a flag: a native account gets the native cards from the day it exists, a legacy Auth0 session keeps the #161 button until the Stage B import. - Cutover explainer modal on the login page (D15), localStorage-dismissed so it needs no session. EN copy only in this commit; the other five locales follow. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017o4pbdfkhHDB4vdJPNY3tc --- .../controllers/cutover_modal_controller.js | 49 +++++++ config/packages/rate_limiter.php | 27 ++++ config/packages/twig.php | 5 + config/services.php | 7 +- .../ChangeAccountEmailController.php | 130 +++++++++++++++++ .../ChangeAccountPasswordController.php | 109 ++++++++++++++ src/Controller/EditProfileController.php | 12 +- src/Controller/PasswordResetController.php | 122 ++++++++++++++++ src/Controller/RegisterController.php | 138 ++++++++++++++++++ .../RegistrationWelcomeController.php | 59 ++++++++ .../RequestPasswordResetController.php | 121 +++++++++++++++ .../ResendEmailVerificationController.php | 86 +++++++++++ src/Controller/VerifyEmailController.php | 83 +++++++++++ src/Entity/Player.php | 10 ++ src/Entity/ResetPasswordRequest.php | 5 +- .../CurrentPasswordDoesNotMatch.php | 15 ++ src/Exceptions/EmailAlreadyRegistered.php | 15 ++ src/FormData/ChangeEmailFormData.php | 18 +++ src/FormData/ChangePasswordFormData.php | 17 +++ src/FormData/RegistrationFormData.php | 19 +++ src/FormData/ResetPasswordFormData.php | 13 ++ src/FormType/ChangeEmailFormType.php | 45 ++++++ src/FormType/ChangePasswordFormType.php | 50 +++++++ src/FormType/RegistrationFormType.php | 54 +++++++ src/FormType/ResetPasswordFormType.php | 44 ++++++ src/Message/ChangeAccountEmail.php | 18 +++ src/Message/ChangeAccountPassword.php | 19 +++ src/Message/RegisterUser.php | 18 +++ src/Message/SendEmailVerificationLink.php | 14 ++ src/Message/SendPasswordResetLink.php | 18 +++ .../ChangeAccountEmailHandler.php | 88 +++++++++++ .../ChangeAccountPasswordHandler.php | 65 +++++++++ src/MessageHandler/RegisterUserHandler.php | 95 ++++++++++++ .../SendEmailVerificationLinkHandler.php | 92 ++++++++++++ .../SendPasswordResetLinkHandler.php | 74 ++++++++++ src/Repository/PlayerRepository.php | 27 ++++ templates/_cutover_modal.html.twig | 52 +++++++ templates/base.html.twig | 13 ++ templates/change_account_email.html.twig | 31 ++++ templates/change_account_password.html.twig | 49 +++++++ templates/edit-profile.html.twig | 78 ++++++++-- templates/emails/password_reset.html.twig | 40 +++++ templates/emails/verify_email.html.twig | 57 ++++++++ templates/login.html.twig | 19 ++- templates/password_reset.html.twig | 41 ++++++ templates/password_reset_dead_token.html.twig | 31 ++++ templates/register.html.twig | 52 +++++++ templates/registration_welcome.html.twig | 47 ++++++ templates/request_password_reset.html.twig | 56 +++++++ templates/verify_email.html.twig | 31 ++++ translations/emails.en.yml | 20 +++ translations/messages.en.yml | 85 +++++++++++ 52 files changed, 2458 insertions(+), 25 deletions(-) create mode 100644 assets/controllers/cutover_modal_controller.js create mode 100644 src/Controller/ChangeAccountEmailController.php create mode 100644 src/Controller/ChangeAccountPasswordController.php create mode 100644 src/Controller/PasswordResetController.php create mode 100644 src/Controller/RegisterController.php create mode 100644 src/Controller/RegistrationWelcomeController.php create mode 100644 src/Controller/RequestPasswordResetController.php create mode 100644 src/Controller/ResendEmailVerificationController.php create mode 100644 src/Controller/VerifyEmailController.php create mode 100644 src/Exceptions/CurrentPasswordDoesNotMatch.php create mode 100644 src/Exceptions/EmailAlreadyRegistered.php create mode 100644 src/FormData/ChangeEmailFormData.php create mode 100644 src/FormData/ChangePasswordFormData.php create mode 100644 src/FormData/RegistrationFormData.php create mode 100644 src/FormData/ResetPasswordFormData.php create mode 100644 src/FormType/ChangeEmailFormType.php create mode 100644 src/FormType/ChangePasswordFormType.php create mode 100644 src/FormType/RegistrationFormType.php create mode 100644 src/FormType/ResetPasswordFormType.php create mode 100644 src/Message/ChangeAccountEmail.php create mode 100644 src/Message/ChangeAccountPassword.php create mode 100644 src/Message/RegisterUser.php create mode 100644 src/Message/SendEmailVerificationLink.php create mode 100644 src/Message/SendPasswordResetLink.php create mode 100644 src/MessageHandler/ChangeAccountEmailHandler.php create mode 100644 src/MessageHandler/ChangeAccountPasswordHandler.php create mode 100644 src/MessageHandler/RegisterUserHandler.php create mode 100644 src/MessageHandler/SendEmailVerificationLinkHandler.php create mode 100644 src/MessageHandler/SendPasswordResetLinkHandler.php create mode 100644 templates/_cutover_modal.html.twig create mode 100644 templates/change_account_email.html.twig create mode 100644 templates/change_account_password.html.twig create mode 100644 templates/emails/password_reset.html.twig create mode 100644 templates/emails/verify_email.html.twig create mode 100644 templates/password_reset.html.twig create mode 100644 templates/password_reset_dead_token.html.twig create mode 100644 templates/register.html.twig create mode 100644 templates/registration_welcome.html.twig create mode 100644 templates/request_password_reset.html.twig create mode 100644 templates/verify_email.html.twig diff --git a/assets/controllers/cutover_modal_controller.js b/assets/controllers/cutover_modal_controller.js new file mode 100644 index 00000000..d84ffaf6 --- /dev/null +++ b/assets/controllers/cutover_modal_controller.js @@ -0,0 +1,49 @@ +import { Controller } from '@hotwired/stimulus'; +import * as bootstrap from 'bootstrap'; + +/** + * The one-time "sign-in has moved home" explainer on the login page (D15, issue + * #147). Shown exactly where the confusion strikes, and only once per browser. + * + * localStorage, never the session: the login page is reached by anonymous + * visitors and must not gain a cookie (#164 anonymous-cacheability). The element + * starts hidden and is only revealed here, so a dismissed modal never flashes in + * for someone who has already read it. + */ +export default class extends Controller { + static STORAGE_KEY = 'sign-in-cutover-modal-dismissed'; + + connect() { + let dismissed = null; + + try { + dismissed = window.localStorage.getItem(this.constructor.STORAGE_KEY); + } catch (error) { + // Private mode or storage disabled: better to show it than to hide it + } + + if (dismissed !== null) { + this.element.remove(); + + return; + } + + this.modal = new bootstrap.Modal(this.element, {}); + this.modal.show(); + } + + dismiss() { + try { + window.localStorage.setItem(this.constructor.STORAGE_KEY, '1'); + } catch (error) { + // Nothing to remember it with: the modal simply comes back next time + } + } + + disconnect() { + if (this.modal) { + this.modal.dispose(); + this.modal = null; + } + } +} diff --git a/config/packages/rate_limiter.php b/config/packages/rate_limiter.php index db3a7b3c..da2932be 100644 --- a/config/packages/rate_limiter.php +++ b/config/packages/rate_limiter.php @@ -39,6 +39,33 @@ 'limit' => 20, 'interval' => '1 hour', ], + // Password reset is the same shape of hazard as the sign-in link: an + // unauthenticated endpoint that mails an address the caller picks. + 'password_reset_email' => [ + 'policy' => 'sliding_window', + 'limit' => 3, + 'interval' => '15 minutes', + ], + 'password_reset_ip' => [ + 'policy' => 'sliding_window', + 'limit' => 20, + 'interval' => '1 hour', + ], + // Registration creates rows and sends mail; generous enough for a + // household or a club behind one NAT, tight enough to be no fun to abuse + // (D8 - the unique-email form error is only acceptable when the endpoint + // cannot be enumerated in bulk). + 'registration_ip' => [ + 'policy' => 'sliding_window', + 'limit' => 10, + 'interval' => '1 hour', + ], + // "Resend the verification email" - authenticated, so the account is the key + 'email_verification_resend' => [ + 'policy' => 'sliding_window', + 'limit' => 3, + 'interval' => '15 minutes', + ], ], ], ]); diff --git a/config/packages/twig.php b/config/packages/twig.php index b7afb99e..9b64f03e 100644 --- a/config/packages/twig.php +++ b/config/packages/twig.php @@ -25,6 +25,11 @@ // (issue #147, docs/features/feature_flags.md). ON by default: it is // announcement copy, not a feature - the switch exists to retire it. 'sign_in_changes_notice_enabled' => '%env(bool:SIGN_IN_CHANGES_NOTICE_ENABLED)%', + // Stage A/B flags of the same migration. Templates need them to decide + // whether to offer the native "Create an account" CTA (Stage A) and the + // native change-password card (Stage B); both retire in Phase 6. + 'native_registration_enabled' => '%nativeRegistrationEnabled%', + 'native_login_enabled' => '%nativeLoginEnabled%', ], 'paths' => [ '%kernel.project_dir%/public/img' => 'images', diff --git a/config/services.php b/config/services.php index 0ddb78d5..2d456700 100644 --- a/config/services.php +++ b/config/services.php @@ -45,9 +45,9 @@ // Auth0 -> native auth migration flags (issue #147, docs/features/feature_flags.md). // Deploy != flip: all three ship OFF and are flipped via env at Stage A / Stage B. - // nativeRegistrationEnabled has no consumer yet (registration ships with the - // 2c-II slice) - it is a parameter only; bind it in defaults() once a service - // injects it. nativeLoginEnabled gates the native login page (LoginController). + // nativeRegistrationEnabled gates the native registration page (RegisterController) + // and the links that point at it; nativeLoginEnabled gates the native login page + // (LoginController) and the native change-password card on profile settings. $parameters->set('nativeRegistrationEnabled', '%env(bool:NATIVE_REGISTRATION_ENABLED)%'); $parameters->set('nativeLoginEnabled', '%env(bool:NATIVE_LOGIN_ENABLED)%'); $parameters->set('auth0TrickleLoginEnabled', '%env(bool:AUTH0_TRICKLE_LOGIN_ENABLED)%'); @@ -76,6 +76,7 @@ ->bind('$auth0DatabaseConnection', '%auth0DatabaseConnection%') ->bind('$auth0TrickleLoginEnabled', '%auth0TrickleLoginEnabled%') ->bind('$nativeLoginEnabled', '%nativeLoginEnabled%') + ->bind('$nativeRegistrationEnabled', '%nativeRegistrationEnabled%') ->bind('$signInLinkLifetimeSeconds', '%signInLinkLifetimeSeconds%'); $services->set(PdoSessionHandler::class) diff --git a/src/Controller/ChangeAccountEmailController.php b/src/Controller/ChangeAccountEmailController.php new file mode 100644 index 00000000..67f01cc1 --- /dev/null +++ b/src/Controller/ChangeAccountEmailController.php @@ -0,0 +1,130 @@ + '/upravit-profil/zmenit-email', + 'en' => '/en/edit-profile/change-email', + 'es' => '/es/editar-perfil/cambiar-correo', + 'ja' => '/ja/プロフィール編集/メールアドレス変更', + 'fr' => '/fr/modifier-profil/changer-email', + 'de' => '/de/profil-bearbeiten/e-mail-aendern', + ], + name: 'change_account_email', + methods: ['GET', 'POST'], + )] + public function __invoke(Request $request, #[CurrentUser] UserInterface $user): Response + { + if (!$user instanceof UserAccount) { + // Window A: a legacy Auth0 session's address lives in Auth0, not here + return $this->redirectToRoute('edit_profile'); + } + + $data = new ChangeEmailFormData(); + $form = $this->createForm(ChangeEmailFormType::class, $data); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + $this->messageBus->dispatch( + new ChangeAccountEmail( + userId: $user->getUserIdentifier(), + newEmail: $data->newEmail, + currentPassword: $data->currentPassword, + ), + ); + } catch (HandlerFailedException $exception) { + $reason = $exception->getPrevious(); + + if ($reason instanceof CurrentPasswordDoesNotMatch) { + $form->get('currentPassword')->addError( + new FormError($this->translator->trans('edit_profile.change_password_wrong_current')), + ); + + return $this->renderPage($form, $user); + } + + if ($reason instanceof EmailAlreadyRegistered) { + $form->get('newEmail')->addError( + new FormError($this->translator->trans('edit_profile.change_email_taken')), + ); + + return $this->renderPage($form, $user); + } + + $this->logger->error('Changing the account email failed', [ + 'exception' => $exception, + ]); + + $this->addFlash('danger', $this->translator->trans('edit_profile.change_email_failed')); + + return $this->renderPage($form, $user); + } + + // Sent after the change is committed, so the token binds the new address + $this->messageBus->dispatch( + new SendEmailVerificationLink( + userId: $user->getUserIdentifier(), + fallbackLocale: $request->getLocale(), + ), + ); + + $this->addFlash('success', $this->translator->trans('edit_profile.change_email_saved')); + + return $this->redirectToRoute('edit_profile'); + } + + return $this->renderPage($form, $user); + } + + /** + * @param FormInterface $form + */ + private function renderPage(FormInterface $form, UserAccount $userAccount): Response + { + return $this->render('change_account_email.html.twig', [ + 'form' => $form->createView(), + 'current_email' => $userAccount->email, + ]); + } +} diff --git a/src/Controller/ChangeAccountPasswordController.php b/src/Controller/ChangeAccountPasswordController.php new file mode 100644 index 00000000..6f790006 --- /dev/null +++ b/src/Controller/ChangeAccountPasswordController.php @@ -0,0 +1,109 @@ + '/upravit-profil/zmenit-heslo', + 'en' => '/en/edit-profile/change-password', + 'es' => '/es/editar-perfil/cambiar-contrasena', + 'ja' => '/ja/プロフィール編集/パスワード変更', + 'fr' => '/fr/modifier-profil/changer-mot-de-passe', + 'de' => '/de/profil-bearbeiten/passwort-aendern', + ], + name: 'change_account_password', + methods: ['GET', 'POST'], + )] + public function __invoke(Request $request, #[CurrentUser] UserInterface $user): Response + { + if (!$user instanceof UserAccount) { + // Window A: a legacy Auth0 session has no local password to change + return $this->redirectToRoute('edit_profile'); + } + + $data = new ChangePasswordFormData(); + $form = $this->createForm(ChangePasswordFormType::class, $data); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + $this->messageBus->dispatch( + new ChangeAccountPassword( + userId: $user->getUserIdentifier(), + currentPassword: $data->currentPassword, + newPassword: $data->newPassword, + ), + ); + } catch (HandlerFailedException $exception) { + if ($exception->getPrevious() instanceof CurrentPasswordDoesNotMatch) { + $form->get('currentPassword')->addError( + new FormError($this->translator->trans('edit_profile.change_password_wrong_current')), + ); + + return $this->renderPage($form); + } + + $this->logger->error('Changing the account password failed', [ + 'exception' => $exception, + ]); + + $this->addFlash('danger', $this->translator->trans('flashes.password_change_failed')); + + return $this->renderPage($form); + } + + $this->addFlash('success', $this->translator->trans('edit_profile.change_password_saved')); + + return $this->redirectToRoute('edit_profile'); + } + + return $this->renderPage($form); + } + + /** + * @param FormInterface $form + */ + private function renderPage(FormInterface $form): Response + { + return $this->render('change_account_password.html.twig', [ + 'form' => $form->createView(), + ]); + } +} diff --git a/src/Controller/EditProfileController.php b/src/Controller/EditProfileController.php index 0df31f61..875a283a 100644 --- a/src/Controller/EditProfileController.php +++ b/src/Controller/EditProfileController.php @@ -4,8 +4,8 @@ namespace SpeedPuzzling\Web\Controller; -use Auth0\Symfony\Models\User; use Psr\Log\LoggerInterface; +use SpeedPuzzling\Web\Entity\UserAccount; use SpeedPuzzling\Web\Exceptions\NonUniquePlayerCode; use SpeedPuzzling\Web\FormData\EditProfileFormData; use SpeedPuzzling\Web\FormData\FeaturesOptionsFormData; @@ -33,6 +33,7 @@ use Symfony\Component\Messenger\Exception\HandlerFailedException; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Http\Attribute\CurrentUser; use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Contracts\Translation\TranslatorInterface; @@ -62,7 +63,7 @@ public function __construct( ], name: 'edit_profile', )] - public function __invoke(Request $request, #[CurrentUser] User $user): Response + public function __invoke(Request $request, #[CurrentUser] UserInterface $user): Response { $player = $this->retrieveLoggedUserProfile->getProfile(); @@ -200,6 +201,13 @@ public function __invoke(Request $request, #[CurrentUser] User $user): Response 'oauth2_consents' => $oauth2Consents, 'personal_access_tokens' => $personalAccessTokens, 'my_applications' => $myApplications, + // Sign-in migration (issue #147): which password/email cards to render + // follows the account class in the session, not a feature flag - a native + // account gets the native forms from the day it exists. The Auth0 branch + // (and this whole trio of variables) goes at Stage B. + 'has_native_account' => $user instanceof UserAccount, + 'account_email' => $user instanceof UserAccount ? $user->email : null, + 'account_email_verified' => $user instanceof UserAccount && $user->emailVerifiedAt !== null, 'can_change_password' => Auth0DatabaseConnection::hasPassword($user->getUserIdentifier()), ]); } diff --git a/src/Controller/PasswordResetController.php b/src/Controller/PasswordResetController.php new file mode 100644 index 00000000..72853e34 --- /dev/null +++ b/src/Controller/PasswordResetController.php @@ -0,0 +1,122 @@ + '[0-9a-f]{64}'], + defaults: [NativeAuthPageSubscriber::ROUTE_DEFAULT => true], + methods: ['GET', 'POST'], + )] + public function __invoke(Request $request, string $token): Response + { + // Checked up front so a dead link says so immediately, instead of letting the + // user pick a password and only then telling them it was wasted + try { + $this->validatePasswordResetToken->validate($token); + } catch (PasswordResetTokenExpired) { + return $this->renderDeadToken('expired'); + } catch (InvalidPasswordResetToken) { + return $this->renderDeadToken('invalid'); + } + + $data = new ResetPasswordFormData(); + $form = $this->createForm(ResetPasswordFormType::class, $data); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + $this->messageBus->dispatch( + new ResetPassword( + token: $token, + plainPassword: $data->plainPassword, + ), + ); + } catch (HandlerFailedException $exception) { + $reason = $exception->getPrevious(); + + if ($reason instanceof PasswordResetTokenExpired) { + return $this->renderDeadToken('expired'); + } + + if ($reason instanceof InvalidPasswordResetToken) { + return $this->renderDeadToken('invalid'); + } + + $this->logger->error('Password reset failed', [ + 'exception' => $exception, + ]); + + $this->addFlash('danger', $this->translator->trans('auth.password_reset.failed')); + + return $this->noReferrer($this->render('password_reset.html.twig', [ + 'form' => $form->createView(), + ])); + } + + // Not logged in here on purpose: proving control of the mailbox resets the + // password, it does not authenticate the browser that opened the link + $this->addFlash('success', $this->translator->trans('auth.password_reset.done')); + + return $this->redirectToRoute('login'); + } + + return $this->noReferrer($this->render('password_reset.html.twig', [ + 'form' => $form->createView(), + ])); + } + + private function renderDeadToken(string $outcome): Response + { + return $this->noReferrer($this->render('password_reset_dead_token.html.twig', [ + 'outcome' => $outcome, + 'headline' => $this->translator->trans('auth.password_reset.' . $outcome . '.headline'), + 'message' => $this->translator->trans('auth.password_reset.' . $outcome . '.message'), + ])); + } + + private function noReferrer(Response $response): Response + { + $response->headers->set('Referrer-Policy', 'no-referrer'); + + return $response; + } +} diff --git a/src/Controller/RegisterController.php b/src/Controller/RegisterController.php new file mode 100644 index 00000000..7744235f --- /dev/null +++ b/src/Controller/RegisterController.php @@ -0,0 +1,138 @@ + true], + methods: ['GET', 'POST'], + )] + public function __invoke(Request $request): Response + { + if ($this->nativeRegistrationEnabled === false) { + return $this->redirectToRoute('login'); + } + + if ($this->getUser() !== null) { + return $this->redirectToRoute('my_profile'); + } + + $data = new RegistrationFormData(); + $form = $this->createForm(RegistrationFormType::class, $data); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $rateLimit = $this->registrationIpLimiter + ->create($request->getClientIp() ?? 'unknown') + ->consume(); + + if ($rateLimit->isAccepted() === false) { + $this->addFlash('warning', $this->translator->trans('auth.register.too_many_attempts')); + + return $this->redirectToRoute('register'); + } + + try { + $envelope = $this->messageBus->dispatch( + new RegisterUser( + email: $data->email, + plainPassword: $data->plainPassword, + locale: $request->getLocale(), + ), + ); + } catch (HandlerFailedException $exception) { + if ($exception->getPrevious() instanceof EmailAlreadyRegistered) { + // D8: the unique-email error is an accepted enumeration tradeoff. + // The copy points at signing in rather than only saying "taken" - + // through window A the collision is usually the user's own older + // Auth0 account, and a second account would strand it. + $form->get('email')->addError( + new FormError($this->translator->trans('auth.register.email_already_registered')), + ); + + return $this->render('register.html.twig', [ + 'form' => $form->createView(), + ]); + } + + $this->logger->error('Native registration failed', [ + 'exception' => $exception, + ]); + + $this->addFlash('danger', $this->translator->trans('auth.register.failed')); + + return $this->redirectToRoute('register'); + } + + /** @var HandledStamp $handledStamp */ + $handledStamp = $envelope->last(HandledStamp::class); + $userId = $handledStamp->getResult(); + assert(is_string($userId)); + + // Straight into the session: the account was just created with a password + // the visitor chose. Verification gates nothing (D7) - it is asked for by + // email, never enforced here. + $this->security->login( + $this->userAccountProvider->loadUserByIdentifier($userId), + firewallName: 'main', + ); + + $this->messageBus->dispatch( + new SendEmailVerificationLink( + userId: $userId, + fallbackLocale: $request->getLocale(), + ), + ); + + return $this->redirectToRoute('registration_welcome'); + } + + return $this->render('register.html.twig', [ + 'form' => $form->createView(), + ]); + } +} diff --git a/src/Controller/RegistrationWelcomeController.php b/src/Controller/RegistrationWelcomeController.php new file mode 100644 index 00000000..aae66d7b --- /dev/null +++ b/src/Controller/RegistrationWelcomeController.php @@ -0,0 +1,59 @@ + true], + methods: ['GET'], + )] + public function __invoke(#[CurrentUser] UserInterface $user): Response + { + // Only ever reached right after a native registration. A window-A Auth0 + // session typing the URL in has no account to describe - send it home + // rather than blow up on the type. + if (!$user instanceof UserAccount) { + return $this->redirectToRoute('my_profile'); + } + + $userAccount = $user; + + return $this->render('registration_welcome.html.twig', [ + 'email' => $userAccount->email, + 'email_verified' => $userAccount->emailVerifiedAt !== null, + // Once login is native the rescue is no longer special - the login page + // itself offers the sign-in link - so the warning retires with window A + 'show_sign_in_link_rescue' => $this->nativeLoginEnabled === false, + ]); + } +} diff --git a/src/Controller/RequestPasswordResetController.php b/src/Controller/RequestPasswordResetController.php new file mode 100644 index 00000000..55892fc7 --- /dev/null +++ b/src/Controller/RequestPasswordResetController.php @@ -0,0 +1,121 @@ + true], + methods: ['GET', 'POST'], + )] + public function __invoke(Request $request): Response + { + if ($request->isMethod('POST') === false) { + return $this->render('request_password_reset.html.twig', [ + 'email' => $request->query->getString('email'), + ]); + } + + if (!$this->isCsrfTokenValid(self::CSRF_TOKEN_ID, (string) $request->request->get('_token'))) { + throw $this->createAccessDeniedException(); + } + + $email = trim((string) $request->request->get('email')); + + if ($email === '') { + $this->addFlash('warning', $this->translator->trans('auth.password_reset.email_required')); + + return $this->redirectToRoute('request_password_reset'); + } + + if ($this->consumeRateLimit($email, $request->getClientIp()) === false) { + $this->addFlash('warning', $this->translator->trans('auth.password_reset.too_many_requests')); + + return $this->redirectToRoute('request_password_reset'); + } + + try { + $envelope = $this->messageBus->dispatch( + new RequestPasswordReset(email: $email), + ); + + /** @var HandledStamp $handledStamp */ + $handledStamp = $envelope->last(HandledStamp::class); + $token = $handledStamp->getResult(); + assert($token === null || $token instanceof PasswordResetToken); + + // null means unknown address or an already-live request - both silent + if ($token !== null) { + $this->messageBus->dispatch( + new SendPasswordResetLink( + email: $email, + token: $token->toString(), + fallbackLocale: $request->getLocale(), + ), + ); + } + } catch (HandlerFailedException $exception) { + $this->logger->error('Could not issue a password reset link', [ + 'exception' => $exception, + ]); + + $this->addFlash('danger', $this->translator->trans('auth.password_reset.failed')); + + return $this->redirectToRoute('request_password_reset'); + } + + $this->addFlash('success', $this->translator->trans('auth.password_reset.sent')); + + return $this->redirectToRoute('request_password_reset'); + } + + private function consumeRateLimit(string $email, null|string $clientIp): bool + { + $perEmail = $this->passwordResetEmailLimiter + ->create(UserAccount::canonicalizeEmail($email)) + ->consume(); + + $perIp = $this->passwordResetIpLimiter + ->create($clientIp ?? 'unknown') + ->consume(); + + return $perEmail->isAccepted() && $perIp->isAccepted(); + } +} diff --git a/src/Controller/ResendEmailVerificationController.php b/src/Controller/ResendEmailVerificationController.php new file mode 100644 index 00000000..fec181ae --- /dev/null +++ b/src/Controller/ResendEmailVerificationController.php @@ -0,0 +1,86 @@ +isCsrfTokenValid(self::CSRF_TOKEN_ID, (string) $request->request->get('_token'))) { + throw $this->createAccessDeniedException(); + } + + // Window A: a legacy Auth0 session has no user_account row to verify against. + // The button that posts here is only rendered for native accounts, so this is + // the belt to that template's braces. + if (!$user instanceof UserAccount) { + return $this->redirectToRoute('edit_profile'); + } + + $userAccount = $user; + + $rateLimit = $this->emailVerificationResendLimiter + ->create($userAccount->userId) + ->consume(); + + if ($rateLimit->isAccepted() === false) { + $this->addFlash('warning', $this->translator->trans('auth.verify_email.resend_too_many')); + + return $this->redirectToRoute('edit_profile'); + } + + try { + $this->messageBus->dispatch( + new SendEmailVerificationLink( + userId: $userAccount->userId, + fallbackLocale: $request->getLocale(), + ), + ); + } catch (HandlerFailedException $exception) { + $this->logger->error('Could not resend the email verification link', [ + 'exception' => $exception, + ]); + + $this->addFlash('danger', $this->translator->trans('auth.verify_email.resend_failed')); + + return $this->redirectToRoute('edit_profile'); + } + + $this->addFlash('success', $this->translator->trans('auth.verify_email.resend_sent')); + + return $this->redirectToRoute('edit_profile'); + } +} diff --git a/src/Controller/VerifyEmailController.php b/src/Controller/VerifyEmailController.php new file mode 100644 index 00000000..bf2061e8 --- /dev/null +++ b/src/Controller/VerifyEmailController.php @@ -0,0 +1,83 @@ + true], + methods: ['GET'], + )] + public function __invoke(Request $request): Response + { + $token = $request->query->getString('token'); + + if ($token === '') { + return $this->renderOutcome('invalid'); + } + + try { + $this->messageBus->dispatch(new VerifyEmail(token: $token)); + } catch (HandlerFailedException $exception) { + $reason = $exception->getPrevious(); + + if ($reason instanceof EmailVerificationTokenExpired) { + return $this->renderOutcome('expired'); + } + + if ($reason instanceof InvalidEmailVerificationToken) { + return $this->renderOutcome('invalid'); + } + + $this->logger->error('Email verification failed', [ + 'exception' => $exception, + ]); + + return $this->renderOutcome('failed'); + } + + return $this->renderOutcome('success'); + } + + private function renderOutcome(string $outcome): Response + { + return $this->render('verify_email.html.twig', [ + 'outcome' => $outcome, + 'headline' => $this->translator->trans('auth.verify_email.' . $outcome . '.headline'), + 'message' => $this->translator->trans('auth.verify_email.' . $outcome . '.message'), + ]); + } +} diff --git a/src/Entity/Player.php b/src/Entity/Player.php index 55169407..186efd84 100644 --- a/src/Entity/Player.php +++ b/src/Entity/Player.php @@ -213,6 +213,16 @@ public function changeProfile( $this->twitch = $twitch; } + /** + * Keeps the player's copy of the address in step with user_account.email when + * the account owner changes it (issue #147). Notification mail is addressed + * from here, so the two drifting apart would send it to the old inbox. + */ + public function changeEmail(string $email): void + { + $this->email = $email; + } + public function backfillFromAuth0Import(null|string $email, null|string $name): void { if ($this->email === null && $email !== null) { diff --git a/src/Entity/ResetPasswordRequest.php b/src/Entity/ResetPasswordRequest.php index dcd8c328..5faea2b3 100644 --- a/src/Entity/ResetPasswordRequest.php +++ b/src/Entity/ResetPasswordRequest.php @@ -23,7 +23,10 @@ #[Entity] class ResetPasswordRequest { - public const string LIFETIME = '+1 hour'; + /** Doubles as the throttle window: one live request at a time per account */ + public const int LIFETIME_MINUTES = 60; + + public const string LIFETIME = '+' . self::LIFETIME_MINUTES . ' minutes'; public function __construct( #[Id] diff --git a/src/Exceptions/CurrentPasswordDoesNotMatch.php b/src/Exceptions/CurrentPasswordDoesNotMatch.php new file mode 100644 index 00000000..1846ac99 --- /dev/null +++ b/src/Exceptions/CurrentPasswordDoesNotMatch.php @@ -0,0 +1,15 @@ + + */ +final class ChangeEmailFormType extends AbstractType +{ + /** + * @param mixed[] $options + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('newEmail', EmailType::class, [ + 'label' => 'edit_profile.change_email_new', + 'attr' => [ + 'autocomplete' => 'email', + ], + ]) + ->add('currentPassword', PasswordType::class, [ + 'label' => 'edit_profile.change_email_current_password', + 'attr' => [ + 'autocomplete' => 'current-password', + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => ChangeEmailFormData::class, + ]); + } +} diff --git a/src/FormType/ChangePasswordFormType.php b/src/FormType/ChangePasswordFormType.php new file mode 100644 index 00000000..299e4999 --- /dev/null +++ b/src/FormType/ChangePasswordFormType.php @@ -0,0 +1,50 @@ + + */ +final class ChangePasswordFormType extends AbstractType +{ + /** + * @param mixed[] $options + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('currentPassword', PasswordType::class, [ + 'label' => 'edit_profile.change_password_current', + 'attr' => [ + 'autocomplete' => 'current-password', + ], + ]) + ->add('newPassword', PasswordType::class, [ + 'label' => 'edit_profile.change_password_new', + 'help' => 'auth.set_password.password_hint', + 'help_translation_parameters' => [ + '%minimum%' => StrongPassword::MINIMUM_LENGTH, + ], + 'attr' => [ + 'autocomplete' => 'new-password', + 'data-password-suggestion-target' => 'field', + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => ChangePasswordFormData::class, + ]); + } +} diff --git a/src/FormType/RegistrationFormType.php b/src/FormType/RegistrationFormType.php new file mode 100644 index 00000000..6d839c67 --- /dev/null +++ b/src/FormType/RegistrationFormType.php @@ -0,0 +1,54 @@ + + */ +final class RegistrationFormType extends AbstractType +{ + /** + * @param mixed[] $options + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('email', EmailType::class, [ + 'label' => 'auth.register.email', + 'attr' => [ + 'autocomplete' => 'username', + 'autofocus' => true, + ], + ]) + ->add('plainPassword', PasswordType::class, [ + 'label' => 'auth.register.password', + 'help' => 'auth.register.password_hint', + 'help_translation_parameters' => [ + '%minimum%' => StrongPassword::MINIMUM_LENGTH, + ], + 'attr' => [ + // Lets the manager offer its generator and, above all, offer to save + // the result under myspeedpuzzling.com right away + 'autocomplete' => 'new-password', + 'data-password-suggestion-target' => 'field', + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => RegistrationFormData::class, + ]); + } +} diff --git a/src/FormType/ResetPasswordFormType.php b/src/FormType/ResetPasswordFormType.php new file mode 100644 index 00000000..a80189d0 --- /dev/null +++ b/src/FormType/ResetPasswordFormType.php @@ -0,0 +1,44 @@ + + */ +final class ResetPasswordFormType extends AbstractType +{ + /** + * @param mixed[] $options + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('plainPassword', PasswordType::class, [ + 'label' => 'auth.password_reset.new_password', + 'help' => 'auth.password_reset.password_hint', + 'help_translation_parameters' => [ + '%minimum%' => StrongPassword::MINIMUM_LENGTH, + ], + 'attr' => [ + 'autocomplete' => 'new-password', + 'data-password-suggestion-target' => 'field', + 'autofocus' => true, + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => ResetPasswordFormData::class, + ]); + } +} diff --git a/src/Message/ChangeAccountEmail.php b/src/Message/ChangeAccountEmail.php new file mode 100644 index 00000000..81dd4131 --- /dev/null +++ b/src/Message/ChangeAccountEmail.php @@ -0,0 +1,18 @@ +userAccountRepository->findByUserId($message->userId); + + if ($userAccount === null) { + throw new UserAccountNotFound(); + } + + if ($userAccount->password === null) { + throw new CurrentPasswordDoesNotMatch(); + } + + if (!$this->passwordHasher->isPasswordValid($userAccount, $message->currentPassword)) { + throw new CurrentPasswordDoesNotMatch(); + } + + $newEmail = UserAccount::canonicalizeEmail($message->newEmail); + + if ($newEmail === $userAccount->email) { + return; + } + + if ($this->userAccountRepository->findByEmail($newEmail) !== null) { + throw new EmailAlreadyRegistered(); + } + + // Same reasoning as registration: an address that already reaches a player - + // including a legacy Auth0 one with no user_account row yet - must not be + // claimed by a second account, or the Stage B import would strand it + $playerOnNewEmail = $this->playerRepository->findByEmail($newEmail); + + if ($playerOnNewEmail !== null && $playerOnNewEmail->userId !== $userAccount->userId) { + throw new EmailAlreadyRegistered(); + } + + $userAccount->changeEmail($newEmail); + + // The player row carries its own copy - it is what notification emails are sent + // to - so the two must not drift apart + $player = $this->playerRepository->findByUserId($userAccount->userId); + + if ($player !== null) { + $player->changeEmail($newEmail); + } + } +} diff --git a/src/MessageHandler/ChangeAccountPasswordHandler.php b/src/MessageHandler/ChangeAccountPasswordHandler.php new file mode 100644 index 00000000..cca8d3f1 --- /dev/null +++ b/src/MessageHandler/ChangeAccountPasswordHandler.php @@ -0,0 +1,65 @@ +userAccountRepository->findByUserId($message->userId); + + if ($userAccount === null) { + throw new UserAccountNotFound(); + } + + // No local hash yet (a legacy account that has only ever been verified through + // the trickle branch): there is nothing to check the current password against, + // so this door stays shut - the sign-in link and the reset flow are the way in + if ($userAccount->password === null) { + throw new CurrentPasswordDoesNotMatch(); + } + + if (!$this->passwordHasher->isPasswordValid($userAccount, $message->currentPassword)) { + throw new CurrentPasswordDoesNotMatch(); + } + + $userAccount->changePassword( + $this->passwordHasher->hashPassword($userAccount, $message->newPassword), + ); + + // Whatever was outstanding for the old password dies with it: reset requests + // explicitly, sign-in links implicitly (their signature covers the password) + $this->resetPasswordRequestRepository->removeAllForUserAccount($userAccount); + } +} diff --git a/src/MessageHandler/RegisterUserHandler.php b/src/MessageHandler/RegisterUserHandler.php new file mode 100644 index 00000000..cd68a141 --- /dev/null +++ b/src/MessageHandler/RegisterUserHandler.php @@ -0,0 +1,95 @@ +email); + + if ($this->userAccountRepository->findByEmail($email) !== null) { + throw new EmailAlreadyRegistered(); + } + + // Player, not just user_account: through window A the user_account table holds + // native registrants only, so a returning user who forgot they had an account + // could otherwise register natively with their existing address. At Stage B the + // import would then skip their Auth0 identity (email taken by another user_id) + // and strand their real profile and every solving time on it. + if ($this->playerRepository->findByEmail($email) !== null) { + throw new EmailAlreadyRegistered(); + } + + $now = $this->clock->now(); + // Provider-agnostic by design (README §Auth-method extensibility): the identity + // string never encodes how the account signs in, so linking a social identity + // later never touches the Player.userId seam. + $userId = 'msp|' . Uuid::uuid7()->toString(); + + $userAccount = new UserAccount( + Uuid::uuid7(), + $userId, + $email, + $now, + ); + $userAccount->changePassword( + $this->passwordHasher->hashPassword($userAccount, $message->plainPassword), + ); + + $this->userAccountRepository->save($userAccount); + + $player = new Player( + Uuid::uuid7(), + $this->generateUniquePlayerCode->generate(), + $userId, + $email, + null, + $now, + ); + + if ($message->locale !== null) { + $player->changeLocale($message->locale); + } + + $this->playerRepository->save($player); + + return $userId; + } +} diff --git a/src/MessageHandler/SendEmailVerificationLinkHandler.php b/src/MessageHandler/SendEmailVerificationLinkHandler.php new file mode 100644 index 00000000..d249d13d --- /dev/null +++ b/src/MessageHandler/SendEmailVerificationLinkHandler.php @@ -0,0 +1,92 @@ +userAccountRepository->findByUserId($message->userId); + + if ($userAccount === null) { + $this->logger->info('Email verification link requested for an unknown account'); + + return; + } + + if ($userAccount->emailVerifiedAt !== null) { + return; + } + + $expiresAt = $this->clock->now()->modify(EmailVerificationTokenSigner::LIFETIME); + $token = $this->tokenSigner->generate($userAccount, $expiresAt); + + $verificationUrl = $this->urlGenerator->generate( + 'verify_email', + ['token' => $token], + UrlGeneratorInterface::ABSOLUTE_URL, + ); + + $player = $this->playerRepository->findByUserId($userAccount->userId); + $locale = $player !== null && $player->locale !== null + ? $player->locale + : $message->fallbackLocale; + + $email = (new TemplatedEmail()) + ->to($userAccount->email) + ->locale($locale) + ->subject($this->translator->trans('verify_email.subject', domain: 'emails', locale: $locale)) + ->htmlTemplate('emails/verify_email.html.twig') + ->context([ + 'verificationUrl' => $verificationUrl, + 'signInLinkUrl' => $this->urlGenerator->generate('sign_in_link_request', [], UrlGeneratorInterface::ABSOLUTE_URL), + // Window A only: a native registrant who logs out meets the Auth0 form, + // which has no identity for them and which we cannot brand or link from. + // The sign-in link is their way back in (implementation-plan §2c). + 'showSignInLinkRescue' => $userAccount->legacyAuth0 === false, + 'expiresInHours' => 24, + ]); + $email->getHeaders()->addTextHeader('X-Transport', 'transactional'); + + $this->mailer->send($email); + + $this->logger->info('Email verification link issued', [ + 'user_id' => $userAccount->userId, + ]); + } +} diff --git a/src/MessageHandler/SendPasswordResetLinkHandler.php b/src/MessageHandler/SendPasswordResetLinkHandler.php new file mode 100644 index 00000000..a3abbfb5 --- /dev/null +++ b/src/MessageHandler/SendPasswordResetLinkHandler.php @@ -0,0 +1,74 @@ +userAccountRepository->findByEmail($message->email); + + if ($userAccount === null) { + return; + } + + $resetUrl = $this->urlGenerator->generate( + 'password_reset', + ['token' => $message->token], + UrlGeneratorInterface::ABSOLUTE_URL, + ); + + $player = $this->playerRepository->findByUserId($userAccount->userId); + $locale = $player !== null && $player->locale !== null + ? $player->locale + : $message->fallbackLocale; + + $email = (new TemplatedEmail()) + ->to($userAccount->email) + ->locale($locale) + ->subject($this->translator->trans('password_reset.subject', domain: 'emails', locale: $locale)) + ->htmlTemplate('emails/password_reset.html.twig') + ->context([ + 'resetUrl' => $resetUrl, + 'expiresInMinutes' => ResetPasswordRequest::LIFETIME_MINUTES, + ]); + $email->getHeaders()->addTextHeader('X-Transport', 'transactional'); + + $this->mailer->send($email); + + $this->logger->info('Password reset link issued', [ + 'user_id' => $userAccount->userId, + 'legacy_auth0' => $userAccount->legacyAuth0, + ]); + } +} diff --git a/src/Repository/PlayerRepository.php b/src/Repository/PlayerRepository.php index 15b766d8..aa3a0809 100644 --- a/src/Repository/PlayerRepository.php +++ b/src/Repository/PlayerRepository.php @@ -73,6 +73,11 @@ public function getByUserIdCreateIfNotExists(string $userId): Player } } + public function save(Player $player): void + { + $this->entityManager->persist($player); + } + public function findByUserId(string $userId): null|Player { return $this->entityManager->getRepository(Player::class) @@ -81,6 +86,28 @@ public function findByUserId(string $userId): null|Player ]); } + /** + * player.email is NOT unique - production carries 7 known duplicate-email pairs + * (deleted-and-re-registered Auth0 accounts, README §Current state) - so this + * answers "is this address taken at all", never "which row is the right one". + */ + public function findByEmail(string $email): null|Player + { + $queryBuilder = $this->entityManager->createQueryBuilder(); + + $player = $queryBuilder->select('player') + ->from(Player::class, 'player') + ->where('LOWER(player.email) = :email') + ->setParameter('email', mb_strtolower(trim($email))) + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + + assert($player === null || $player instanceof Player); + + return $player; + } + /** * @throws PlayerNotFound */ diff --git a/templates/_cutover_modal.html.twig b/templates/_cutover_modal.html.twig new file mode 100644 index 00000000..dab2575a --- /dev/null +++ b/templates/_cutover_modal.html.twig @@ -0,0 +1,52 @@ +{# Cutover explainer (D15, UX funnel §2, issue #147): the one-time modal that + greets people on the login page once sign-in is native. Dismissal is + localStorage, so it works for anonymous visitors and adds no session - the + login page must stay session-free for the #164 constraint. + + Retires ~4 weeks after Stage B together with the site-wide notice. #} + diff --git a/templates/base.html.twig b/templates/base.html.twig index 8e076f9f..eb00450b 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -366,6 +366,19 @@ {% if logged_user.profile is null %} + {# Stage A of the sign-in migration (issue #147): registration goes + native before login does, so while /login still redirects to + Auth0 this is the only signpost to the native form. Retires with + the flag in Phase 6. #} + {% if native_registration_enabled %} + + + + + {% endif %} + -
-

{{ 'edit_profile.change_password'|trans }}

+ {# Sign-in migration (issue #147). Which card shows follows the account the + session actually holds, not a flag: a native account gets the native + forms from the day it exists (window-A registrants included), a legacy + Auth0 session keeps the #161 reset-email button until the Stage B import + turns it into a user_account. The Auth0 branch and its three classes are + removed at Stage B - see docs/features/auth-migration. #} + {% if has_native_account %} +
+

{{ 'edit_profile.change_email'|trans }}

+

{{ 'edit_profile.change_email_description'|trans({'%email%': account_email}) }}

- {% if can_change_password %} -

{{ 'edit_profile.change_password_description'|trans }}

+ {% if account_email_verified %} +

+ {{ 'edit_profile.email_verified'|trans }} +

+ {% else %} +

+ {{ 'edit_profile.email_not_verified'|trans }} +

-
- + + -

- -

-
- {% else %} -

{{ 'edit_profile.change_password_social_login'|trans }}

- {% endif %} -
+ + {% endif %} + +

+ + {{ 'edit_profile.change_email_button'|trans }} + +

+
+ +
+

{{ 'edit_profile.change_password'|trans }}

+

{{ 'edit_profile.change_password_native_description'|trans }}

+ +

+ + {{ 'edit_profile.change_password_native_button'|trans }} + +

+
+ {% else %} +
+

{{ 'edit_profile.change_password'|trans }}

+ + {% if can_change_password %} +

{{ 'edit_profile.change_password_description'|trans }}

+ +
+ + +

+ +

+
+ {% else %} +

{{ 'edit_profile.change_password_social_login'|trans }}

+ {% endif %} +
+ {% endif %}

{{ 'edit_profile.personal_access_tokens'|trans }}

diff --git a/templates/emails/password_reset.html.twig b/templates/emails/password_reset.html.twig new file mode 100644 index 00000000..d78e47fd --- /dev/null +++ b/templates/emails/password_reset.html.twig @@ -0,0 +1,40 @@ +{% trans_default_domain 'emails' %} +{% apply inky_to_html|inline_css(source('@styles/foundation-emails.css')) %} + + + {{ include('emails/_header.html.twig') }} + + + + +

{{ 'password_reset.title'|trans }}

+ +
+
+ + + + {{ 'password_reset.content'|trans|raw }} + + + + + +
+ {{ 'password_reset.button'|trans }} +
+ +
+
+ + + +

{{ 'password_reset.expires'|trans({'%minutes%': expiresInMinutes}) }}

+

{{ 'password_reset.not_requested'|trans }}

+
+
+ + {{ include('emails/_footer.html.twig') }} +
+
+{% endapply %} diff --git a/templates/emails/verify_email.html.twig b/templates/emails/verify_email.html.twig new file mode 100644 index 00000000..08c5cb44 --- /dev/null +++ b/templates/emails/verify_email.html.twig @@ -0,0 +1,57 @@ +{% trans_default_domain 'emails' %} +{% apply inky_to_html|inline_css(source('@styles/foundation-emails.css')) %} + + + {{ include('emails/_header.html.twig') }} + + + + +

{{ 'verify_email.title'|trans }}

+ +
+
+ + + + {{ 'verify_email.content'|trans|raw }} + + + + + +
+ {{ 'verify_email.button'|trans }} +
+ +
+
+ + + +

{{ 'verify_email.expires'|trans({'%hours%': expiresInHours}) }}

+
+
+ + {% if showSignInLinkRescue %} + {# Window A of the sign-in migration (issue #147): a native registrant who + gets signed out meets the Auth0 form, which has no identity for them. + This email is one of the two things they keep, so it carries the rescue. #} + + + +

{{ 'verify_email.signed_out_rescue'|trans({'%url%': signInLinkUrl})|raw }}

+
+
+ {% endif %} + + + +

{{ 'verify_email.not_requested'|trans }}

+
+
+ + {{ include('emails/_footer.html.twig') }} +
+
+{% endapply %} diff --git a/templates/login.html.twig b/templates/login.html.twig index ce408ae4..cd039bab 100644 --- a/templates/login.html.twig +++ b/templates/login.html.twig @@ -4,6 +4,11 @@ {% block robots %}{% endblock %} {% block content %} + {# D15: shown once per browser, on the page where the change is actually met. + Only after login went native - before that /login is the Auth0 redirect and + this template does not render at all. #} + {{ include('_cutover_modal.html.twig') }} +
{# The sign-in link buttons live in two places (the failure helper above the card, the secondary action inside it) and both submit the separate @@ -71,11 +76,17 @@ {{ 'auth.sign_in_link.submit'|trans }} -

{{ 'auth.sign_in_link.explainer'|trans }}

+

{{ 'auth.sign_in_link.explainer'|trans }}

- {# TODO (2c-II slice): "Forgot password?" and "Create an account" links - belong here - their routes (password_reset_request, register) ship - with the registration/reset flows. #} + {# Tertiary, in the UX funnel's order (§3): password login first, the + sign-in link prominently second, these last. #} +

+ {{ 'auth.password_reset.link'|trans }} + {% if native_registration_enabled %} + · + {{ 'auth.register.title'|trans }} + {% endif %} +

diff --git a/templates/password_reset.html.twig b/templates/password_reset.html.twig new file mode 100644 index 00000000..8435ea80 --- /dev/null +++ b/templates/password_reset.html.twig @@ -0,0 +1,41 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'auth.password_reset.title'|trans }}{% endblock %} +{% block robots %}{% endblock %} + +{% block content %} +
+
+

{{ 'auth.password_reset.title'|trans }}

+ +

{{ 'auth.password_reset.intro'|trans }}

+ +
+
+ {{ form_start(form) }} +
+ {{ form_label(form.plainPassword) }} + {{ form_widget(form.plainPassword) }} + {{ form_help(form.plainPassword) }} + {{ form_errors(form.plainPassword) }} +
+ +

+ +

+ + + {{ form_end(form) }} +
+
+
+
+{% endblock %} diff --git a/templates/password_reset_dead_token.html.twig b/templates/password_reset_dead_token.html.twig new file mode 100644 index 00000000..b2f3006b --- /dev/null +++ b/templates/password_reset_dead_token.html.twig @@ -0,0 +1,31 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ headline }}{% endblock %} +{% block robots %}{% endblock %} + +{% block content %} +
+ + +
+ +
+
+{% endblock %} diff --git a/templates/register.html.twig b/templates/register.html.twig new file mode 100644 index 00000000..11aa3f59 --- /dev/null +++ b/templates/register.html.twig @@ -0,0 +1,52 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'auth.register.title'|trans }}{% endblock %} +{% block robots %}{% endblock %} + +{% block content %} +
+
+

{{ 'auth.register.title'|trans }}

+ +

{{ 'auth.register.intro'|trans }}

+ +
+
+ {{ form_start(form, {'action': path('register')}) }} + {{ form_row(form.email) }} + +
+ {{ form_label(form.plainPassword) }} + {{ form_widget(form.plainPassword) }} + {{ form_help(form.plainPassword) }} + {{ form_errors(form.plainPassword) }} +
+ +

+ +

+ + + {{ form_end(form) }} +
+
+ +

+ {{ 'auth.register.have_account'|trans }} + {{ 'auth.login.submit'|trans }} +

+
+ +
+ +
+
+{% endblock %} diff --git a/templates/registration_welcome.html.twig b/templates/registration_welcome.html.twig new file mode 100644 index 00000000..2c6e0a35 --- /dev/null +++ b/templates/registration_welcome.html.twig @@ -0,0 +1,47 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'auth.welcome.title'|trans }}{% endblock %} +{% block robots %}{% endblock %} + +{% block content %} +
+
+

{{ 'auth.welcome.title'|trans }}

+ +

{{ 'auth.welcome.intro'|trans }}

+ + {% if not email_verified %} + + {% endif %} + + {% if show_sign_in_link_rescue %} + {# Window A only: /login still redirects to Auth0, which has no identity + for this brand-new native account. The sign-in link is the way back + in until Stage B - so it is spelled out here and in the verification + email, the two things a new registrant actually keeps. #} +
+
+

{{ 'auth.welcome.signed_out_title'|trans }}

+

{{ 'auth.welcome.signed_out_text'|trans }}

+ + {{ 'auth.sign_in_link.submit'|trans }} + +
+
+ {% endif %} + +

+ + {{ 'auth.welcome.to_profile'|trans }} + +

+
+ +
+ +
+
+{% endblock %} diff --git a/templates/request_password_reset.html.twig b/templates/request_password_reset.html.twig new file mode 100644 index 00000000..7255e56c --- /dev/null +++ b/templates/request_password_reset.html.twig @@ -0,0 +1,56 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'auth.password_reset.request_title'|trans }}{% endblock %} +{% block robots %}{% endblock %} + +{% block content %} +
+
+

{{ 'auth.password_reset.request_title'|trans }}

+ +

{{ 'auth.password_reset.request_intro'|trans }}

+ +
+
+
+ + +
+ + +
+ + +
+ +
{{ 'auth.login.or'|trans }}
+ + {# The faster door for the very common case behind a forgotten password: + the manager filed it under the old Auth0 domain (UX funnel §3) #} + + {{ 'auth.sign_in_link.submit'|trans }} + +
+
+ +

+ {{ 'auth.sign_in_link.back_to_login'|trans }} +

+
+ +
+ +
+
+{% endblock %} diff --git a/templates/verify_email.html.twig b/templates/verify_email.html.twig new file mode 100644 index 00000000..fa531efe --- /dev/null +++ b/templates/verify_email.html.twig @@ -0,0 +1,31 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ headline }}{% endblock %} +{% block robots %}{% endblock %} + +{% block content %} +
+
+

{{ headline }}

+ + + +

+ {% if outcome == 'success' %} + {{ 'auth.verify_email.to_profile'|trans }} + {% else %} + {# A fresh link needs an authenticated session, so signing in is the + route back to the resend button on the profile settings page #} + {{ 'auth.login.submit'|trans }} + {% endif %} +

+
+ +
+ +
+
+{% endblock %} diff --git a/translations/emails.en.yml b/translations/emails.en.yml index cbcbb844..b0966015 100644 --- a/translations/emails.en.yml +++ b/translations/emails.en.yml @@ -157,3 +157,23 @@ sign_in_link: button: "Sign in" expires: "The link works once and expires in %minutes% minutes." not_requested: "Did not request this? You can safely ignore this email — nobody can sign in without the link." + +verify_email: + subject: "Confirm your email address for MySpeedPuzzling.com" + title: "Confirm your email address" + content: | +

Welcome to MySpeedPuzzling! Confirm this address so we can reach you about your account.

+ button: "Confirm my email address" + expires: "The link is valid for %hours% hours." + signed_out_rescue: | +

One thing worth keeping: signing in still runs through our old provider for a few more days, and it does not know your brand-new account yet. If you are signed out before the switch, get back in with a sign-in link by email: %url%

+ not_requested: "Did not create this account? You can safely ignore this email." + +password_reset: + subject: "Reset your MySpeedPuzzling.com password" + title: "Choose a new password" + content: | +

Somebody — hopefully you — asked to reset the password for your MySpeedPuzzling.com account. Click the button below to choose a new one.

+ button: "Choose a new password" + expires: "The link works once and expires in %minutes% minutes." + not_requested: "Did not request this? You can safely ignore this email — your password stays as it is." diff --git a/translations/messages.en.yml b/translations/messages.en.yml index b1c4c95e..cdd99617 100644 --- a/translations/messages.en.yml +++ b/translations/messages.en.yml @@ -7,6 +7,7 @@ menu: players: "Players" faq: "FAQ" sign_in: "Sign in" + register: "Register" hub: "Hub" feedback: "Send feedback" support_us: "Support us" @@ -904,6 +905,25 @@ edit_profile: change_password_description: "We'll send you an email with a secure link to set a new password. The link expires after a while, so use it soon." change_password_button: "Send password change email" change_password_social_login: "You sign in through an external account (such as Google or Facebook), so there is no MySpeedPuzzling password to change. You can change your password with your login provider." + change_password_native_description: "Set a new password with your current one. Your password manager will save it under myspeedpuzzling.com." + change_password_native_button: "Change password" + change_password_current: "Current password" + change_password_new: "New password" + change_password_wrong_current: "That is not your current password." + change_password_saved: "Your new password is saved." + change_password_forgot: "Do not know your current password?" + change_email: "Email address" + change_email_description: "You sign in with %email%. It is also where password resets and sign-in links are sent." + change_email_new: "New email address" + change_email_current_password: "Current password" + change_email_button: "Change email address" + change_email_saved: "Your email address is changed. Check the new inbox for a confirmation link." + change_email_taken: "This email address already belongs to another account." + change_email_failed: "We could not change your email address. Please try again in a moment." + email_verified: "Email address confirmed" + email_not_verified: "Email address not confirmed yet" + email_verify_resend: "Resend the confirmation email" + back: "Back to profile settings" puzzle_detail: meta: @@ -3162,3 +3182,68 @@ auth: submit: "Save password" skip: "Not now — my current password keeps working" saved: "Your new password is saved." + register: + title: "Create an account" + intro: "One account for your times, collections and everything else on MySpeedPuzzling." + email: "Email" + password: "Password" + password_hint: "At least %minimum% characters." + submit: "Create account" + have_account: "Already have an account?" + email_already_registered: "This email address already has an account. Sign in instead — or use \"Email me a sign-in link\" if the password will not come to hand." + too_many_attempts: "Too many registration attempts from here. Please try again later." + failed: "We could not create your account. Please try again in a moment." + welcome: + title: "Welcome to MySpeedPuzzling!" + intro: "Your account is ready and you are signed in." + verification_sent: "We sent a confirmation link to %email%. Open it to confirm the address is yours." + signed_out_title: "If you get signed out" + signed_out_text: "Signing in still runs through our old provider for a few more days, and it does not know your brand-new account yet. If you are signed out before the switch, get back in with a sign-in link by email." + to_profile: "Go to my profile" + verify_email: + success: + headline: "Email address confirmed" + message: "Thank you — your email address is confirmed." + expired: + headline: "This link has expired" + message: "Confirmation links are valid for 24 hours. Sign in and send yourself a new one from your profile settings." + invalid: + headline: "This link does not work" + message: "It may already have been used, or the address on the account has changed since it was sent. Sign in and send yourself a new one from your profile settings." + failed: + headline: "Something went wrong" + message: "We could not confirm your email address. Please try again in a moment." + to_profile: "Go to my profile" + resend_sent: "Confirmation email sent. Check your inbox — and your spam folder." + resend_too_many: "We just sent one. Please wait a few minutes before asking for another." + resend_failed: "We could not send the confirmation email. Please try again in a moment." + cutover_modal: + title: "Sign-in has moved home" + intro: "Signing in now happens right here on myspeedpuzzling.com — no more redirect to auth0.com." + same_credentials: "Same email, same password. Nothing was reset." + vault_tip: "Password manager not offering it? It saved your password under our old sign-in domain (%old_domain%). Search it for \"speedpuzzling\" or \"auth0\" — it is there." + sign_in_link: "Can't find it? Use \"Email me a sign-in link\" below — one click and you are in. You can set a fresh password afterwards." + why: "Why did this change?" + got_it: "Got it" + dismiss: "Close" + password_reset: + link: "Forgot password?" + request_title: "Reset your password" + request_intro: "Enter your email address and we will send you a link to choose a new password." + request_submit: "Email me a reset link" + sent: "If an account exists for that address, a reset link is on its way. Check your inbox — and your spam folder." + email_required: "Please enter your email address." + too_many_requests: "Too many reset requests. Please wait a few minutes and try again." + failed: "We could not send the reset link. Please try again in a moment." + title: "Choose a new password" + intro: "Pick a new password for your account. Your password manager will save it under myspeedpuzzling.com." + new_password: "New password" + password_hint: "At least %minimum% characters." + submit: "Save new password" + done: "Your new password is saved. Sign in with it." + expired: + headline: "This reset link has expired" + message: "Reset links are valid for one hour. Ask for a new one and use it right away." + invalid: + headline: "This reset link does not work" + message: "It may already have been used, or a newer reset link replaced it. Ask for a new one below." From 47d81ebd4b038ebf62abd1a70bb406a8da15997d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Mike=C5=A1?= Date: Sat, 25 Jul 2026 00:50:58 +0200 Subject: [PATCH 2/9] Document the 2c-II flows and flag surfaces (issue #147) Flips the 2c registration / verification / reset / change-password / change-email / cutover-modal checkboxes and records what each item actually does, including the two places the implementation diverged from the plan: - the edit-profile card swap needed no flag - it branches on whether the session holds a UserAccount, so window-A native registrants get native cards immediately and the Auth0 branch retires itself after the Stage B import; - the reset token stays in the URL with Referrer-Policy: no-referrer rather than in a session, which would put a cookie on every later page (#164). Removal of the three #161 classes and AUTH0_DB_CONNECTION stays open for Stage B, as specified. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017o4pbdfkhHDB4vdJPNY3tc --- .../auth-migration/implementation-plan.md | 20 +++++++++---------- docs/features/feature_flags.md | 10 ++++++++-- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/docs/features/auth-migration/implementation-plan.md b/docs/features/auth-migration/implementation-plan.md index e3081d5c..dc4cf6a1 100644 --- a/docs/features/auth-migration/implementation-plan.md +++ b/docs/features/auth-migration/implementation-plan.md @@ -67,25 +67,25 @@ Sequencing matters: signups are frozen at Stage A, so a single export generated - [x] **Window-A dual wiring** (Stage A → Stage B): `main` firewall runs `custom_authenticators: [LoginFormAuthenticator, 'auth0.authenticator']` with a **chain provider** (`user_account_provider` first, then `auth0_provider`) so both session user classes refresh. Entry point stays `Auth0EntryPoint` until Stage B. Verify: Auth0 authenticator failure returns null response (must not short-circuit the chain) and Auth0's `loadUserByIdentifier` doesn't greedily fabricate users — **explicit functional test before Stage A ships**. *(Done — `tests/Security/WindowADualWiringTest.php`: native bcrypt→argon2id login, chain refresh both user classes, trickle branches, anti-enumeration, CSRF, anonymous cacheability.)* - [ ] Stage B firewall state: entry point → `LoginFormAuthenticator`, add `login_link`, `remember_me` (signature-based, `secure: true`, `samesite: lax`, 30d), `login_throttling` (add `symfony/rate-limiter`), keep `logout` on `app_logout` (single-legged now). - [x] Feature flags `native_registration` (Stage A) + `native_login` (Stage B) — deploy ≠ flip, rollback = flag off. **Document both in `docs/features/feature_flags.md`** (project rule) incl. removal date (Phase 6). *(Done as env vars `NATIVE_REGISTRATION_ENABLED`/`NATIVE_LOGIN_ENABLED` (parameters, no consumers yet — 2c wires them) + a third operational flag `AUTH0_TRICKLE_LOGIN_ENABLED` gating the trickle branch independently; all documented, all OFF.)* -- [ ] Routes: `login` stays `/login` (native page at Stage B — `base.html.twig` link untouched), add `register`, `password-reset/*`, `verify-email`, `login-link/*`. Delete bundle `callback` route at Stage B, keep until then. *(Ships with the 2c controllers — nothing to add in 2b; the native login POST already rides the existing `/login` route through the authenticator. 2c-I added `/login-link`, `/login-link/check` and `/set-password`, and pointed `login` at `LoginController`; `register` + `password-reset/*` + `verify-email` remain for 2c-II. The auth funnel keeps single, locale-free paths and negotiates the language from `Accept-Language` — `NativeAuthPageSubscriber`, routes opt in with the `_auth_page` default.)* +- [x] Routes: `login` stays `/login` (native page at Stage B — `base.html.twig` link untouched), add `register`, `password-reset/*`, `verify-email`, `login-link/*`. Delete bundle `callback` route at Stage B, keep until then. *(Ships with the 2c controllers — nothing to add in 2b; the native login POST already rides the existing `/login` route through the authenticator. 2c-I added `/login-link`, `/login-link/check` and `/set-password`, and pointed `login` at `LoginController`. **2c-II completed the set:** `/register`, `/welcome`, `/verify-email`, `/resend-email-verification`, `/password-reset`, `/password-reset/{token}`, plus the two localized settings routes `change_account_password` / `change_account_email`. The auth funnel keeps single, locale-free paths and negotiates the language from `Accept-Language` — `NativeAuthPageSubscriber`, routes opt in with the `_auth_page` default; the two settings pages sit behind login and keep the localized-path convention of `edit_profile`. The bundle `callback` route stays until Stage B.)* - [ ] Post-login redirect: `TargetPathTrait` replaces `Auth0EntryPoint` + `Auth0RedirectSubscriber` at Stage B. **Must-test:** OAuth2 `/oauth2/authorize` deep-link → login → return round-trip (third-party API clients depend on it). *(2b done: `onAuthenticationSuccess` uses `TargetPathTrait` + falls back to the Auth0 authenticator's `auth0:callback_redirect` **session** key — while the Auth0 authenticator is wired, its protected-page failure redirect preempts the `ExceptionListener`, so TargetPathTrait's key is never written; the client-writable redirect cookie is deliberately NOT honored (open redirect). Deep-link return is functional-tested; the OAuth2 authorize Panther round-trip stays 2d.)* - [x] **Anonymous-cacheability regression check** (README §constraint): no session start on anonymous GETs, login GET session-free, `AnonymousCacheHeadersSubscriber` behavior unchanged — verify in test env per the #164 method. *(Verified for the window-A wiring: existing #164 tests green + explicit assertions in `WindowADualWiringTest`. Re-verify at the Stage B flip.)* ### 2c. User-facing flows (all new; **all 6 locales** en/cs/de/es/fr/ja per D17) -- [ ] Login page: email+password, prominent "Email me a sign-in link" secondary CTA, "Forgot password?", registration link, persistent migration microcopy (communication-plan). *(Built in the 2c-I slice — `LoginController` + `templates/login.html.twig`, behind `native_login`; `/login` keeps its path and hands over to the Auth0 bundle controller while the flag is OFF. **Open:** the "Forgot password?" and registration links, whose routes ship with 2c-II — the spot is marked in the template.)* +- [x] Login page: email+password, prominent "Email me a sign-in link" secondary CTA, "Forgot password?", registration link, persistent migration microcopy (communication-plan). *(Built in the 2c-I slice — `LoginController` + `templates/login.html.twig`, behind `native_login`; `/login` keeps its path and hands over to the Auth0 bundle controller while the flag is OFF. 2c-II filled in the "Forgot password?" and "Create an account" links now that their routes exist.)* - [x] **Login-failure helper** (UX funnel §4): failed attempt reveals inline box — vault-search tip ("search your password manager for speedpuzzling or auth0") + one-click sign-in-link button pre-filled with the typed email. Same rendering regardless of account existence. *(`templates/_login_failure_helper.html.twig`; the button re-posts the login form to the sign-in link endpoint via `form=`/`formaction`, so the typed address always travels with it.)* -- [ ] **Cutover explainer modal** (D15, UX funnel §2): one-time auto-modal on the login page when `native_login` is on; localStorage-dismissed (works for anonymous); content per communication-plan. Respect the existing modal/Turbo patterns (`.claude/symfony-ux-hotwire-architecture-guide.md`). +- [x] **Cutover explainer modal** (D15, UX funnel §2): one-time auto-modal on the login page when `native_login` is on; localStorage-dismissed (works for anonymous); content per communication-plan. Respect the existing modal/Turbo patterns (`.claude/symfony-ux-hotwire-architecture-guide.md`). *(`templates/_cutover_modal.html.twig` + `assets/controllers/cutover_modal_controller.js`. It renders only inside `login.html.twig`, which only renders when `native_login` is ON — no second flag check needed. The controller reads localStorage in `connect()` and removes the element instead of showing it, so a dismissed modal never flashes; dismissal is written on both the ✕ and the "Got it" button.)* - [x] Site-wide dismissable banner (T-7d "coming" wording → Stage B "changed" wording → removed ~B+4w). Reuse the hint-dismissing pattern for logged-in users where it fits; localStorage for anonymous. *(Shipped **early**, ahead of Stage A, on Jan's call: nobody should meet this change unwarned. `SIGN_IN_CHANGES_NOTICE_ENABLED` (ON by default) + `templates/_sign_in_changes_notice.html.twig` + the explainer page `SignInChangesController` at `/en/sign-in-is-moving` (six locale paths), all six locales. Dismissal is localStorage for **everyone**, not the hint-dismissing DB pattern: the notice rides on pages that are shared-cacheable for anonymous visitors (#164) and must not add a session. An inline `` script hides it before first paint, so a dismissed notice never flashes in or shifts the page; `tests/Panther/SignInChangesNoticeTest.php` measures the real boxes in Chrome. The Stage B "changed" wording is still to write.)* -- [ ] Registration: form → `RegisterUser` command → handler creates `UserAccount` (`msp|`) + `Player` atomically; password `Compound` constraint — use the `StrongPassword` constraint built in 2c-I; verification email; programmatic login via `Security::login()`. - - **Window-A collision (found 2026-07-24, must-fix in this item):** reject registration when the email already belongs to a **`Player`**, not only when it belongs to a `user_account`. In window A the `user_account` table holds native registrants only, so a returning user who forgot they had an account can register natively with their existing address — and at Stage B `ImportAuth0UserHandler` then *skips* their Auth0 identity (email already taken by another `user_id`, `src/MessageHandler/ImportAuth0UserHandler.php`), leaving their real profile and all their solving times unreachable while they sit on an empty new account. Point them at sign-in instead (same enumeration tradeoff as D8, already accepted for registration). - - **Stranded-registrant discoverability:** a window-A registrant who logs out meets the Auth0 form, which has no identity for them and which we cannot brand or link from (hosted, free tier). The magic sign-in link is the rescue (D6) but nothing points at it — so the registration success screen and the verification email must both carry "if you ever get signed out before {Stage B date}, use Email me a sign-in link" with the `/login-link` URL. Support playbook entry 4 already covers the ticket side. -- [ ] Email verification flow (UI + email) on top of the native domain layer already built (decision 2026-07-24: no auth bundles — `verify-email-bundle` dropped like the reset bundle; stateless HMAC token via `EmailVerificationTokenSigner` binds `user_id` + email + 24h expiry, `VerifyEmail` handler is idempotent, anonymous-validation works since the token is self-contained, and a link dies when the address changes — `UserAccount::changeEmail()` also resets `email_verified_at` for the change-email flow below). -- [ ] Password reset flow (UI + email) on top of the native domain layer built in 2a (`RequestPasswordReset` returns the token to send, or null for unknown/throttled — respond identically in both cases for anti-enumeration; `ResetPassword` consumes the token and invalidates all other open requests). No bundle (decision 2026-07-24). +- [x] Registration: form → `RegisterUser` command → handler creates `UserAccount` (`msp|`) + `Player` atomically; password `Compound` constraint — use the `StrongPassword` constraint built in 2c-I; verification email; programmatic login via `Security::login()`. + - **Window-A collision (found 2026-07-24, must-fix in this item):** reject registration when the email already belongs to a **`Player`**, not only when it belongs to a `user_account`. In window A the `user_account` table holds native registrants only, so a returning user who forgot they had an account can register natively with their existing address — and at Stage B `ImportAuth0UserHandler` then *skips* their Auth0 identity (email already taken by another `user_id`, `src/MessageHandler/ImportAuth0UserHandler.php`), leaving their real profile and all their solving times unreachable while they sit on an empty new account. Point them at sign-in instead (same enumeration tradeoff as D8, already accepted for registration). *(Done — both checks in `RegisterUserHandler`, the `Player` one via the new `PlayerRepository::findByEmail()` which is `LOWER()`-matched and `setMaxResults(1)` because `player.email` is not unique — the 7 known duplicate pairs would otherwise throw `NonUniqueResultException`. The form error points at signing in rather than only saying "taken".)* + - **Stranded-registrant discoverability:** a window-A registrant who logs out meets the Auth0 form, which has no identity for them and which we cannot brand or link from (hosted, free tier). The magic sign-in link is the rescue (D6) but nothing points at it — so the registration success screen and the verification email must both carry "if you ever get signed out before {Stage B date}, use Email me a sign-in link" with the `/login-link` URL. Support playbook entry 4 already covers the ticket side. *(Done — `RegistrationWelcomeController` at `/welcome` and the `showSignInLinkRescue` block in `emails/verify_email.html.twig`. Both suppress the warning once `native_login` is ON / for `legacy_auth0` accounts, so it retires by itself.)* +- [x] Email verification flow (UI + email) on top of the native domain layer already built (decision 2026-07-24: no auth bundles — `verify-email-bundle` dropped like the reset bundle; stateless HMAC token via `EmailVerificationTokenSigner` binds `user_id` + email + 24h expiry, `VerifyEmail` handler is idempotent, anonymous-validation works since the token is self-contained, and a link dies when the address changes — `UserAccount::changeEmail()` also resets `email_verified_at` for the change-email flow below). *(Done — `SendEmailVerificationLink` handler + `emails/verify_email.html.twig`, anonymous `VerifyEmailController` at `/verify-email` (four outcomes: success / expired / invalid / failed, no session granted), and a rate-limited resend button on profile settings. Sent on registration and after every email change.)* +- [x] Password reset flow (UI + email) on top of the native domain layer built in 2a (`RequestPasswordReset` returns the token to send, or null for unknown/throttled — respond identically in both cases for anti-enumeration; `ResetPassword` consumes the token and invalidates all other open requests). No bundle (decision 2026-07-24). *(Done — `RequestPasswordResetController` at `/password-reset` (throttled 3/15min per address, 20/h per IP; reads the token off the `HandledStamp` and dispatches `SendPasswordResetLink` only when non-null, so unknown and throttled addresses are indistinguishable) and `PasswordResetController` at `/password-reset/{token}`. The token stays in the URL rather than in a session — a session cookie here would follow the visitor across every later page and break #164 — with `Referrer-Policy: no-referrer` closing the leak that buys. A dead token is reported before the user picks a password, not after. Resetting does not log anyone in: proving control of the mailbox resets the password, it does not authenticate the browser.)* - [x] Magic login link: `login_link` config + rate-limited "email me a link" endpoint; **live from Stage A** (rescues window-A native registrants who log out). **Single-use (D18, locked 2026-07-24):** Symfony `login_link` alone is replayable within its lifetime — add consumption storage (hashed-token row consumed on successful login, consumed/unknown links rejected identically; reset-request pattern). *(Done: `login_link` on the `main` firewall (30 min, signature over email+password, `user_account_provider`), `SingleUseLoginLinkHandler` decorating the firewall's handler + `login_link_request` table (generated migration), `SignInLinkController` at `/login-link` throttled 3/15min per address and 20/h per IP, `RequestSignInLink` handler sending the localized email. Symfony's own `max_uses` was not used — `ExpiredSignatureStorage` is final and needs a PSR-6 pool, which cannot express "issued by us".)* - [x] **Post-magic-link password setup** (UX funnel §5): after link login on a `legacy_auth0` account, one-time skippable prompt — `autocomplete="new-password"` field + "Suggest strong password" button that fills the field (editable/copyable, NOT readonly). Skipping keeps the old password. *(`LoginLinkSuccessHandler` sets the one-time session flag → `SetPasswordAfterSignInLinkController` at `/set-password` → `SetAccountPassword` handler; `StrongPassword` compound constraint (NotBlank + min 12 + PasswordStrength + NotCompromisedPassword) is shared with 2c-II registration/reset.)* -- [ ] Change password (native): current password + new password on profile settings — **replaces the #161 Auth0 flow at Stage B** (swap the edit-profile "Password" card action; remove `RequestPasswordChangeController`, `RequestPasswordChangeHandler`, `Services/Auth0DatabaseConnection`, `AUTH0_DB_CONNECTION` at Stage B; reuse/adjust the existing 7 translation keys ×6 locales). -- [ ] Change email with re-verification: minimal viable version. +- [x] Change password (native): current password + new password on profile settings — **replaces the #161 Auth0 flow at Stage B** (swap the edit-profile "Password" card action; remove `RequestPasswordChangeController`, `RequestPasswordChangeHandler`, `Services/Auth0DatabaseConnection`, `AUTH0_DB_CONNECTION` at Stage B; reuse/adjust the existing 7 translation keys ×6 locales). *(Done — `ChangeAccountPassword` handler + `ChangeAccountPasswordController` at the localized `/…/edit-profile/change-password`. **The card swap turned out not to want a flag:** `templates/edit-profile.html.twig` branches on whether the session holds a `UserAccount`, so a window-A native registrant gets the native card the day they register while a legacy Auth0 session keeps the #161 button — and the Auth0 branch dies on its own once the Stage B import has converted everyone. The current password is required (a hijacked session must not lock the owner out); an imported bcrypt hash verifies through the migrating hasher and the new password lands as argon2id; an account with no local hash yet is refused and pointed at the reset/sign-in-link doors. **Removal of the three #161 classes + `AUTH0_DB_CONNECTION` stays open for Stage B**, as specified.)* +- [x] Change email with re-verification: minimal viable version. *(Done — `ChangeAccountEmail` handler + controller at the localized `/…/edit-profile/change-email`. Requires the current password, refuses an address already held by another `user_account` **or by another `Player`** (same window-A reasoning as registration), keeps `player.email` in step with `user_account.email` so notification mail does not go to the old inbox, resets `email_verified_at`, and sends a fresh verification link to the new address. A typo is recoverable: the old password still signs in, so the address can simply be fixed again.)* ### 2d. Code sweep diff --git a/docs/features/feature_flags.md b/docs/features/feature_flags.md index d8ece071..6fba7f0d 100644 --- a/docs/features/feature_flags.md +++ b/docs/features/feature_flags.md @@ -7,7 +7,11 @@ This file documents all active feature flags in the codebase — where they are, - **Feature:** Auth0 → native auth migration, Stage A (issue #147, `docs/features/auth-migration/`) - **Flag:** env var `NATIVE_REGISTRATION_ENABLED` → container parameter `nativeRegistrationEnabled` (`config/services.php`) - **Default:** OFF everywhere. Flipped ON in production on Stage A day (native `/register` goes live, Auth0 signups frozen). Rollback = flip OFF. -- **Gated files:** none yet — the registration flow ships in the 2c build slice and must consume this parameter +- **Gated files:** + - `src/Controller/RegisterController.php` — `/register` renders the native form when ON, redirects to `/login` (the Auth0 hosted page, whose signup tab is frozen at Stage A) when OFF + - `templates/base.html.twig` — the "Register" navbar tool for anonymous visitors. Load-bearing while `NATIVE_LOGIN_ENABLED` is still OFF: `/login` is the Auth0 redirect then, so this is the only signpost to the native form + - `templates/login.html.twig` — the "Create an account" link under the sign-in form (only rendered when login is also native) +- **Not gated on purpose:** the pages a native account needs once it exists — `/welcome`, `/verify-email`, `/resend-email-verification`, `/password-reset*`, the native change-password and change-email cards — follow the account class in the session, not this flag. A window-A registrant must be able to use them the moment they have an account. - **Remove when:** Phase 6 decommission (~Sep 2026), together with the trickle gateway and Auth0 tenant ## Native Login (`NATIVE_LOGIN_ENABLED`) @@ -17,7 +21,9 @@ This file documents all active feature flags in the codebase — where they are, - **Default:** OFF everywhere. Flipped ON in production at Stage B cutover (native login page + entry point replace the Auth0 redirect). Rollback = flip OFF; Auth0 login resumes against the intact tenant. - **Gated files:** - `src/Controller/LoginController.php` — `/login` renders the native form when ON, hands over to the Auth0 bundle controller when OFF -- **Not gated on purpose:** the magic sign-in link (`/login-link`, `/login-link/check`, `/set-password`) is live from Stage A per D6 — it is the rescue for window-A native registrants who log out while `/login` still points at Auth0 + - `templates/login.html.twig` — reached only when ON; carries the cutover explainer modal (D15) and the "Create an account" link + - `src/Controller/RegistrationWelcomeController.php` — while OFF, the welcome screen warns a fresh registrant that being signed out means falling back to the sign-in link; the warning retires when login goes native +- **Not gated on purpose:** the magic sign-in link (`/login-link`, `/login-link/check`, `/set-password`) is live from Stage A per D6 — it is the rescue for window-A native registrants who log out while `/login` still points at Auth0. The native change-password/change-email cards on profile settings are likewise not flag-gated: `templates/edit-profile.html.twig` branches on whether the session holds a `UserAccount`, so a native account gets the native cards from the day it exists and a legacy Auth0 session keeps the #161 reset-email button until the Stage B import - **Remove when:** Phase 6 decommission ## Sign-in Migration Notice (`SIGN_IN_CHANGES_NOTICE_ENABLED`) From fe273fc86d397b024940bb9c3e620aeee0d8da6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Mike=C5=A1?= Date: Sat, 25 Jul 2026 00:54:53 +0200 Subject: [PATCH 3/9] Translate the 2c-II auth flows into the other five locales (issue #147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 87 keys × cs/de/es/fr/ja across messages and emails: registration, the welcome screen, email verification, password reset, the native change-password and change-email cards, the cutover explainer modal, and the two new transactional emails. D17 requires all six locales for anything auth-facing - Auth0's Universal Login was auto-localized, so English-only auth pages would be a regression. check-translations reports 0 missing keys in every locale; placeholders, inline HTML and block scalars verified byte-identical to the English source. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017o4pbdfkhHDB4vdJPNY3tc --- translations/emails.cs.yml | 20 +++++++++ translations/emails.de.yml | 20 +++++++++ translations/emails.es.yml | 20 +++++++++ translations/emails.fr.yml | 20 +++++++++ translations/emails.ja.yml | 20 +++++++++ translations/messages.cs.yml | 85 ++++++++++++++++++++++++++++++++++++ translations/messages.de.yml | 85 ++++++++++++++++++++++++++++++++++++ translations/messages.es.yml | 85 ++++++++++++++++++++++++++++++++++++ translations/messages.fr.yml | 85 ++++++++++++++++++++++++++++++++++++ translations/messages.ja.yml | 85 ++++++++++++++++++++++++++++++++++++ 10 files changed, 525 insertions(+) diff --git a/translations/emails.cs.yml b/translations/emails.cs.yml index 05ac37de..302a6e74 100644 --- a/translations/emails.cs.yml +++ b/translations/emails.cs.yml @@ -157,3 +157,23 @@ sign_in_link: button: "Přihlásit se" expires: "Odkaz funguje jednou a vyprší za %minutes% minut." not_requested: "Nežádali jste o něj? Tento e-mail můžete klidně ignorovat — bez odkazu se nikdo nepřihlásí." + +verify_email: + subject: "Potvrďte svou e-mailovou adresu pro MySpeedPuzzling.com" + title: "Potvrďte svou e-mailovou adresu" + content: | +

Vítejte na MySpeedPuzzling! Potvrďte tuto adresu, ať vás můžeme kontaktovat ohledně vašeho účtu.

+ button: "Potvrdit mou e-mailovou adresu" + expires: "Odkaz platí %hours% hodin." + signed_out_rescue: | +

Ještě jedna věc, kterou je dobré vědět: přihlašování ještě několik dní běží přes našeho starého poskytovatele a ten o vašem zbrusu novém účtu zatím neví. Pokud budete před přepnutím odhlášeni, vraťte se zpátky pomocí přihlašovacího odkazu e-mailem: %url%

+ not_requested: "Tento účet jste nezakládali? Tento e-mail můžete klidně ignorovat." + +password_reset: + subject: "Obnovení hesla k MySpeedPuzzling.com" + title: "Zvolte si nové heslo" + content: | +

Někdo — doufejme, že vy — požádal o obnovení hesla k vašemu účtu na MySpeedPuzzling.com. Kliknutím na tlačítko níže si zvolíte nové.

+ button: "Zvolit nové heslo" + expires: "Odkaz funguje jednou a vyprší za %minutes% minut." + not_requested: "Nežádali jste o něj? Tento e-mail můžete klidně ignorovat — vaše heslo zůstane beze změny." diff --git a/translations/emails.de.yml b/translations/emails.de.yml index 5e40f62f..ae770619 100644 --- a/translations/emails.de.yml +++ b/translations/emails.de.yml @@ -157,3 +157,23 @@ sign_in_link: button: "Anmelden" expires: "Der Link funktioniert einmal und läuft nach %minutes% Minuten ab." not_requested: "Du hast das nicht angefordert? Dann kannst du diese E-Mail einfach ignorieren — ohne den Link kann sich niemand anmelden." + +verify_email: + subject: "Bestätige deine E-Mail-Adresse für MySpeedPuzzling.com" + title: "Bestätige deine E-Mail-Adresse" + content: | +

Willkommen bei MySpeedPuzzling! Bestätige diese Adresse, damit wir dich rund um dein Konto erreichen können.

+ button: "Meine E-Mail-Adresse bestätigen" + expires: "Der Link ist %hours% Stunden gültig." + signed_out_rescue: | +

Eine Sache solltest du dir merken: Die Anmeldung läuft noch ein paar Tage über unseren alten Anbieter, und der kennt dein brandneues Konto noch nicht. Falls du vor der Umstellung abgemeldet wirst, kommst du mit einem Anmeldelink per E-Mail wieder hinein: %url%

+ not_requested: "Du hast dieses Konto nicht erstellt? Dann kannst du diese E-Mail einfach ignorieren." + +password_reset: + subject: "Setze dein Passwort für MySpeedPuzzling.com zurück" + title: "Neues Passwort wählen" + content: | +

Jemand — hoffentlich du — hat angefordert, das Passwort für dein Konto bei MySpeedPuzzling.com zurückzusetzen. Klicke auf den Button unten, um ein neues zu wählen.

+ button: "Neues Passwort wählen" + expires: "Der Link funktioniert einmal und läuft nach %minutes% Minuten ab." + not_requested: "Du hast das nicht angefordert? Dann kannst du diese E-Mail einfach ignorieren — dein Passwort bleibt unverändert." diff --git a/translations/emails.es.yml b/translations/emails.es.yml index 34c9204f..887c5511 100644 --- a/translations/emails.es.yml +++ b/translations/emails.es.yml @@ -157,3 +157,23 @@ sign_in_link: button: "Iniciar sesión" expires: "El enlace funciona una sola vez y caduca en %minutes% minutos." not_requested: "¿No lo has solicitado? Puedes ignorar este correo sin problema: nadie puede iniciar sesión sin el enlace." + +verify_email: + subject: "Confirma tu dirección de correo electrónico para MySpeedPuzzling.com" + title: "Confirma tu dirección de correo electrónico" + content: | +

¡Bienvenido a MySpeedPuzzling! Confirma esta dirección para que podamos contactarte sobre tu cuenta.

+ button: "Confirmar mi dirección de correo electrónico" + expires: "El enlace es válido durante %hours% horas." + signed_out_rescue: | +

Una cosa que conviene tener en cuenta: el inicio de sesión sigue pasando por nuestro proveedor antiguo durante unos días más, y todavía no conoce tu cuenta recién creada. Si se cierra tu sesión antes del cambio, vuelve a entrar con un enlace de acceso por correo: %url%

+ not_requested: "¿No has creado esta cuenta? Puedes ignorar este correo sin problema." + +password_reset: + subject: "Restablece tu contraseña de MySpeedPuzzling.com" + title: "Elige una contraseña nueva" + content: | +

Alguien —esperamos que tú— ha pedido restablecer la contraseña de tu cuenta de MySpeedPuzzling.com. Haz clic en el botón de abajo para elegir una nueva.

+ button: "Elegir una contraseña nueva" + expires: "El enlace funciona una sola vez y caduca en %minutes% minutos." + not_requested: "¿No lo has solicitado? Puedes ignorar este correo sin problema: tu contraseña seguirá siendo la misma." diff --git a/translations/emails.fr.yml b/translations/emails.fr.yml index 108155b5..d8c4f7ae 100644 --- a/translations/emails.fr.yml +++ b/translations/emails.fr.yml @@ -157,3 +157,23 @@ sign_in_link: button: "Se connecter" expires: "Le lien ne fonctionne qu'une seule fois et expire dans %minutes% minutes." not_requested: "Vous n'êtes pas à l'origine de cette demande ? Vous pouvez ignorer cet e-mail sans crainte — personne ne peut se connecter sans le lien." + +verify_email: + subject: "Confirmez votre adresse e-mail pour MySpeedPuzzling.com" + title: "Confirmez votre adresse e-mail" + content: | +

Bienvenue sur MySpeedPuzzling ! Confirmez cette adresse pour que nous puissions vous joindre au sujet de votre compte.

+ button: "Confirmer mon adresse e-mail" + expires: "Le lien est valable %hours% heures." + signed_out_rescue: | +

Une chose à garder en tête : la connexion passe encore par notre ancien fournisseur pendant quelques jours, et il ne connaît pas encore votre tout nouveau compte. Si vous êtes déconnecté avant le basculement, reconnectez-vous grâce à un lien de connexion par e-mail : %url%

+ not_requested: "Vous n'avez pas créé ce compte ? Vous pouvez ignorer cet e-mail sans crainte." + +password_reset: + subject: "Réinitialisez votre mot de passe MySpeedPuzzling.com" + title: "Choisissez un nouveau mot de passe" + content: | +

Quelqu'un — nous espérons que c'est vous — a demandé la réinitialisation du mot de passe de votre compte MySpeedPuzzling.com. Cliquez sur le bouton ci-dessous pour en choisir un nouveau.

+ button: "Choisir un nouveau mot de passe" + expires: "Le lien ne fonctionne qu'une seule fois et expire dans %minutes% minutes." + not_requested: "Vous n'êtes pas à l'origine de cette demande ? Vous pouvez ignorer cet e-mail sans crainte — votre mot de passe reste inchangé." diff --git a/translations/emails.ja.yml b/translations/emails.ja.yml index 025f3217..15bffa24 100644 --- a/translations/emails.ja.yml +++ b/translations/emails.ja.yml @@ -157,3 +157,23 @@ sign_in_link: button: "サインイン" expires: "リンクは1回のみ有効で、%minutes%分で期限切れになります。" not_requested: "お心当たりがない場合は、このメールは無視していただいて問題ありません。リンクがなければ誰もサインインできません。" + +verify_email: + subject: "MySpeedPuzzling.comのメールアドレスを確認してください" + title: "メールアドレスを確認してください" + content: | +

MySpeedPuzzlingへようこそ!アカウントに関するご連絡ができるよう、このアドレスをご確認ください。

+ button: "メールアドレスを確認する" + expires: "リンクの有効期限は%hours%時間です。" + signed_out_rescue: | +

ひとつ覚えておいていただきたいことがあります。サインインはあと数日のあいだ、これまでの外部サービスを経由しており、そのサービスは作成されたばかりのあなたのアカウントをまだ知りません。切り替えの前にサインアウトされてしまった場合は、メールで届くサインインリンクからもう一度サインインしてください:%url%

+ not_requested: "このアカウントを作成した覚えがない場合は、このメールは無視していただいて問題ありません。" + +password_reset: + subject: "MySpeedPuzzling.comのパスワードをリセット" + title: "新しいパスワードを設定してください" + content: | +

どなたか — おそらくあなた — が、MySpeedPuzzling.comアカウントのパスワードのリセットをリクエストしました。下のボタンをクリックして、新しいパスワードを設定してください。

+ button: "新しいパスワードを設定する" + expires: "リンクは1回のみ有効で、%minutes%分で期限切れになります。" + not_requested: "お心当たりがない場合は、このメールは無視していただいて問題ありません。パスワードはそのまま変わりません。" diff --git a/translations/messages.cs.yml b/translations/messages.cs.yml index fc71197e..d05c3694 100644 --- a/translations/messages.cs.yml +++ b/translations/messages.cs.yml @@ -7,6 +7,7 @@ "players": "Hráči" "faq": "Časté dotazy" "sign_in": "Přihlásit se" + "register": "Registrovat se" "hub": "Hub" "feedback": "Zpětná vazba" "support_us": "Podpořte nás" @@ -750,6 +751,25 @@ "change_password_description": "Pošleme vám e-mail s bezpečným odkazem pro nastavení nového hesla. Platnost odkazu je omezená, využijte ho proto co nejdříve." "change_password_button": "Poslat e-mail pro změnu hesla" "change_password_social_login": "Přihlašujete se přes externí účet (například Google nebo Facebook), takže na MySpeedPuzzling žádné heslo nemáte. Změnit si ho můžete u svého poskytovatele přihlášení." + "change_password_native_description": "Nastavte si nové heslo pomocí toho současného. Váš správce hesel ho uloží pod doménou myspeedpuzzling.com." + "change_password_native_button": "Změnit heslo" + "change_password_current": "Současné heslo" + "change_password_new": "Nové heslo" + "change_password_wrong_current": "To není vaše současné heslo." + "change_password_saved": "Vaše nové heslo je uložené." + "change_password_forgot": "Neznáte své současné heslo?" + "change_email": "E-mailová adresa" + "change_email_description": "Přihlašujete se pomocí %email%. Na tuto adresu také chodí obnovení hesla a přihlašovací odkazy." + "change_email_new": "Nová e-mailová adresa" + "change_email_current_password": "Současné heslo" + "change_email_button": "Změnit e-mailovou adresu" + "change_email_saved": "Vaše e-mailová adresa je změněná. V nové schránce najdete potvrzovací odkaz." + "change_email_taken": "Tato e-mailová adresa už patří jinému účtu." + "change_email_failed": "E-mailovou adresu se nepodařilo změnit. Zkuste to prosím za chvíli znovu." + "email_verified": "E-mailová adresa je potvrzená" + "email_not_verified": "E-mailová adresa zatím není potvrzená" + "email_verify_resend": "Poslat potvrzovací e-mail znovu" + "back": "Zpět na nastavení profilu" "puzzle_detail": "meta": "title": "%brand% %name% – %pieces% dílků" @@ -2873,3 +2893,68 @@ auth: submit: "Uložit heslo" skip: "Teď ne — moje současné heslo dál funguje" saved: "Vaše nové heslo je uložené." + register: + title: "Vytvořit účet" + intro: "Jeden účet pro vaše časy, sbírky a všechno ostatní na MySpeedPuzzling." + email: "E-mail" + password: "Heslo" + password_hint: "Alespoň %minimum% znaků." + submit: "Vytvořit účet" + have_account: "Už máte účet?" + email_already_registered: "K této e-mailové adrese už účet existuje. Přihlaste se — nebo použijte „Poslat mi přihlašovací odkaz e-mailem“, pokud si heslo nemůžete vybavit." + too_many_attempts: "Příliš mnoho pokusů o registraci z tohoto místa. Zkuste to prosím později." + failed: "Váš účet se nepodařilo vytvořit. Zkuste to prosím za chvíli znovu." + welcome: + title: "Vítejte na MySpeedPuzzling!" + intro: "Váš účet je připravený a jste přihlášeni." + verification_sent: "Na adresu %email% jsme poslali potvrzovací odkaz. Otevřete ho a potvrďte, že je adresa vaše." + signed_out_title: "Pokud budete odhlášeni" + signed_out_text: "Přihlašování ještě několik dní běží přes našeho starého poskytovatele a ten o vašem zbrusu novém účtu zatím neví. Pokud budete před přepnutím odhlášeni, vraťte se zpátky pomocí přihlašovacího odkazu e-mailem." + to_profile: "Přejít na můj profil" + verify_email: + success: + headline: "E-mailová adresa je potvrzená" + message: "Děkujeme — vaše e-mailová adresa je potvrzená." + expired: + headline: "Platnost tohoto odkazu vypršela" + message: "Potvrzovací odkazy platí 24 hodin. Přihlaste se a pošlete si nový z nastavení profilu." + invalid: + headline: "Tento odkaz nefunguje" + message: "Možná už byl použit, nebo se od jeho odeslání změnila adresa na účtu. Přihlaste se a pošlete si nový z nastavení profilu." + failed: + headline: "Něco se pokazilo" + message: "Vaši e-mailovou adresu se nepodařilo potvrdit. Zkuste to prosím za chvíli znovu." + to_profile: "Přejít na můj profil" + resend_sent: "Potvrzovací e-mail jsme odeslali. Zkontrolujte svou schránku — i složku se spamem." + resend_too_many: "Právě jsme jeden poslali. Počkejte prosím pár minut, než si vyžádáte další." + resend_failed: "Potvrzovací e-mail se nepodařilo odeslat. Zkuste to prosím za chvíli znovu." + cutover_modal: + title: "Přihlašování se přestěhovalo domů" + intro: "Přihlašování teď probíhá přímo tady na myspeedpuzzling.com — už žádné přesměrování na auth0.com." + same_credentials: "Stejný e-mail, stejné heslo. Nic jsme neresetovali." + vault_tip: "Správce hesel vám ho nenabízí? Uložil si vaše heslo pod naší starou přihlašovací doménou (%old_domain%). Vyhledejte si v něm „speedpuzzling“ nebo „auth0“ — je tam." + sign_in_link: "Nemůžete ho najít? Použijte níže „Poslat mi přihlašovací odkaz e-mailem“ — jedno kliknutí a jste uvnitř. Nové heslo si pak můžete nastavit hned potom." + why: "Proč se to změnilo?" + got_it: "Rozumím" + dismiss: "Zavřít" + password_reset: + link: "Zapomenuté heslo?" + request_title: "Obnovení hesla" + request_intro: "Zadejte svou e-mailovou adresu a my vám pošleme odkaz pro nastavení nového hesla." + request_submit: "Poslat mi odkaz pro obnovu hesla" + sent: "Pokud pro tuto adresu existuje účet, odkaz pro obnovu hesla je na cestě. Zkontrolujte svou schránku — i složku se spamem." + email_required: "Zadejte prosím svou e-mailovou adresu." + too_many_requests: "Příliš mnoho žádostí o obnovu hesla. Počkejte prosím pár minut a zkuste to znovu." + failed: "Odkaz pro obnovu hesla se nepodařilo odeslat. Zkuste to prosím za chvíli znovu." + title: "Zvolte si nové heslo" + intro: "Vyberte si pro svůj účet nové heslo. Váš správce hesel ho uloží pod doménou myspeedpuzzling.com." + new_password: "Nové heslo" + password_hint: "Alespoň %minimum% znaků." + submit: "Uložit nové heslo" + done: "Vaše nové heslo je uložené. Přihlaste se s ním." + expired: + headline: "Platnost tohoto odkazu pro obnovu hesla vypršela" + message: "Odkazy pro obnovu hesla platí jednu hodinu. Vyžádejte si nový a hned ho použijte." + invalid: + headline: "Tento odkaz pro obnovu hesla nefunguje" + message: "Možná už byl použit, nebo ho nahradil novější odkaz pro obnovu hesla. Níže si vyžádejte nový." diff --git a/translations/messages.de.yml b/translations/messages.de.yml index 1b54589a..786c772b 100644 --- a/translations/messages.de.yml +++ b/translations/messages.de.yml @@ -7,6 +7,7 @@ "players": "Spieler" "faq": "FAQ" "sign_in": "Anmelden" + "register": "Registrieren" "hub": "Hub" "feedback": "Feedback senden" "support_us": "Unterstütze uns" @@ -730,6 +731,25 @@ "change_password_description": "Wir senden Ihnen eine E-Mail mit einem sicheren Link, über den Sie ein neues Passwort festlegen können. Der Link ist nur begrenzte Zeit gültig, nutzen Sie ihn also bald." "change_password_button": "E-Mail zur Passwortänderung senden" "change_password_social_login": "Sie melden sich über ein externes Konto an (z. B. Google oder Facebook), daher gibt es kein MySpeedPuzzling-Passwort, das geändert werden könnte. Ihr Passwort können Sie bei Ihrem Anmeldeanbieter ändern." + "change_password_native_description": "Legen Sie mit Ihrem aktuellen Passwort ein neues fest. Ihr Passwort-Manager speichert es unter myspeedpuzzling.com." + "change_password_native_button": "Passwort ändern" + "change_password_current": "Aktuelles Passwort" + "change_password_new": "Neues Passwort" + "change_password_wrong_current": "Das ist nicht Ihr aktuelles Passwort." + "change_password_saved": "Ihr neues Passwort wurde gespeichert." + "change_password_forgot": "Sie kennen Ihr aktuelles Passwort nicht?" + "change_email": "E-Mail-Adresse" + "change_email_description": "Sie melden sich mit %email% an. An diese Adresse gehen auch Links zum Zurücksetzen des Passworts und Anmeldelinks." + "change_email_new": "Neue E-Mail-Adresse" + "change_email_current_password": "Aktuelles Passwort" + "change_email_button": "E-Mail-Adresse ändern" + "change_email_saved": "Ihre E-Mail-Adresse wurde geändert. Schauen Sie im neuen Postfach nach dem Bestätigungslink." + "change_email_taken": "Diese E-Mail-Adresse gehört bereits zu einem anderen Konto." + "change_email_failed": "Wir konnten Ihre E-Mail-Adresse nicht ändern. Bitte versuchen Sie es gleich noch einmal." + "email_verified": "E-Mail-Adresse bestätigt" + "email_not_verified": "E-Mail-Adresse noch nicht bestätigt" + "email_verify_resend": "Bestätigungs-E-Mail erneut senden" + "back": "Zurück zu den Profileinstellungen" "puzzle_detail": "meta": "title": "%brand% %name% – %pieces% Teile" @@ -2875,3 +2895,68 @@ auth: submit: "Passwort speichern" skip: "Jetzt nicht – mein aktuelles Passwort funktioniert weiter" saved: "Ihr neues Passwort wurde gespeichert." + register: + title: "Konto erstellen" + intro: "Ein Konto für Ihre Zeiten, Sammlungen und alles andere auf MySpeedPuzzling." + email: "E-Mail" + password: "Passwort" + password_hint: "Mindestens %minimum% Zeichen." + submit: "Konto erstellen" + have_account: "Sie haben bereits ein Konto?" + email_already_registered: "Für diese E-Mail-Adresse gibt es bereits ein Konto. Melden Sie sich stattdessen an – oder nutzen Sie „Anmeldelink per E-Mail schicken“, falls Ihnen das Passwort gerade nicht einfällt." + too_many_attempts: "Zu viele Registrierungsversuche von hier. Bitte versuchen Sie es später erneut." + failed: "Wir konnten Ihr Konto nicht erstellen. Bitte versuchen Sie es gleich noch einmal." + welcome: + title: "Willkommen bei MySpeedPuzzling!" + intro: "Ihr Konto ist bereit und Sie sind angemeldet." + verification_sent: "Wir haben einen Bestätigungslink an %email% geschickt. Öffnen Sie ihn, um zu bestätigen, dass die Adresse Ihnen gehört." + signed_out_title: "Falls Sie abgemeldet werden" + signed_out_text: "Die Anmeldung läuft noch ein paar Tage über unseren alten Anbieter, und der kennt Ihr brandneues Konto noch nicht. Falls Sie vor der Umstellung abgemeldet werden, kommen Sie mit einem Anmeldelink per E-Mail wieder hinein." + to_profile: "Zu meinem Profil" + verify_email: + success: + headline: "E-Mail-Adresse bestätigt" + message: "Vielen Dank – Ihre E-Mail-Adresse ist bestätigt." + expired: + headline: "Dieser Link ist abgelaufen" + message: "Bestätigungslinks sind 24 Stunden gültig. Melden Sie sich an und schicken Sie sich in Ihren Profileinstellungen einen neuen." + invalid: + headline: "Dieser Link funktioniert nicht" + message: "Er wurde möglicherweise bereits verwendet, oder die Adresse des Kontos hat sich seit dem Versand geändert. Melden Sie sich an und schicken Sie sich in Ihren Profileinstellungen einen neuen." + failed: + headline: "Etwas ist schiefgelaufen" + message: "Wir konnten Ihre E-Mail-Adresse nicht bestätigen. Bitte versuchen Sie es gleich noch einmal." + to_profile: "Zu meinem Profil" + resend_sent: "Bestätigungs-E-Mail gesendet. Schauen Sie in Ihr Postfach – und in den Spam-Ordner." + resend_too_many: "Wir haben gerade eine geschickt. Bitte warten Sie ein paar Minuten, bevor Sie eine weitere anfordern." + resend_failed: "Wir konnten die Bestätigungs-E-Mail nicht senden. Bitte versuchen Sie es gleich noch einmal." + cutover_modal: + title: "Die Anmeldung ist umgezogen" + intro: "Die Anmeldung findet jetzt direkt hier auf myspeedpuzzling.com statt – keine Weiterleitung mehr zu auth0.com." + same_credentials: "Gleiche E-Mail, gleiches Passwort. Es wurde nichts zurückgesetzt." + vault_tip: "Ihr Passwort-Manager bietet es nicht an? Er hat Ihr Passwort unter unserer alten Anmelde-Domain (%old_domain%) gespeichert. Suchen Sie dort nach „speedpuzzling“ oder „auth0“ – es ist da." + sign_in_link: "Nicht zu finden? Nutzen Sie unten „Anmeldelink per E-Mail schicken“ – ein Klick und Sie sind drin. Ein frisches Passwort können Sie danach festlegen." + why: "Warum hat sich das geändert?" + got_it: "Alles klar" + dismiss: "Schließen" + password_reset: + link: "Passwort vergessen?" + request_title: "Passwort zurücksetzen" + request_intro: "Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen einen Link, mit dem Sie ein neues Passwort wählen." + request_submit: "Link zum Zurücksetzen per E-Mail schicken" + sent: "Falls für diese Adresse ein Konto existiert, ist ein Link zum Zurücksetzen unterwegs. Schauen Sie in Ihr Postfach – und in den Spam-Ordner." + email_required: "Bitte geben Sie Ihre E-Mail-Adresse ein." + too_many_requests: "Zu viele Anfragen zum Zurücksetzen. Bitte warten Sie ein paar Minuten und versuchen Sie es erneut." + failed: "Wir konnten den Link zum Zurücksetzen nicht senden. Bitte versuchen Sie es gleich noch einmal." + title: "Neues Passwort wählen" + intro: "Wählen Sie ein neues Passwort für Ihr Konto. Ihr Passwort-Manager speichert es unter myspeedpuzzling.com." + new_password: "Neues Passwort" + password_hint: "Mindestens %minimum% Zeichen." + submit: "Neues Passwort speichern" + done: "Ihr neues Passwort wurde gespeichert. Melden Sie sich damit an." + expired: + headline: "Dieser Link zum Zurücksetzen ist abgelaufen" + message: "Links zum Zurücksetzen sind eine Stunde gültig. Fordern Sie einen neuen an und verwenden Sie ihn gleich." + invalid: + headline: "Dieser Link zum Zurücksetzen funktioniert nicht" + message: "Er wurde möglicherweise bereits verwendet, oder ein neuerer Link zum Zurücksetzen hat ihn ersetzt. Fordern Sie unten einen neuen an." diff --git a/translations/messages.es.yml b/translations/messages.es.yml index 86fae4e2..b915d4ee 100644 --- a/translations/messages.es.yml +++ b/translations/messages.es.yml @@ -7,6 +7,7 @@ "players": "Jugadores" "faq": "Preguntas frecuentes" "sign_in": "Iniciar sesión" + "register": "Registrarse" "hub": "Centro" "feedback": "Enviar comentarios" "support_us": "Apóyanos" @@ -495,6 +496,25 @@ "change_password_description": "Te enviaremos un correo con un enlace seguro para establecer una nueva contraseña. El enlace caduca al cabo de un tiempo, así que úsalo pronto." "change_password_button": "Enviar correo para cambiar la contraseña" "change_password_social_login": "Inicias sesión con una cuenta externa (como Google o Facebook), así que no hay ninguna contraseña de MySpeedPuzzling que cambiar. Puedes cambiar tu contraseña desde tu proveedor de inicio de sesión." + "change_password_native_description": "Establece una contraseña nueva usando la actual. Tu gestor de contraseñas la guardará bajo myspeedpuzzling.com." + "change_password_native_button": "Cambiar contraseña" + "change_password_current": "Contraseña actual" + "change_password_new": "Nueva contraseña" + "change_password_wrong_current": "Esa no es tu contraseña actual." + "change_password_saved": "Tu nueva contraseña se ha guardado." + "change_password_forgot": "¿No recuerdas tu contraseña actual?" + "change_email": "Dirección de correo electrónico" + "change_email_description": "Inicias sesión con %email%. Es también la dirección a la que enviamos los restablecimientos de contraseña y los enlaces de acceso." + "change_email_new": "Nueva dirección de correo electrónico" + "change_email_current_password": "Contraseña actual" + "change_email_button": "Cambiar la dirección de correo electrónico" + "change_email_saved": "Tu dirección de correo electrónico se ha cambiado. Revisa la nueva bandeja de entrada para encontrar el enlace de confirmación." + "change_email_taken": "Esta dirección de correo electrónico ya pertenece a otra cuenta." + "change_email_failed": "No hemos podido cambiar tu dirección de correo electrónico. Inténtalo de nuevo en un momento." + "email_verified": "Dirección de correo electrónico confirmada" + "email_not_verified": "Dirección de correo electrónico aún sin confirmar" + "email_verify_resend": "Reenviar el correo de confirmación" + "back": "Volver a la configuración del perfil" "puzzle_detail": "meta": "title": "%brand% %name% – %pieces% piezas" @@ -2875,3 +2895,68 @@ fair_use_policy: "submit": "Guardar contraseña" "skip": "Ahora no: mi contraseña actual sigue funcionando" "saved": "Tu nueva contraseña se ha guardado." + "register": + "title": "Crear una cuenta" + "intro": "Una sola cuenta para tus tiempos, tus colecciones y todo lo demás en MySpeedPuzzling." + "email": "Correo electrónico" + "password": "Contraseña" + "password_hint": "Al menos %minimum% caracteres." + "submit": "Crear cuenta" + "have_account": "¿Ya tienes una cuenta?" + "email_already_registered": "Esta dirección de correo electrónico ya tiene una cuenta. Inicia sesión, o usa \"Envíame un enlace de acceso por correo\" si no consigues dar con la contraseña." + "too_many_attempts": "Demasiados intentos de registro desde aquí. Inténtalo de nuevo más tarde." + "failed": "No hemos podido crear tu cuenta. Inténtalo de nuevo en un momento." + "welcome": + "title": "¡Bienvenido a MySpeedPuzzling!" + "intro": "Tu cuenta ya está lista y has iniciado sesión." + "verification_sent": "Hemos enviado un enlace de confirmación a %email%. Ábrelo para confirmar que la dirección es tuya." + "signed_out_title": "Si se cierra tu sesión" + "signed_out_text": "El inicio de sesión sigue pasando por nuestro proveedor antiguo durante unos días más, y todavía no conoce tu cuenta recién creada. Si se cierra tu sesión antes del cambio, vuelve a entrar con un enlace de acceso por correo." + "to_profile": "Ir a mi perfil" + "verify_email": + "success": + "headline": "Dirección de correo electrónico confirmada" + "message": "Gracias: tu dirección de correo electrónico está confirmada." + "expired": + "headline": "Este enlace ha caducado" + "message": "Los enlaces de confirmación son válidos durante 24 horas. Inicia sesión y envíate uno nuevo desde la configuración de tu perfil." + "invalid": + "headline": "Este enlace no funciona" + "message": "Puede que ya se haya usado, o que la dirección de la cuenta haya cambiado desde que se envió. Inicia sesión y envíate uno nuevo desde la configuración de tu perfil." + "failed": + "headline": "Algo ha salido mal" + "message": "No hemos podido confirmar tu dirección de correo electrónico. Inténtalo de nuevo en un momento." + "to_profile": "Ir a mi perfil" + "resend_sent": "Correo de confirmación enviado. Revisa tu bandeja de entrada y también la carpeta de spam." + "resend_too_many": "Acabamos de enviarte uno. Espera unos minutos antes de pedir otro." + "resend_failed": "No hemos podido enviar el correo de confirmación. Inténtalo de nuevo en un momento." + "cutover_modal": + "title": "El inicio de sesión se ha mudado a casa" + "intro": "Ahora el inicio de sesión ocurre aquí mismo, en myspeedpuzzling.com: se acabaron las redirecciones a auth0.com." + "same_credentials": "El mismo correo, la misma contraseña. No se ha restablecido nada." + "vault_tip": "¿Tu gestor de contraseñas no te la ofrece? Guardó tu contraseña bajo nuestro antiguo dominio de inicio de sesión (%old_domain%). Busca ahí \"speedpuzzling\" o \"auth0\": está ahí." + "sign_in_link": "¿No la encuentras? Usa \"Envíame un enlace de acceso por correo\" aquí abajo: un clic y ya estás dentro. Después puedes establecer una contraseña nueva." + "why": "¿Por qué ha cambiado esto?" + "got_it": "Entendido" + "dismiss": "Cerrar" + "password_reset": + "link": "¿Has olvidado tu contraseña?" + "request_title": "Restablecer tu contraseña" + "request_intro": "Introduce tu dirección de correo electrónico y te enviaremos un enlace para elegir una contraseña nueva." + "request_submit": "Envíame un enlace de restablecimiento" + "sent": "Si existe una cuenta con esa dirección, el enlace de restablecimiento ya está en camino. Revisa tu bandeja de entrada y también la carpeta de spam." + "email_required": "Por favor, introduce tu dirección de correo electrónico." + "too_many_requests": "Demasiadas solicitudes de restablecimiento. Espera unos minutos e inténtalo de nuevo." + "failed": "No hemos podido enviar el enlace de restablecimiento. Inténtalo de nuevo en un momento." + "title": "Elige una contraseña nueva" + "intro": "Elige una contraseña nueva para tu cuenta. Tu gestor de contraseñas la guardará bajo myspeedpuzzling.com." + "new_password": "Nueva contraseña" + "password_hint": "Al menos %minimum% caracteres." + "submit": "Guardar la nueva contraseña" + "done": "Tu nueva contraseña se ha guardado. Inicia sesión con ella." + "expired": + "headline": "Este enlace de restablecimiento ha caducado" + "message": "Los enlaces de restablecimiento son válidos durante una hora. Solicita uno nuevo y úsalo enseguida." + "invalid": + "headline": "Este enlace de restablecimiento no funciona" + "message": "Puede que ya se haya usado, o que un enlace de restablecimiento más nuevo lo haya sustituido. Solicita uno nuevo aquí abajo." diff --git a/translations/messages.fr.yml b/translations/messages.fr.yml index c5958d16..a973ee22 100644 --- a/translations/messages.fr.yml +++ b/translations/messages.fr.yml @@ -7,6 +7,7 @@ "players": "Joueurs" "faq": "FAQ" "sign_in": "Se connecter" + "register": "S'inscrire" "hub": "Centre" "feedback": "Envoyer un commentaire" "support_us": "Nous soutenir" @@ -733,6 +734,25 @@ "change_password_description": "Nous vous enverrons un e-mail contenant un lien sécurisé pour définir un nouveau mot de passe. Ce lien expire au bout d'un certain temps, utilisez-le donc rapidement." "change_password_button": "Envoyer l'e-mail de changement de mot de passe" "change_password_social_login": "Vous vous connectez via un compte externe (Google ou Facebook, par exemple), il n'y a donc aucun mot de passe MySpeedPuzzling à changer. Vous pouvez modifier votre mot de passe auprès de votre fournisseur de connexion." + "change_password_native_description": "Définissez un nouveau mot de passe à l'aide de votre mot de passe actuel. Votre gestionnaire de mots de passe l'enregistrera sous myspeedpuzzling.com." + "change_password_native_button": "Changer le mot de passe" + "change_password_current": "Mot de passe actuel" + "change_password_new": "Nouveau mot de passe" + "change_password_wrong_current": "Ce n'est pas votre mot de passe actuel." + "change_password_saved": "Votre nouveau mot de passe est enregistré." + "change_password_forgot": "Vous ne connaissez pas votre mot de passe actuel ?" + "change_email": "Adresse e-mail" + "change_email_description": "Vous vous connectez avec %email%. C'est aussi à cette adresse que sont envoyés les liens de réinitialisation de mot de passe et de connexion." + "change_email_new": "Nouvelle adresse e-mail" + "change_email_current_password": "Mot de passe actuel" + "change_email_button": "Changer l'adresse e-mail" + "change_email_saved": "Votre adresse e-mail a été changée. Consultez la nouvelle boîte de réception pour y trouver un lien de confirmation." + "change_email_taken": "Cette adresse e-mail appartient déjà à un autre compte." + "change_email_failed": "Nous n'avons pas pu changer votre adresse e-mail. Veuillez réessayer dans un instant." + "email_verified": "Adresse e-mail confirmée" + "email_not_verified": "Adresse e-mail pas encore confirmée" + "email_verify_resend": "Renvoyer l'e-mail de confirmation" + "back": "Retour aux paramètres du profil" "puzzle_detail": "meta": "title": "%brand% %name% – %pieces% pièces" @@ -2877,3 +2897,68 @@ edition: "submit": "Enregistrer le mot de passe" "skip": "Pas maintenant — mon mot de passe actuel fonctionne toujours" "saved": "Votre nouveau mot de passe est enregistré." + "register": + "title": "Créer un compte" + "intro": "Un seul compte pour vos temps, vos collections et tout le reste sur MySpeedPuzzling." + "email": "E-mail" + "password": "Mot de passe" + "password_hint": "Au moins %minimum% caractères." + "submit": "Créer un compte" + "have_account": "Vous avez déjà un compte ?" + "email_already_registered": "Cette adresse e-mail a déjà un compte. Connectez-vous plutôt — ou utilisez « Recevoir un lien de connexion par e-mail » si le mot de passe ne vous revient pas." + "too_many_attempts": "Trop de tentatives d'inscription depuis cet appareil. Veuillez réessayer plus tard." + "failed": "Nous n'avons pas pu créer votre compte. Veuillez réessayer dans un instant." + "welcome": + "title": "Bienvenue sur MySpeedPuzzling !" + "intro": "Votre compte est prêt et vous êtes connecté." + "verification_sent": "Nous avons envoyé un lien de confirmation à %email%. Ouvrez-le pour confirmer que cette adresse est bien la vôtre." + "signed_out_title": "Si vous êtes déconnecté" + "signed_out_text": "La connexion passe encore par notre ancien fournisseur pendant quelques jours, et il ne connaît pas encore votre tout nouveau compte. Si vous êtes déconnecté avant le basculement, reconnectez-vous grâce à un lien de connexion par e-mail." + "to_profile": "Aller à mon profil" + "verify_email": + "success": + "headline": "Adresse e-mail confirmée" + "message": "Merci — votre adresse e-mail est confirmée." + "expired": + "headline": "Ce lien a expiré" + "message": "Les liens de confirmation sont valables 24 heures. Connectez-vous et demandez-en un nouveau depuis les paramètres de votre profil." + "invalid": + "headline": "Ce lien ne fonctionne pas" + "message": "Il a peut-être déjà été utilisé, ou l'adresse du compte a changé depuis son envoi. Connectez-vous et demandez-en un nouveau depuis les paramètres de votre profil." + "failed": + "headline": "Une erreur est survenue" + "message": "Nous n'avons pas pu confirmer votre adresse e-mail. Veuillez réessayer dans un instant." + "to_profile": "Aller à mon profil" + "resend_sent": "E-mail de confirmation envoyé. Vérifiez votre boîte de réception — et vos spams." + "resend_too_many": "Nous venons d'en envoyer un. Patientez quelques minutes avant d'en demander un autre." + "resend_failed": "Nous n'avons pas pu envoyer l'e-mail de confirmation. Veuillez réessayer dans un instant." + "cutover_modal": + "title": "La connexion a déménagé chez nous" + "intro": "La connexion se fait désormais ici même, sur myspeedpuzzling.com — plus de redirection vers auth0.com." + "same_credentials": "Même e-mail, même mot de passe. Rien n'a été réinitialisé." + "vault_tip": "Votre gestionnaire de mots de passe ne le propose pas ? Il a enregistré votre mot de passe sous notre ancien domaine de connexion (%old_domain%). Cherchez-y \"speedpuzzling\" ou \"auth0\" — il y est." + "sign_in_link": "Vous ne le retrouvez pas ? Utilisez « Recevoir un lien de connexion par e-mail » ci-dessous — un clic et vous êtes connecté. Vous pourrez définir un nouveau mot de passe ensuite." + "why": "Pourquoi ce changement ?" + "got_it": "J'ai compris" + "dismiss": "Fermer" + "password_reset": + "link": "Mot de passe oublié ?" + "request_title": "Réinitialiser votre mot de passe" + "request_intro": "Saisissez votre adresse e-mail et nous vous enverrons un lien pour choisir un nouveau mot de passe." + "request_submit": "Recevoir un lien de réinitialisation par e-mail" + "sent": "Si un compte existe pour cette adresse, un lien de réinitialisation est en route. Vérifiez votre boîte de réception — et vos spams." + "email_required": "Veuillez saisir votre adresse e-mail." + "too_many_requests": "Trop de demandes de réinitialisation. Patientez quelques minutes et réessayez." + "failed": "Nous n'avons pas pu envoyer le lien de réinitialisation. Veuillez réessayer dans un instant." + "title": "Choisissez un nouveau mot de passe" + "intro": "Choisissez un nouveau mot de passe pour votre compte. Votre gestionnaire de mots de passe l'enregistrera sous myspeedpuzzling.com." + "new_password": "Nouveau mot de passe" + "password_hint": "Au moins %minimum% caractères." + "submit": "Enregistrer le nouveau mot de passe" + "done": "Votre nouveau mot de passe est enregistré. Connectez-vous avec celui-ci." + "expired": + "headline": "Ce lien de réinitialisation a expiré" + "message": "Les liens de réinitialisation sont valables une heure. Demandez-en un nouveau et utilisez-le immédiatement." + "invalid": + "headline": "Ce lien de réinitialisation ne fonctionne pas" + "message": "Il a peut-être déjà été utilisé, ou un lien de réinitialisation plus récent l'a remplacé. Demandez-en un nouveau ci-dessous." diff --git a/translations/messages.ja.yml b/translations/messages.ja.yml index 0b0c4099..067bdb42 100644 --- a/translations/messages.ja.yml +++ b/translations/messages.ja.yml @@ -7,6 +7,7 @@ "players": "プレイヤー" "faq": "よくある質問" "sign_in": "サインイン" + "register": "登録" "hub": "ハブ" "feedback": "フィードバック送信" "support_us": "サポート" @@ -838,6 +839,25 @@ "change_password_description": "新しいパスワードを設定するための安全なリンクをメールでお送りします。リンクは一定時間で期限切れになりますので、お早めにご利用ください。" "change_password_button": "パスワード変更メールを送信" "change_password_social_login": "外部アカウント(GoogleやFacebookなど)でサインインしているため、変更できるMySpeedPuzzlingのパスワードはありません。パスワードの変更はご利用のログインプロバイダーで行ってください。" + "change_password_native_description": "現在のパスワードを使って、新しいパスワードを設定します。パスワードマネージャーはmyspeedpuzzling.comのものとして保存します。" + "change_password_native_button": "パスワードを変更" + "change_password_current": "現在のパスワード" + "change_password_new": "新しいパスワード" + "change_password_wrong_current": "現在のパスワードが正しくありません。" + "change_password_saved": "新しいパスワードを保存しました。" + "change_password_forgot": "現在のパスワードがわかりませんか?" + "change_email": "メールアドレス" + "change_email_description": "%email%でサインインしています。パスワードのリセットやサインインリンクも、このアドレスにお送りします。" + "change_email_new": "新しいメールアドレス" + "change_email_current_password": "現在のパスワード" + "change_email_button": "メールアドレスを変更" + "change_email_saved": "メールアドレスを変更しました。新しい受信トレイで確認リンクをご確認ください。" + "change_email_taken": "このメールアドレスはすでに別のアカウントで使用されています。" + "change_email_failed": "メールアドレスを変更できませんでした。しばらくしてからもう一度お試しください。" + "email_verified": "メールアドレスは確認済みです" + "email_not_verified": "メールアドレスはまだ確認されていません" + "email_verify_resend": "確認メールを再送する" + "back": "プロフィール設定に戻る" "puzzle_detail": "meta": "title": "%brand% %name% – %pieces%ピース" @@ -2864,3 +2884,68 @@ qr_code: "submit": "パスワードを保存" "skip": "今はしない — 現在のパスワードをそのまま使う" "saved": "新しいパスワードを保存しました。" + "register": + "title": "アカウントを作成" + "intro": "タイムもコレクションも、MySpeedPuzzlingのすべてをひとつのアカウントで。" + "email": "メールアドレス" + "password": "パスワード" + "password_hint": "%minimum%文字以上。" + "submit": "アカウントを作成" + "have_account": "すでにアカウントをお持ちですか?" + "email_already_registered": "このメールアドレスのアカウントはすでに存在します。サインインしてください — パスワードが思い出せない場合は「サインインリンクをメールで受け取る」をご利用ください。" + "too_many_attempts": "この場所からの登録の試行が多すぎます。しばらくしてからもう一度お試しください。" + "failed": "アカウントを作成できませんでした。しばらくしてからもう一度お試しください。" + "welcome": + "title": "MySpeedPuzzlingへようこそ!" + "intro": "アカウントの準備が整い、サインインが完了しました。" + "verification_sent": "%email%に確認リンクをお送りしました。リンクを開いて、このアドレスがあなたのものであることをご確認ください。" + "signed_out_title": "サインアウトされてしまったときは" + "signed_out_text": "サインインはあと数日のあいだ、これまでの外部サービスを経由します。そのサービスは、作成されたばかりのあなたのアカウントをまだ知りません。切り替えの前にサインアウトされてしまった場合は、メールで届くサインインリンクからもう一度サインインしてください。" + "to_profile": "マイプロフィールへ" + "verify_email": + "success": + "headline": "メールアドレスを確認しました" + "message": "ありがとうございます — メールアドレスの確認が完了しました。" + "expired": + "headline": "このリンクは期限切れです" + "message": "確認リンクの有効期限は24時間です。サインインして、プロフィール設定から新しいリンクをご自身にお送りください。" + "invalid": + "headline": "このリンクは使用できません" + "message": "すでに使用済みか、送信後にアカウントのアドレスが変更された可能性があります。サインインして、プロフィール設定から新しいリンクをご自身にお送りください。" + "failed": + "headline": "問題が発生しました" + "message": "メールアドレスを確認できませんでした。しばらくしてからもう一度お試しください。" + "to_profile": "マイプロフィールへ" + "resend_sent": "確認メールを送信しました。受信トレイと迷惑メールフォルダをご確認ください。" + "resend_too_many": "たった今お送りしたばかりです。次のリクエストまで数分お待ちください。" + "resend_failed": "確認メールを送信できませんでした。しばらくしてからもう一度お試しください。" + "cutover_modal": + "title": "サインインがこのサイトに移りました" + "intro": "サインインは、auth0.comへ移動することなく、このmyspeedpuzzling.com上で行われるようになりました。" + "same_credentials": "メールアドレスもパスワードもこれまでどおりです。リセットされたものはありません。" + "vault_tip": "パスワードマネージャーが提示してくれませんか?パスワードは以前のサインイン用ドメイン(%old_domain%)で保存されています。マネージャー内を「speedpuzzling」または「auth0」で検索すれば、きっと見つかります。" + "sign_in_link": "見つかりませんか?下の「サインインリンクをメールで受け取る」をご利用ください。クリックするだけでサインインできます。そのあとで新しいパスワードを設定することもできます。" + "why": "なぜ変わったのですか?" + "got_it": "了解しました" + "dismiss": "閉じる" + "password_reset": + "link": "パスワードをお忘れですか?" + "request_title": "パスワードをリセット" + "request_intro": "メールアドレスを入力してください。新しいパスワードを設定するためのリンクをお送りします。" + "request_submit": "リセットリンクをメールで送る" + "sent": "そのアドレスのアカウントが存在する場合は、リセットリンクをお送りしました。受信トレイと迷惑メールフォルダをご確認ください。" + "email_required": "メールアドレスを入力してください。" + "too_many_requests": "リセットのリクエストが多すぎます。数分お待ちいただいてから、もう一度お試しください。" + "failed": "リセットリンクを送信できませんでした。しばらくしてからもう一度お試しください。" + "title": "新しいパスワードを設定" + "intro": "アカウントの新しいパスワードを設定してください。パスワードマネージャーはmyspeedpuzzling.comのものとして保存します。" + "new_password": "新しいパスワード" + "password_hint": "%minimum%文字以上。" + "submit": "新しいパスワードを保存" + "done": "新しいパスワードを保存しました。このパスワードでサインインしてください。" + "expired": + "headline": "このリセットリンクは期限切れです" + "message": "リセットリンクの有効期限は1時間です。新しいリンクをリクエストして、すぐにご利用ください。" + "invalid": + "headline": "このリセットリンクは使用できません" + "message": "すでに使用済みか、新しいリセットリンクに置き換えられた可能性があります。下記から新しいリンクをリクエストしてください。" From 4511c1ac471f82b6baf1530d98444dfc8847ce83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Mike=C5=A1?= Date: Sat, 25 Jul 2026 00:56:44 +0200 Subject: [PATCH 4/9] Fix four defects found reviewing the 2c-II auth core (issue #147) All four would have shipped dark and only bitten at Stage A/B. 1. Security::login() without an authenticator name throws through window A. The `main` firewall carries three authenticators (LoginFormAuthenticator, auth0.authenticator, login_link) and Security::login() refuses to guess - so EVERY native registration would have 500'd the moment the flag flipped. Now names LoginFormAuthenticator explicitly; RegisterControllerTest::testRegistrationCreatesAccountAndPlayerAndSignsTheUserIn fails with "Too many authenticators were found" without the fix. 2. The "forgot password" CSRF token id was not in stateless_token_ids, so rendering the form for an anonymous visitor reached for the session and put a cookie on the page - a straight #164 regression that would have made every later page uncacheable for that visitor. 3. Changing the account email left outstanding password-reset requests alive. Reset tokens bind to the account, not the address, and losing control of a mailbox is a common reason to change it - so whoever held the old inbox kept an hour-long way back in. Now invalidated with the address. 4. The reset-token route required exactly 64 hex chars, so a link a mail client wrapped or truncated 404'd instead of reaching the "this link does not work, here is a new one" page. Adds RegisterControllerTest: both flag states, the window-A player-email collision, weak-password rejection, no-session/no-shared-cache on the anonymous GET, and locale negotiation. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017o4pbdfkhHDB4vdJPNY3tc --- config/packages/csrf.php | 3 + src/Controller/PasswordResetController.php | 5 +- src/Controller/RegisterController.php | 6 + .../ChangeAccountEmailHandler.php | 8 + tests/Controller/RegisterControllerTest.php | 208 ++++++++++++++++++ 5 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 tests/Controller/RegisterControllerTest.php diff --git a/config/packages/csrf.php b/config/packages/csrf.php index 460c0c1e..531789f2 100644 --- a/config/packages/csrf.php +++ b/config/packages/csrf.php @@ -21,6 +21,9 @@ // session-free (#164), so its token id must not fall back to the // session-backed manager 'sign_in_link', + // Same reason: the "forgot password" form is rendered for anonymous + // visitors, and a session-backed token would put a cookie on the page + 'request_password_reset', ], ], ], diff --git a/src/Controller/PasswordResetController.php b/src/Controller/PasswordResetController.php index 72853e34..7a1900b4 100644 --- a/src/Controller/PasswordResetController.php +++ b/src/Controller/PasswordResetController.php @@ -42,7 +42,10 @@ public function __construct( #[Route( path: '/password-reset/{token}', name: 'password_reset', - requirements: ['token' => '[0-9a-f]{64}'], + // Deliberately looser than the token's real shape (64 hex chars): a link the + // mail client wrapped or truncated should reach the controller and get the + // "this link does not work, here is a new one" page, not a bare 404 + requirements: ['token' => '[0-9a-zA-Z]{1,128}'], defaults: [NativeAuthPageSubscriber::ROUTE_DEFAULT => true], methods: ['GET', 'POST'], )] diff --git a/src/Controller/RegisterController.php b/src/Controller/RegisterController.php index 7744235f..7d3d4d62 100644 --- a/src/Controller/RegisterController.php +++ b/src/Controller/RegisterController.php @@ -11,6 +11,7 @@ use SpeedPuzzling\Web\FormType\RegistrationFormType; use SpeedPuzzling\Web\Message\RegisterUser; use SpeedPuzzling\Web\Message\SendEmailVerificationLink; +use SpeedPuzzling\Web\Security\LoginFormAuthenticator; use SpeedPuzzling\Web\Security\UserAccountProvider; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\SecurityBundle\Security; @@ -116,8 +117,13 @@ public function __invoke(Request $request): Response // Straight into the session: the account was just created with a password // the visitor chose. Verification gates nothing (D7) - it is asked for by // email, never enforced here. + // + // The authenticator must be named: through window A the `main` firewall + // carries LoginFormAuthenticator, the Auth0 authenticator and the login + // link, and Security::login() refuses to guess between them. $this->security->login( $this->userAccountProvider->loadUserByIdentifier($userId), + authenticatorName: LoginFormAuthenticator::class, firewallName: 'main', ); diff --git a/src/MessageHandler/ChangeAccountEmailHandler.php b/src/MessageHandler/ChangeAccountEmailHandler.php index a2c85651..1fd01ee4 100644 --- a/src/MessageHandler/ChangeAccountEmailHandler.php +++ b/src/MessageHandler/ChangeAccountEmailHandler.php @@ -10,6 +10,7 @@ use SpeedPuzzling\Web\Exceptions\UserAccountNotFound; use SpeedPuzzling\Web\Message\ChangeAccountEmail; use SpeedPuzzling\Web\Repository\PlayerRepository; +use SpeedPuzzling\Web\Repository\ResetPasswordRequestRepository; use SpeedPuzzling\Web\Repository\UserAccountRepository; use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; @@ -31,6 +32,7 @@ public function __construct( private UserAccountRepository $userAccountRepository, private PlayerRepository $playerRepository, + private ResetPasswordRequestRepository $resetPasswordRequestRepository, private UserPasswordHasherInterface $passwordHasher, ) { } @@ -77,6 +79,12 @@ public function __invoke(ChangeAccountEmail $message): void $userAccount->changeEmail($newEmail); + // A reset link already on its way to the OLD address must die with it. Losing + // control of that mailbox is one of the reasons people change their address, + // and reset tokens are bound to the account rather than to the address - so + // without this, whoever holds the old inbox keeps an hour-long way back in. + $this->resetPasswordRequestRepository->removeAllForUserAccount($userAccount); + // The player row carries its own copy - it is what notification emails are sent // to - so the two must not drift apart $player = $this->playerRepository->findByUserId($userAccount->userId); diff --git a/tests/Controller/RegisterControllerTest.php b/tests/Controller/RegisterControllerTest.php new file mode 100644 index 00000000..38c6285f --- /dev/null +++ b/tests/Controller/RegisterControllerTest.php @@ -0,0 +1,208 @@ +createClientWithNativeRegistration(false); + + $browser->request('GET', '/register'); + + self::assertResponseRedirects('/login'); + } + + public function testRegistrationCreatesAccountAndPlayerAndSignsTheUserIn(): void + { + $browser = $this->createClientWithNativeRegistration(true); + $email = $this->randomEmail('register.happy'); + + $this->submitRegistration($browser, $email, 'a-properly-long-passphrase'); + + self::assertResponseRedirects('/welcome'); + + // The whole point of naming the authenticator in Security::login(): the `main` + // firewall carries three of them through window A, and an unnamed call throws + $token = $browser->getContainer()->get(TokenStorageInterface::class)->getToken(); + self::assertNotNull($token); + self::assertInstanceOf(UserAccount::class, $token->getUser()); + + $userId = $token->getUserIdentifier(); + self::assertStringStartsWith('msp|', $userId); + + $userAccount = $browser->getContainer()->get(UserAccountRepository::class)->findByUserId($userId); + self::assertNotNull($userAccount); + self::assertSame($email, $userAccount->email); + self::assertFalse($userAccount->legacyAuth0); + self::assertNull($userAccount->emailVerifiedAt); + + $player = $browser->getContainer()->get(PlayerRepository::class)->findByUserId($userId); + self::assertNotNull($player); + self::assertSame($email, $player->email); + + // The verification mail goes out, and carries the window-A sign-in-link rescue + $messages = self::getMailerMessages(); + self::assertCount(1, $messages); + self::assertInstanceOf(Email::class, $messages[0]); + + $body = (string) $messages[0]->getHtmlBody(); + self::assertStringContainsString('/verify-email?token=', $body); + self::assertStringContainsString('/login-link', $body); + } + + public function testWelcomeScreenCarriesTheSignInLinkRescue(): void + { + $browser = $this->createClientWithNativeRegistration(true); + $email = $this->randomEmail('register.welcome'); + + $this->submitRegistration($browser, $email, 'a-properly-long-passphrase'); + $crawler = $browser->followRedirect(); + + self::assertResponseIsSuccessful(); + // While /login is still the Auth0 redirect, this link is the only way back in + // for somebody who gets signed out (implementation-plan §2c) + self::assertGreaterThan(0, $crawler->filter('a[href^="/login-link"]')->count()); + } + + public function testAddressAlreadyOnAUserAccountIsRefused(): void + { + $browser = $this->createClientWithNativeRegistration(true); + $email = $this->randomEmail('register.taken'); + + $entityManager = $browser->getContainer()->get(EntityManagerInterface::class); + $entityManager->persist( + new UserAccount(Uuid::uuid7(), 'msp|' . bin2hex(random_bytes(4)), $email, new DateTimeImmutable()), + ); + $entityManager->flush(); + + $crawler = $this->submitRegistration($browser, strtoupper($email), 'a-properly-long-passphrase'); + + self::assertResponseIsSuccessful(); + self::assertStringContainsString('already has an account', $crawler->filter('form')->text()); + } + + /** + * The window-A collision (implementation-plan §2c): a legacy Auth0 user has a + * player row but no user_account yet. Letting them register a second account on + * the same address would make the Stage B import skip their Auth0 identity and + * strand their profile and every solving time on it. + */ + public function testAddressBelongingToALegacyPlayerWithoutAnAccountIsRefused(): void + { + $browser = $this->createClientWithNativeRegistration(true); + $email = $this->randomEmail('register.legacy'); + + $entityManager = $browser->getContainer()->get(EntityManagerInterface::class); + $entityManager->persist( + new Player( + Uuid::uuid7(), + 'RGST' . bin2hex(random_bytes(2)), + 'auth0|' . bin2hex(random_bytes(4)), + // Stored with different casing than the visitor types it + strtoupper($email), + null, + new DateTimeImmutable(), + ), + ); + $entityManager->flush(); + + $crawler = $this->submitRegistration($browser, $email, 'a-properly-long-passphrase'); + + self::assertResponseIsSuccessful(); + self::assertStringContainsString('already has an account', $crawler->filter('form')->text()); + self::assertNull($browser->getContainer()->get(TokenStorageInterface::class)->getToken()); + } + + public function testWeakPasswordIsRefusedBeforeAnythingIsCreated(): void + { + $browser = $this->createClientWithNativeRegistration(true); + $email = $this->randomEmail('register.weak'); + + $this->submitRegistration($browser, $email, 'short'); + + self::assertResponseIsSuccessful(); + self::assertNull($browser->getContainer()->get(UserAccountRepository::class)->findByEmail($email)); + } + + public function testRegistrationPageStartsNoSessionAndStaysOutOfSharedCaches(): void + { + $browser = $this->createClientWithNativeRegistration(true); + + $browser->request('GET', '/register'); + + // #164: an anonymous GET may not start a session or set a cookie. The form's + // CSRF token id ('submit') is in stateless_token_ids, so rendering it does not + // reach for the session + self::assertSame([], $browser->getResponse()->headers->getCookies()); + + $cacheControl = (string) $browser->getResponse()->headers->get('Cache-Control'); + self::assertStringContainsString('no-store', $cacheControl); + self::assertStringNotContainsString('public', $cacheControl); + } + + public function testPageIsRenderedInTheBrowserLanguage(): void + { + $browser = $this->createClientWithNativeRegistration(true); + + $crawler = $browser->request('GET', '/register', server: ['HTTP_ACCEPT_LANGUAGE' => 'de-DE,de;q=0.9']); + + self::assertResponseIsSuccessful(); + self::assertSame('de', $crawler->filter('html')->attr('lang')); + } + + private function submitRegistration(KernelBrowser $browser, string $email, string $password): Crawler + { + $crawler = $browser->request('GET', '/register'); + $form = $crawler->selectButton('Create account')->form(); + + return $browser->submit($form, [ + $form->getName() . '[email]' => $email, + $form->getName() . '[plainPassword]' => $password, + ]); + } + + private function createClientWithNativeRegistration(bool $enabled): KernelBrowser + { + // The flag is a runtime env placeholder, so a kernel booted after this sees it + $_ENV['NATIVE_REGISTRATION_ENABLED'] = $enabled ? '1' : '0'; + $_SERVER['NATIVE_REGISTRATION_ENABLED'] = $_ENV['NATIVE_REGISTRATION_ENABLED']; + + return self::createClient(); + } + + private function randomEmail(string $prefix): string + { + return sprintf('%s+%s@example.com', $prefix, bin2hex(random_bytes(4))); + } +} From 2396fe0bd8e20e8f1793960e9ecc5939b4a891e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Mike=C5=A1?= Date: Sat, 25 Jul 2026 01:04:15 +0200 Subject: [PATCH 5/9] Test the 2c-II handlers, and fix what testing them found (issue #147) 24 handler tests across registration, verification-link sending, password reset mailing, change-password and change-email, plus fixes for three defects they surfaced. Fixes: - ChangeAccountEmailHandler asked "fetch a player on this address and compare its user_id to mine". player.email is not unique - production carries 7 known duplicate pairs - and the lookup is an unordered LIMIT 1, so when the target address sat on both a stale row and the caller's own, the change was allowed or refused depending on which row Postgres returned. Replaced with PlayerRepository::emailBelongsToAnotherPlayer(), which asks the question directly. Covered by testAddressSharedWithAStaleDuplicateOfTheCallerIsStillRefused. - The verification email promised "valid for 24 hours" from a hardcoded literal while the token's real lifetime lived in a separate constant. Folded into EmailVerificationTokenSigner::LIFETIME_HOURS. - Two concurrent registrations on one address both pass the advisory checks and one loses at the unique index. That is the same situation for the user as a plain collision, so it now gets the same message instead of a generic failure and a Sentry alert for a benign race. Test-infrastructure fix, and it is mine: adding native_login_enabled / native_registration_enabled as Twig globals made them resolve on nearly every rendered page, which turned LoginControllerTest's existing `unset($_ENV['NATIVE_LOGIN_ENABLED'])` teardown into 355 suite-wide EnvNotFoundException errors. Both flag tests now restore the original value through the OverridesFeatureFlagEnv trait, and the registration tests vary REMOTE_ADDR so the per-IP limiter's cache (not rolled back between runs) cannot starve them. Full suite: 1363 tests green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017o4pbdfkhHDB4vdJPNY3tc --- .../auth-migration/implementation-plan.md | 1 + src/Controller/RegisterController.php | 9 +- .../ChangeAccountEmailHandler.php | 9 +- .../SendEmailVerificationLinkHandler.php | 2 +- src/Repository/PlayerRepository.php | 25 +++ src/Services/EmailVerificationTokenSigner.php | 5 +- tests/Controller/LoginControllerTest.php | 8 +- tests/Controller/RegisterControllerTest.php | 13 +- .../ChangeAccountEmailHandlerTest.php | 211 ++++++++++++++++++ .../ChangeAccountPasswordHandlerTest.php | 152 +++++++++++++ .../RegisterUserHandlerTest.php | 164 ++++++++++++++ .../SendEmailVerificationLinkHandlerTest.php | 201 +++++++++++++++++ .../SendPasswordResetLinkHandlerTest.php | 142 ++++++++++++ tests/OverridesFeatureFlagEnv.php | 48 ++++ 14 files changed, 977 insertions(+), 13 deletions(-) create mode 100644 tests/MessageHandler/ChangeAccountEmailHandlerTest.php create mode 100644 tests/MessageHandler/ChangeAccountPasswordHandlerTest.php create mode 100644 tests/MessageHandler/RegisterUserHandlerTest.php create mode 100644 tests/MessageHandler/SendEmailVerificationLinkHandlerTest.php create mode 100644 tests/MessageHandler/SendPasswordResetLinkHandlerTest.php create mode 100644 tests/OverridesFeatureFlagEnv.php diff --git a/docs/features/auth-migration/implementation-plan.md b/docs/features/auth-migration/implementation-plan.md index dc4cf6a1..2ddd2444 100644 --- a/docs/features/auth-migration/implementation-plan.md +++ b/docs/features/auth-migration/implementation-plan.md @@ -90,6 +90,7 @@ Sequencing matters: signups are frozen at Stage A, so a single export generated ### 2d. Code sweep - [ ] `RetrieveLoggedUserProfile`: handle `UserAccount` (window A: both classes); JIT `RegisterUserToPlay` becomes dead code for native users but stays as safety net until Phase 6. + - **Blocks part of 2c-II — do this first in the slice.** `getProfile()` still tests `instanceof Auth0\Symfony\Models\User` only, so a native `UserAccount` session gets `logged_user.profile === null`. Consequences already built and waiting on it: `EditProfileController` bounces such a session to `my_profile` (its `$player === null` guard), which makes the native change-password and change-email cards shipped in 2c-II **unreachable** until this lands; and every page a native account sees renders the anonymous navbar. Nothing is user-visible yet — both flags are OFF and Stage A ships after 2d — but no native session is really usable before this is fixed. - [ ] `EventSubscriber/OAuth2AuthorizationSubscriber.php`: replace Auth0-class branches with `UserAccount` (window A: accept both). - [ ] Sweep `Auth0\Symfony\Models\User` references — **50 files as of 2026-07-23** (was 46 on 07-11; #161 added `RequestPasswordChangeController` a.o.): `#[CurrentUser]` hints → `UserAccount`; 25 `UserInterface` hints untouched. - [ ] Tests: rewrite `tests/TestingLogin.php` + `src/Controller/Test/TestLoginController.php` + `tests/Panther/AbstractPantherTestCase.php` to fabricate `UserAccount`; fixtures keep `auth0|regular001` ids but gain `UserAccount` rows; add `msp|`-style native fixtures. diff --git a/src/Controller/RegisterController.php b/src/Controller/RegisterController.php index 7d3d4d62..eb51543f 100644 --- a/src/Controller/RegisterController.php +++ b/src/Controller/RegisterController.php @@ -4,6 +4,7 @@ namespace SpeedPuzzling\Web\Controller; +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Psr\Log\LoggerInterface; use SpeedPuzzling\Web\EventSubscriber\NativeAuthPageSubscriber; use SpeedPuzzling\Web\Exceptions\EmailAlreadyRegistered; @@ -86,7 +87,13 @@ public function __invoke(Request $request): Response ), ); } catch (HandlerFailedException $exception) { - if ($exception->getPrevious() instanceof EmailAlreadyRegistered) { + // The handler's checks are advisory - two registrations racing on the + // same address get past both and one loses at the unique index. Same + // situation for the user, so it deserves the same message rather than + // a "something went wrong" and a Sentry alert for a benign race. + $reason = $exception->getPrevious(); + + if ($reason instanceof EmailAlreadyRegistered || $reason instanceof UniqueConstraintViolationException) { // D8: the unique-email error is an accepted enumeration tradeoff. // The copy points at signing in rather than only saying "taken" - // through window A the collision is usually the user's own older diff --git a/src/MessageHandler/ChangeAccountEmailHandler.php b/src/MessageHandler/ChangeAccountEmailHandler.php index 1fd01ee4..33fc8508 100644 --- a/src/MessageHandler/ChangeAccountEmailHandler.php +++ b/src/MessageHandler/ChangeAccountEmailHandler.php @@ -70,10 +70,11 @@ public function __invoke(ChangeAccountEmail $message): void // Same reasoning as registration: an address that already reaches a player - // including a legacy Auth0 one with no user_account row yet - must not be - // claimed by a second account, or the Stage B import would strand it - $playerOnNewEmail = $this->playerRepository->findByEmail($newEmail); - - if ($playerOnNewEmail !== null && $playerOnNewEmail->userId !== $userAccount->userId) { + // claimed by a second account, or the Stage B import would strand it. + // Asked as "any player but me" rather than "fetch one and compare": player.email + // is not unique, so comparing a single arbitrary row would answer differently + // from run to run when the address sits on a duplicate pair. + if ($this->playerRepository->emailBelongsToAnotherPlayer($newEmail, $userAccount->userId)) { throw new EmailAlreadyRegistered(); } diff --git a/src/MessageHandler/SendEmailVerificationLinkHandler.php b/src/MessageHandler/SendEmailVerificationLinkHandler.php index d249d13d..7edebb9c 100644 --- a/src/MessageHandler/SendEmailVerificationLinkHandler.php +++ b/src/MessageHandler/SendEmailVerificationLinkHandler.php @@ -79,7 +79,7 @@ public function __invoke(SendEmailVerificationLink $message): void // which has no identity for them and which we cannot brand or link from. // The sign-in link is their way back in (implementation-plan §2c). 'showSignInLinkRescue' => $userAccount->legacyAuth0 === false, - 'expiresInHours' => 24, + 'expiresInHours' => EmailVerificationTokenSigner::LIFETIME_HOURS, ]); $email->getHeaders()->addTextHeader('X-Transport', 'transactional'); diff --git a/src/Repository/PlayerRepository.php b/src/Repository/PlayerRepository.php index aa3a0809..011ff33d 100644 --- a/src/Repository/PlayerRepository.php +++ b/src/Repository/PlayerRepository.php @@ -90,6 +90,7 @@ public function findByUserId(string $userId): null|Player * player.email is NOT unique - production carries 7 known duplicate-email pairs * (deleted-and-re-registered Auth0 accounts, README §Current state) - so this * answers "is this address taken at all", never "which row is the right one". + * When the answer has to exclude the asker, use emailBelongsToAnotherPlayer(). */ public function findByEmail(string $email): null|Player { @@ -108,6 +109,30 @@ public function findByEmail(string $email): null|Player return $player; } + /** + * "Is this address held by any player other than me?" - the question the + * change-email flow actually has to ask. Doing it as findByEmail() plus an + * ownership comparison would be non-deterministic: player.email is not unique + * (7 known duplicate pairs) and an unordered LIMIT 1 could return either row, + * so an address the caller shares with a stale duplicate would be accepted or + * refused depending on what Postgres felt like returning. + */ + public function emailBelongsToAnotherPlayer(string $email, string $exceptUserId): bool + { + $queryBuilder = $this->entityManager->createQueryBuilder(); + + $count = $queryBuilder->select('COUNT(player.id)') + ->from(Player::class, 'player') + ->where('LOWER(player.email) = :email') + ->andWhere('player.userId IS NULL OR player.userId != :userId') + ->setParameter('email', mb_strtolower(trim($email))) + ->setParameter('userId', $exceptUserId) + ->getQuery() + ->getSingleScalarResult(); + + return (int) $count > 0; + } + /** * @throws PlayerNotFound */ diff --git a/src/Services/EmailVerificationTokenSigner.php b/src/Services/EmailVerificationTokenSigner.php index bc6b4cfa..bb95f9a6 100644 --- a/src/Services/EmailVerificationTokenSigner.php +++ b/src/Services/EmailVerificationTokenSigner.php @@ -20,7 +20,10 @@ */ readonly final class EmailVerificationTokenSigner { - public const string LIFETIME = '+24 hours'; + public const int LIFETIME_HOURS = 24; + + /** Single source for the expiry the token carries and the expiry the email promises */ + public const string LIFETIME = '+' . self::LIFETIME_HOURS . ' hours'; public function __construct( #[Autowire(param: 'kernel.secret')] diff --git a/tests/Controller/LoginControllerTest.php b/tests/Controller/LoginControllerTest.php index fefd97dc..2c9c942b 100644 --- a/tests/Controller/LoginControllerTest.php +++ b/tests/Controller/LoginControllerTest.php @@ -8,6 +8,7 @@ use Doctrine\ORM\EntityManagerInterface; use Ramsey\Uuid\Uuid; use SpeedPuzzling\Web\Entity\UserAccount; +use SpeedPuzzling\Web\Tests\OverridesFeatureFlagEnv; use Symfony\Bundle\FrameworkBundle\KernelBrowser; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; @@ -19,9 +20,11 @@ */ final class LoginControllerTest extends WebTestCase { + use OverridesFeatureFlagEnv; + protected function tearDown(): void { - unset($_ENV['NATIVE_LOGIN_ENABLED'], $_SERVER['NATIVE_LOGIN_ENABLED']); + $this->restoreFeatureFlagEnv(); parent::tearDown(); } @@ -114,8 +117,7 @@ public function testFailedAttemptComesBackWithTheHelperAndThePrefilledAddress(): private function createClientWithNativeLogin(bool $enabled): KernelBrowser { // The flag is a runtime env placeholder, so a kernel booted after this sees it - $_ENV['NATIVE_LOGIN_ENABLED'] = $enabled ? '1' : '0'; - $_SERVER['NATIVE_LOGIN_ENABLED'] = $_ENV['NATIVE_LOGIN_ENABLED']; + $this->overrideFeatureFlagEnv('NATIVE_LOGIN_ENABLED', $enabled); return self::createClient(); } diff --git a/tests/Controller/RegisterControllerTest.php b/tests/Controller/RegisterControllerTest.php index 38c6285f..3f120fd3 100644 --- a/tests/Controller/RegisterControllerTest.php +++ b/tests/Controller/RegisterControllerTest.php @@ -11,6 +11,7 @@ use SpeedPuzzling\Web\Entity\UserAccount; use SpeedPuzzling\Web\Repository\PlayerRepository; use SpeedPuzzling\Web\Repository\UserAccountRepository; +use SpeedPuzzling\Web\Tests\OverridesFeatureFlagEnv; use Symfony\Bundle\FrameworkBundle\KernelBrowser; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Component\DomCrawler\Crawler; @@ -27,9 +28,11 @@ */ final class RegisterControllerTest extends WebTestCase { + use OverridesFeatureFlagEnv; + protected function tearDown(): void { - unset($_ENV['NATIVE_REGISTRATION_ENABLED'], $_SERVER['NATIVE_REGISTRATION_ENABLED']); + $this->restoreFeatureFlagEnv(); parent::tearDown(); } @@ -183,6 +186,11 @@ public function testPageIsRenderedInTheBrowserLanguage(): void private function submitRegistration(KernelBrowser $browser, string $email, string $password): Crawler { + // A fresh client IP per submit: registration is throttled per IP and the + // limiter's cache is not rolled back between tests or runs (DAMA only wraps + // the database), so a shared 127.0.0.1 would starve later tests + $browser->setServerParameter('REMOTE_ADDR', sprintf('198.51.100.%d', random_int(1, 254))); + $crawler = $browser->request('GET', '/register'); $form = $crawler->selectButton('Create account')->form(); @@ -195,8 +203,7 @@ private function submitRegistration(KernelBrowser $browser, string $email, strin private function createClientWithNativeRegistration(bool $enabled): KernelBrowser { // The flag is a runtime env placeholder, so a kernel booted after this sees it - $_ENV['NATIVE_REGISTRATION_ENABLED'] = $enabled ? '1' : '0'; - $_SERVER['NATIVE_REGISTRATION_ENABLED'] = $_ENV['NATIVE_REGISTRATION_ENABLED']; + $this->overrideFeatureFlagEnv('NATIVE_REGISTRATION_ENABLED', $enabled); return self::createClient(); } diff --git a/tests/MessageHandler/ChangeAccountEmailHandlerTest.php b/tests/MessageHandler/ChangeAccountEmailHandlerTest.php new file mode 100644 index 00000000..27095754 --- /dev/null +++ b/tests/MessageHandler/ChangeAccountEmailHandlerTest.php @@ -0,0 +1,211 @@ +messageBus = $container->get(MessageBusInterface::class); + $this->userAccountRepository = $container->get(UserAccountRepository::class); + $this->playerRepository = $container->get(PlayerRepository::class); + $this->passwordHasher = $container->get(UserPasswordHasherInterface::class); + $this->entityManager = $container->get(EntityManagerInterface::class); + } + + public function testChangesEmailEverywhereAndResetsVerification(): void + { + $userAccount = $this->createUserAccountWithPassword('msp|chmail1', 'chmail.one@example.com', 'passphrase-1'); + $userAccount->markEmailVerified(new DateTimeImmutable()); + $this->createPlayer('msp|chmail1', 'chmail1', 'chmail.one@example.com'); + $this->entityManager->flush(); + + $this->messageBus->dispatch( + new ChangeAccountEmail('msp|chmail1', ' Chmail.One.NEW@Example.COM ', 'passphrase-1'), + ); + + $userAccount = $this->userAccountRepository->findByUserId('msp|chmail1'); + self::assertNotNull($userAccount); + self::assertSame('chmail.one.new@example.com', $userAccount->email); + // The new address is unproven until its own link is clicked + self::assertNull($userAccount->emailVerifiedAt); + + // The player's copy is what notification mail is addressed from - it must not drift + $player = $this->playerRepository->findByUserId('msp|chmail1'); + self::assertNotNull($player); + self::assertSame('chmail.one.new@example.com', $player->email); + } + + public function testWrongCurrentPasswordChangesNothing(): void + { + $userAccount = $this->createUserAccountWithPassword('msp|chmail2', 'chmail.two@example.com', 'passphrase-2'); + $userAccount->markEmailVerified(new DateTimeImmutable()); + $this->createPlayer('msp|chmail2', 'chmail2', 'chmail.two@example.com'); + $this->entityManager->flush(); + + $this->expectHandlerException( + new ChangeAccountEmail('msp|chmail2', 'chmail.two.new@example.com', 'not-the-passphrase'), + CurrentPasswordDoesNotMatch::class, + ); + + $userAccount = $this->userAccountRepository->findByUserId('msp|chmail2'); + self::assertNotNull($userAccount); + self::assertSame('chmail.two@example.com', $userAccount->email); + self::assertNotNull($userAccount->emailVerifiedAt); + + $player = $this->playerRepository->findByUserId('msp|chmail2'); + self::assertNotNull($player); + self::assertSame('chmail.two@example.com', $player->email); + } + + public function testAddressAlreadyOnAnotherAccountIsRejected(): void + { + $this->createUserAccountWithPassword('msp|chmail3', 'chmail.three@example.com', 'passphrase-3'); + $this->createUserAccount('msp|chmail3-other', 'chmail.taken@example.com'); + + $this->expectHandlerException( + new ChangeAccountEmail('msp|chmail3', 'Chmail.TAKEN@example.com', 'passphrase-3'), + EmailAlreadyRegistered::class, + ); + + $userAccount = $this->userAccountRepository->findByUserId('msp|chmail3'); + self::assertNotNull($userAccount); + self::assertSame('chmail.three@example.com', $userAccount->email); + } + + public function testAddressOfLegacyPlayerWithoutAccountIsRejected(): void + { + $this->createUserAccountWithPassword('msp|chmail4', 'chmail.four@example.com', 'passphrase-4'); + // Window A: an Auth0 identity not imported yet - player row, no user_account + $this->createPlayer('auth0|chmail4-legacy', 'chmail4legacy', 'Chmail.Legacy@Example.com'); + $this->entityManager->flush(); + + self::assertNull($this->userAccountRepository->findByEmail('chmail.legacy@example.com')); + + $this->expectHandlerException( + new ChangeAccountEmail('msp|chmail4', 'chmail.legacy@example.com', 'passphrase-4'), + EmailAlreadyRegistered::class, + ); + + $userAccount = $this->userAccountRepository->findByUserId('msp|chmail4'); + self::assertNotNull($userAccount); + self::assertSame('chmail.four@example.com', $userAccount->email); + } + + /** + * player.email is not unique - production carries 7 known duplicate pairs. When + * the address the caller is moving to sits on BOTH a stale row and their own, the + * answer must not depend on which row the database happens to return first. + */ + public function testAddressSharedWithAStaleDuplicateOfTheCallerIsStillRefused(): void + { + $this->createUserAccountWithPassword('msp|chmail6', 'chmail.six@example.com', 'passphrase-6'); + // The caller's own player row already carries the target address... + $this->createPlayer('msp|chmail6', 'chmail6', 'chmail.shared@example.com'); + // ...and so does a stale row left behind by a deleted-and-re-registered account + $this->createPlayer('auth0|chmail6-stale', 'chmail6stale', 'Chmail.Shared@Example.com'); + $this->entityManager->flush(); + + $this->expectHandlerException( + new ChangeAccountEmail('msp|chmail6', 'chmail.shared@example.com', 'passphrase-6'), + EmailAlreadyRegistered::class, + ); + + $userAccount = $this->userAccountRepository->findByUserId('msp|chmail6'); + self::assertNotNull($userAccount); + self::assertSame('chmail.six@example.com', $userAccount->email); + } + + public function testChangingToTheSameAddressInDifferentCaseIsANoOp(): void + { + $userAccount = $this->createUserAccountWithPassword('msp|chmail5', 'chmail.five@example.com', 'passphrase-5'); + $userAccount->markEmailVerified(new DateTimeImmutable()); + $this->entityManager->flush(); + + $verifiedAt = $userAccount->emailVerifiedAt; + self::assertNotNull($verifiedAt); + + $this->messageBus->dispatch( + new ChangeAccountEmail('msp|chmail5', ' CHMAIL.Five@Example.com ', 'passphrase-5'), + ); + + $userAccount = $this->userAccountRepository->findByUserId('msp|chmail5'); + self::assertNotNull($userAccount); + self::assertSame('chmail.five@example.com', $userAccount->email); + // Re-typing your own address must not cost you your verified status + self::assertEquals($verifiedAt, $userAccount->emailVerifiedAt); + } + + /** + * @param class-string<\Throwable> $expectedException + */ + private function expectHandlerException(ChangeAccountEmail $message, string $expectedException): void + { + try { + $this->messageBus->dispatch($message); + self::fail(sprintf('Expected %s was not thrown', $expectedException)); + } catch (HandlerFailedException $e) { + self::assertInstanceOf($expectedException, $e->getPrevious()); + } + } + + private function createUserAccount(string $userId, string $email): UserAccount + { + $userAccount = new UserAccount(Uuid::uuid7(), $userId, $email, new DateTimeImmutable()); + + $this->entityManager->persist($userAccount); + $this->entityManager->flush(); + + return $userAccount; + } + + private function createUserAccountWithPassword(string $userId, string $email, string $plainPassword): UserAccount + { + $userAccount = $this->createUserAccount($userId, $email); + $userAccount->changePassword($this->passwordHasher->hashPassword($userAccount, $plainPassword)); + $this->entityManager->flush(); + + return $userAccount; + } + + private function createPlayer(string $userId, string $code, null|string $email): Player + { + $player = new Player( + Uuid::uuid7(), + $code, + $userId, + $email, + null, + new DateTimeImmutable(), + ); + + $this->entityManager->persist($player); + + return $player; + } +} diff --git a/tests/MessageHandler/ChangeAccountPasswordHandlerTest.php b/tests/MessageHandler/ChangeAccountPasswordHandlerTest.php new file mode 100644 index 00000000..0a1310b4 --- /dev/null +++ b/tests/MessageHandler/ChangeAccountPasswordHandlerTest.php @@ -0,0 +1,152 @@ +messageBus = $container->get(MessageBusInterface::class); + $this->userAccountRepository = $container->get(UserAccountRepository::class); + $this->resetPasswordRequestRepository = $container->get(ResetPasswordRequestRepository::class); + $this->passwordHasher = $container->get(UserPasswordHasherInterface::class); + $this->entityManager = $container->get(EntityManagerInterface::class); + } + + public function testChangesThePasswordToTheNewOne(): void + { + $this->createUserAccountWithPassword('msp|chpass1', 'chpass.one@example.com', 'old-passphrase-1'); + + $this->messageBus->dispatch(new ChangeAccountPassword('msp|chpass1', 'old-passphrase-1', 'new-passphrase-1')); + + $userAccount = $this->userAccountRepository->findByUserId('msp|chpass1'); + self::assertNotNull($userAccount); + self::assertTrue($this->passwordHasher->isPasswordValid($userAccount, 'new-passphrase-1')); + self::assertFalse($this->passwordHasher->isPasswordValid($userAccount, 'old-passphrase-1')); + } + + public function testLegacyBcryptCurrentPasswordIsAcceptedAndRehashedToArgon2id(): void + { + // An imported Auth0 account still carries the bcrypt hash Auth0 exported + $userAccount = $this->createUserAccount('msp|chpass2', 'chpass.two@example.com'); + $userAccount->changePassword(password_hash('legacy-bcrypt-passphrase', PASSWORD_BCRYPT)); + $this->entityManager->flush(); + + $this->messageBus->dispatch( + new ChangeAccountPassword('msp|chpass2', 'legacy-bcrypt-passphrase', 'new-passphrase-2'), + ); + + $userAccount = $this->userAccountRepository->findByUserId('msp|chpass2'); + self::assertNotNull($userAccount); + self::assertNotNull($userAccount->password); + self::assertStringStartsWith('$argon2id', $userAccount->password); + self::assertStringStartsNotWith('$2', $userAccount->password); + self::assertTrue($this->passwordHasher->isPasswordValid($userAccount, 'new-passphrase-2')); + } + + public function testWrongCurrentPasswordIsRejectedAndLeavesTheHashUntouched(): void + { + $userAccount = $this->createUserAccountWithPassword('msp|chpass3', 'chpass.three@example.com', 'old-passphrase-3'); + $hashBefore = $userAccount->password; + + $this->expectCurrentPasswordRejected( + new ChangeAccountPassword('msp|chpass3', 'not-the-current-passphrase', 'new-passphrase-3'), + ); + + $userAccount = $this->userAccountRepository->findByUserId('msp|chpass3'); + self::assertNotNull($userAccount); + self::assertSame($hashBefore, $userAccount->password); + self::assertTrue($this->passwordHasher->isPasswordValid($userAccount, 'old-passphrase-3')); + } + + public function testAccountWithoutAnyPasswordIsRejected(): void + { + // Legacy account that has only ever been reached through the sign-in link + $this->createUserAccount('msp|chpass4', 'chpass.four@example.com'); + + $this->expectCurrentPasswordRejected( + new ChangeAccountPassword('msp|chpass4', 'anything-at-all', 'new-passphrase-4'), + ); + + $userAccount = $this->userAccountRepository->findByUserId('msp|chpass4'); + self::assertNotNull($userAccount); + self::assertNull($userAccount->password); + } + + public function testSuccessfulChangeRemovesOutstandingResetRequests(): void + { + $userAccount = $this->createUserAccountWithPassword('msp|chpass5', 'chpass.five@example.com', 'old-passphrase-5'); + + $token = PasswordResetToken::generate(); + $requestedAt = new DateTimeImmutable(); + $this->resetPasswordRequestRepository->save(new ResetPasswordRequest( + Uuid::uuid7(), + $userAccount, + $token->selector, + $token->hashedVerifier(), + $requestedAt, + $requestedAt->modify(ResetPasswordRequest::LIFETIME), + )); + $this->entityManager->flush(); + + self::assertNotNull($this->resetPasswordRequestRepository->findBySelector($token->selector)); + + $this->messageBus->dispatch(new ChangeAccountPassword('msp|chpass5', 'old-passphrase-5', 'new-passphrase-5')); + + self::assertNull($this->resetPasswordRequestRepository->findBySelector($token->selector)); + } + + private function expectCurrentPasswordRejected(ChangeAccountPassword $message): void + { + try { + $this->messageBus->dispatch($message); + self::fail('Expected CurrentPasswordDoesNotMatch was not thrown'); + } catch (HandlerFailedException $e) { + self::assertInstanceOf(CurrentPasswordDoesNotMatch::class, $e->getPrevious()); + } + } + + private function createUserAccount(string $userId, string $email): UserAccount + { + $userAccount = new UserAccount(Uuid::uuid7(), $userId, $email, new DateTimeImmutable()); + + $this->entityManager->persist($userAccount); + $this->entityManager->flush(); + + return $userAccount; + } + + private function createUserAccountWithPassword(string $userId, string $email, string $plainPassword): UserAccount + { + $userAccount = $this->createUserAccount($userId, $email); + $userAccount->changePassword($this->passwordHasher->hashPassword($userAccount, $plainPassword)); + $this->entityManager->flush(); + + return $userAccount; + } +} diff --git a/tests/MessageHandler/RegisterUserHandlerTest.php b/tests/MessageHandler/RegisterUserHandlerTest.php new file mode 100644 index 00000000..a9521f5d --- /dev/null +++ b/tests/MessageHandler/RegisterUserHandlerTest.php @@ -0,0 +1,164 @@ +messageBus = $container->get(MessageBusInterface::class); + $this->userAccountRepository = $container->get(UserAccountRepository::class); + $this->playerRepository = $container->get(PlayerRepository::class); + $this->passwordHasher = $container->get(UserPasswordHasherInterface::class); + $this->entityManager = $container->get(EntityManagerInterface::class); + } + + public function testRegistrationCreatesAccountAndPlayerTogether(): void + { + $userId = $this->register(' Register.One@EXAMPLE.com ', 'a-strong-passphrase-1', 'cs'); + + self::assertStringStartsWith('msp|', $userId); + + $userAccount = $this->userAccountRepository->findByUserId($userId); + self::assertNotNull($userAccount); + self::assertSame('register.one@example.com', $userAccount->email); + self::assertFalse($userAccount->legacyAuth0); + self::assertNull($userAccount->emailVerifiedAt); + + // Never the plaintext, and the stored hash must actually verify against it + self::assertNotNull($userAccount->password); + self::assertNotSame('a-strong-passphrase-1', $userAccount->password); + self::assertTrue($this->passwordHasher->isPasswordValid($userAccount, 'a-strong-passphrase-1')); + + $player = $this->playerRepository->findByUserId($userId); + self::assertNotNull($player); + self::assertSame($userId, $player->userId); + self::assertSame('register.one@example.com', $player->email); + self::assertNotSame('', $player->code); + self::assertSame('cs', $player->locale); + } + + public function testEmailAlreadyOnAnAccountIsRejectedRegardlessOfCase(): void + { + $this->createUserAccount('msp|register2', 'register.two@example.com'); + + $this->expectRegistrationRejected(' Register.TWO@Example.COM '); + + // The colliding registration must not have left anything behind + self::assertSame(1, $this->countAccountsWithEmail('register.two@example.com')); + } + + public function testEmailOfLegacyPlayerWithoutAccountIsRejected(): void + { + // Window A: an Auth0 user before the Stage B import has a player row but no + // user_account. Registering natively on their address would strand their profile. + $this->createPlayer('auth0|register3', 'reg-legacy-3', 'Legacy.Three@Example.com', locale: null); + + self::assertNull($this->userAccountRepository->findByEmail('legacy.three@example.com')); + + $this->expectRegistrationRejected('legacy.three@example.com'); + + self::assertSame(0, $this->countAccountsWithEmail('legacy.three@example.com')); + } + + public function testRegistrationWithoutLocaleLeavesPlayerLocaleNull(): void + { + $userId = $this->register('register.four@example.com', 'a-strong-passphrase-4', null); + + $player = $this->playerRepository->findByUserId($userId); + self::assertNotNull($player); + self::assertNull($player->locale); + } + + private function register(string $email, string $plainPassword, null|string $locale): string + { + $envelope = $this->messageBus->dispatch(new RegisterUser($email, $plainPassword, $locale)); + + $handledStamp = $envelope->last(HandledStamp::class); + self::assertNotNull($handledStamp); + + $userId = $handledStamp->getResult(); + self::assertIsString($userId); + + return $userId; + } + + private function expectRegistrationRejected(string $email): void + { + try { + $this->messageBus->dispatch(new RegisterUser($email, 'a-strong-passphrase', null)); + self::fail('Expected EmailAlreadyRegistered was not thrown'); + } catch (HandlerFailedException $e) { + self::assertInstanceOf(EmailAlreadyRegistered::class, $e->getPrevious()); + } + } + + private function createUserAccount(string $userId, string $email): UserAccount + { + $userAccount = new UserAccount(Uuid::uuid7(), $userId, $email, new DateTimeImmutable()); + + $this->entityManager->persist($userAccount); + $this->entityManager->flush(); + + return $userAccount; + } + + private function createPlayer(string $userId, string $code, null|string $email, null|string $locale): Player + { + $player = new Player( + Uuid::uuid7(), + $code, + $userId, + $email, + null, + new DateTimeImmutable(), + ); + + if ($locale !== null) { + $player->changeLocale($locale); + } + + $this->entityManager->persist($player); + $this->entityManager->flush(); + + return $player; + } + + private function countAccountsWithEmail(string $email): int + { + $count = $this->entityManager->createQueryBuilder() + ->select('COUNT(user_account.id)') + ->from(UserAccount::class, 'user_account') + ->where('user_account.email = :email') + ->setParameter('email', $email) + ->getQuery() + ->getSingleScalarResult(); + + return (int) $count; + } +} diff --git a/tests/MessageHandler/SendEmailVerificationLinkHandlerTest.php b/tests/MessageHandler/SendEmailVerificationLinkHandlerTest.php new file mode 100644 index 00000000..1878696b --- /dev/null +++ b/tests/MessageHandler/SendEmailVerificationLinkHandlerTest.php @@ -0,0 +1,201 @@ +mailer = new TestMailerSpy(); + $this->messageBus = $container->get(MessageBusInterface::class); + $this->userAccountRepository = $container->get(UserAccountRepository::class); + $this->entityManager = $container->get(EntityManagerInterface::class); + + $this->handler = new SendEmailVerificationLinkHandler( + userAccountRepository: $this->userAccountRepository, + playerRepository: $container->get(PlayerRepository::class), + tokenSigner: $container->get(EmailVerificationTokenSigner::class), + mailer: $this->mailer, + translator: $container->get(TranslatorInterface::class), + urlGenerator: $container->get(UrlGeneratorInterface::class), + clock: new MockClock(), + logger: new NullLogger(), + ); + } + + public function testSendsLinkWhoseTokenActuallyVerifiesTheAccount(): void + { + $this->createUserAccount('msp|sendverify1', 'send.verify.one@example.com'); + + ($this->handler)(new SendEmailVerificationLink('msp|sendverify1', 'en')); + + self::assertCount(1, $this->mailer->sent); + $email = $this->assertTemplatedEmail($this->mailer->sent[0]); + self::assertSame(['send.verify.one@example.com'], $this->toAddresses($email)); + self::assertSame(self::TEMPLATE, $email->getHtmlTemplate()); + + // The two halves must fit: the token this handler mints is the one VerifyEmail accepts + $token = $this->tokenFromVerificationUrl($email); + + $this->messageBus->dispatch(new VerifyEmail($token)); + + $userAccount = $this->userAccountRepository->findByUserId('msp|sendverify1'); + self::assertNotNull($userAccount); + self::assertNotNull($userAccount->emailVerifiedAt); + } + + public function testUnknownUserIdSendsNothingAndDoesNotThrow(): void + { + ($this->handler)(new SendEmailVerificationLink('msp|sendverify-ghost', 'en')); + + self::assertCount(0, $this->mailer->sent); + } + + public function testAlreadyVerifiedAccountSendsNothing(): void + { + $userAccount = $this->createUserAccount('msp|sendverify2', 'send.verify.two@example.com'); + $userAccount->markEmailVerified(new DateTimeImmutable()); + $this->entityManager->flush(); + + ($this->handler)(new SendEmailVerificationLink('msp|sendverify2', 'en')); + + self::assertCount(0, $this->mailer->sent); + } + + public function testPlayerLocaleWinsOverFallbackLocale(): void + { + $this->createUserAccount('msp|sendverify3', 'send.verify.three@example.com'); + $this->createPlayer('msp|sendverify3', 'sendverify3', 'send.verify.three@example.com', locale: 'de'); + + ($this->handler)(new SendEmailVerificationLink('msp|sendverify3', 'en')); + + self::assertCount(1, $this->mailer->sent); + self::assertSame('de', $this->assertTemplatedEmail($this->mailer->sent[0])->getLocale()); + } + + public function testFallbackLocaleUsedWhenPlayerHasNoLocale(): void + { + $this->createUserAccount('msp|sendverify4', 'send.verify.four@example.com'); + $this->createPlayer('msp|sendverify4', 'sendverify4', 'send.verify.four@example.com', locale: null); + + ($this->handler)(new SendEmailVerificationLink('msp|sendverify4', 'cs')); + + self::assertCount(1, $this->mailer->sent); + self::assertSame('cs', $this->assertTemplatedEmail($this->mailer->sent[0])->getLocale()); + } + + public function testSignInLinkRescueIsOfferedOnlyToNativeAccounts(): void + { + $this->createUserAccount('msp|sendverify5', 'send.verify.five@example.com'); + + $importedAccount = $this->createUserAccount('auth0|sendverify6', 'send.verify.six@example.com'); + $importedAccount->applyAuth0Import('send.verify.six@example.com', null, false, new DateTimeImmutable()); + $this->entityManager->flush(); + + ($this->handler)(new SendEmailVerificationLink('msp|sendverify5', 'en')); + ($this->handler)(new SendEmailVerificationLink('auth0|sendverify6', 'en')); + + self::assertCount(2, $this->mailer->sent); + self::assertTrue($this->assertTemplatedEmail($this->mailer->sent[0])->getContext()['showSignInLinkRescue']); + self::assertFalse($this->assertTemplatedEmail($this->mailer->sent[1])->getContext()['showSignInLinkRescue']); + } + + private function tokenFromVerificationUrl(TemplatedEmail $email): string + { + $verificationUrl = $email->getContext()['verificationUrl'] ?? null; + self::assertIsString($verificationUrl); + + $query = parse_url($verificationUrl, PHP_URL_QUERY); + self::assertIsString($query); + + parse_str($query, $parameters); + $token = $parameters['token'] ?? null; + self::assertIsString($token); + self::assertNotSame('', $token); + + return $token; + } + + /** + * @return list + */ + private function toAddresses(TemplatedEmail $email): array + { + return array_values(array_map( + fn(Address $address): string => $address->getAddress(), + $email->getTo(), + )); + } + + private function assertTemplatedEmail(RawMessage $message): TemplatedEmail + { + self::assertInstanceOf(TemplatedEmail::class, $message); + + return $message; + } + + private function createUserAccount(string $userId, string $email): UserAccount + { + $userAccount = new UserAccount(Uuid::uuid7(), $userId, $email, new DateTimeImmutable()); + + $this->entityManager->persist($userAccount); + $this->entityManager->flush(); + + return $userAccount; + } + + private function createPlayer(string $userId, string $code, null|string $email, null|string $locale): Player + { + $player = new Player( + Uuid::uuid7(), + $code, + $userId, + $email, + null, + new DateTimeImmutable(), + ); + + if ($locale !== null) { + $player->changeLocale($locale); + } + + $this->entityManager->persist($player); + $this->entityManager->flush(); + + return $player; + } +} diff --git a/tests/MessageHandler/SendPasswordResetLinkHandlerTest.php b/tests/MessageHandler/SendPasswordResetLinkHandlerTest.php new file mode 100644 index 00000000..8db8666e --- /dev/null +++ b/tests/MessageHandler/SendPasswordResetLinkHandlerTest.php @@ -0,0 +1,142 @@ +mailer = new TestMailerSpy(); + $this->entityManager = $container->get(EntityManagerInterface::class); + + $this->handler = new SendPasswordResetLinkHandler( + userAccountRepository: $container->get(UserAccountRepository::class), + playerRepository: $container->get(PlayerRepository::class), + mailer: $this->mailer, + translator: $container->get(TranslatorInterface::class), + urlGenerator: $container->get(UrlGeneratorInterface::class), + logger: new NullLogger(), + ); + } + + public function testSendsResetLinkCarryingTheToken(): void + { + $this->createUserAccount('msp|sendreset1', 'send.reset.one@example.com'); + $token = PasswordResetToken::generate(); + + ($this->handler)(new SendPasswordResetLink('Send.Reset.One@EXAMPLE.com', $token->toString(), 'en')); + + self::assertCount(1, $this->mailer->sent); + $email = $this->assertTemplatedEmail($this->mailer->sent[0]); + self::assertSame(['send.reset.one@example.com'], $this->toAddresses($email)); + self::assertSame(self::TEMPLATE, $email->getHtmlTemplate()); + + $resetUrl = $email->getContext()['resetUrl'] ?? null; + self::assertIsString($resetUrl); + self::assertStringContainsString($token->toString(), $resetUrl); + } + + public function testUnknownEmailSendsNothingAndDoesNotThrow(): void + { + ($this->handler)(new SendPasswordResetLink( + 'nobody.reset@example.com', + PasswordResetToken::generate()->toString(), + 'en', + )); + + self::assertCount(0, $this->mailer->sent); + } + + public function testPlayerLocaleWinsOverFallbackLocale(): void + { + $this->createUserAccount('msp|sendreset2', 'send.reset.two@example.com'); + $this->createPlayer('msp|sendreset2', 'sendreset2', 'send.reset.two@example.com', locale: 'de'); + + ($this->handler)(new SendPasswordResetLink( + 'send.reset.two@example.com', + PasswordResetToken::generate()->toString(), + 'en', + )); + + self::assertCount(1, $this->mailer->sent); + self::assertSame('de', $this->assertTemplatedEmail($this->mailer->sent[0])->getLocale()); + } + + /** + * @return list + */ + private function toAddresses(TemplatedEmail $email): array + { + return array_values(array_map( + fn(Address $address): string => $address->getAddress(), + $email->getTo(), + )); + } + + private function assertTemplatedEmail(RawMessage $message): TemplatedEmail + { + self::assertInstanceOf(TemplatedEmail::class, $message); + + return $message; + } + + private function createUserAccount(string $userId, string $email): UserAccount + { + $userAccount = new UserAccount(Uuid::uuid7(), $userId, $email, new DateTimeImmutable()); + + $this->entityManager->persist($userAccount); + $this->entityManager->flush(); + + return $userAccount; + } + + private function createPlayer(string $userId, string $code, null|string $email, null|string $locale): Player + { + $player = new Player( + Uuid::uuid7(), + $code, + $userId, + $email, + null, + new DateTimeImmutable(), + ); + + if ($locale !== null) { + $player->changeLocale($locale); + } + + $this->entityManager->persist($player); + $this->entityManager->flush(); + + return $player; + } +} diff --git a/tests/OverridesFeatureFlagEnv.php b/tests/OverridesFeatureFlagEnv.php new file mode 100644 index 00000000..0ffb5b38 --- /dev/null +++ b/tests/OverridesFeatureFlagEnv.php @@ -0,0 +1,48 @@ + original value, or false when it was unset */ + private array $originalFeatureFlagEnv = []; + + private function overrideFeatureFlagEnv(string $name, bool $enabled): void + { + if (!array_key_exists($name, $this->originalFeatureFlagEnv)) { + $original = $_ENV[$name] ?? false; + $this->originalFeatureFlagEnv[$name] = is_string($original) ? $original : false; + } + + $_ENV[$name] = $enabled ? '1' : '0'; + $_SERVER[$name] = $_ENV[$name]; + } + + private function restoreFeatureFlagEnv(): void + { + foreach ($this->originalFeatureFlagEnv as $name => $original) { + if ($original === false) { + unset($_ENV[$name], $_SERVER[$name]); + + continue; + } + + $_ENV[$name] = $original; + $_SERVER[$name] = $original; + } + + $this->originalFeatureFlagEnv = []; + } +} From 76ed80f3ff4070a973e61299c2b87b98166f00c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Mike=C5=A1?= Date: Sat, 25 Jul 2026 01:07:15 +0200 Subject: [PATCH 6/9] Cover the password-reset endpoint's anti-enumeration property (issue #147) The security-critical property of "forgot password" is that it gives nothing away: a known address, an unknown address and a throttled repeat all have to look identical from the outside (D8), even though only one of them sends mail. That was untested until now. Also covers the end-to-end token (the mailed link opens the form), the no-session/no-shared-cache requirement on the anonymous form page (#164), the mangled-link case that motivated relaxing the route requirement, and the Referrer-Policy that keeps the token out of outbound referers. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017o4pbdfkhHDB4vdJPNY3tc --- .../RequestPasswordResetControllerTest.php | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 tests/Controller/RequestPasswordResetControllerTest.php diff --git a/tests/Controller/RequestPasswordResetControllerTest.php b/tests/Controller/RequestPasswordResetControllerTest.php new file mode 100644 index 00000000..e2e26dd4 --- /dev/null +++ b/tests/Controller/RequestPasswordResetControllerTest.php @@ -0,0 +1,157 @@ +seedAccount($browser); + + $this->requestReset($browser, $email); + + self::assertResponseRedirects('/password-reset'); + + $messages = self::getMailerMessages(); + self::assertCount(1, $messages); + self::assertInstanceOf(Email::class, $messages[0]); + + $body = (string) $messages[0]->getHtmlBody(); + self::assertSame( + 1, + preg_match('#/password-reset/([0-9a-f]{64})#', $body, $matches), + 'The reset email must carry a link with a 64-hex token', + ); + + // The token page opens, so the token the mail carries is the one we stored + $browser->request('GET', '/password-reset/' . $matches[1]); + self::assertResponseIsSuccessful(); + self::assertStringNotContainsString('does not work', (string) $browser->getResponse()->getContent()); + } + + public function testUnknownAddressLooksExactlyLikeAKnownOne(): void + { + $browser = self::createClient(); + $known = $this->seedAccount($browser); + + $this->requestReset($browser, $known); + $knownResponse = $browser->followRedirect()->filter('main')->text(); + + $this->requestReset($browser, sprintf('nobody+%s@example.com', bin2hex(random_bytes(4)))); + $unknownResponse = $browser->followRedirect()->filter('main')->text(); + + self::assertSame($knownResponse, $unknownResponse); + } + + public function testUnknownAddressSendsNoMail(): void + { + $browser = self::createClient(); + + $this->requestReset($browser, sprintf('nobody+%s@example.com', bin2hex(random_bytes(4)))); + + self::assertResponseRedirects('/password-reset'); + self::assertCount(0, self::getMailerMessages()); + } + + /** + * A second request while one is still live is silently refused by the handler. + * The page must not say so - "we already sent you one" is a positive answer to + * "does this address have an account". + */ + public function testThrottledRepeatLooksLikeSuccessAndSendsNothingExtra(): void + { + $browser = self::createClient(); + $email = $this->seedAccount($browser); + + // Asserted before following the redirect: getMailerMessages() reads the + // profile of the LAST request, so the redirect target would report zero + $this->requestReset($browser, $email); + self::assertCount(1, self::getMailerMessages()); + $firstResponse = $browser->followRedirect()->filter('main')->text(); + + $this->requestReset($browser, $email); + self::assertCount(0, self::getMailerMessages()); + $secondResponse = $browser->followRedirect()->filter('main')->text(); + + self::assertSame($firstResponse, $secondResponse); + } + + public function testTheFormPageStartsNoSessionAndStaysOutOfSharedCaches(): void + { + $browser = self::createClient(); + + $browser->request('GET', '/password-reset'); + + // #164: rendering the CSRF token here must not reach for the session, which + // is why 'request_password_reset' is in stateless_token_ids + self::assertSame([], $browser->getResponse()->headers->getCookies()); + + $cacheControl = (string) $browser->getResponse()->headers->get('Cache-Control'); + self::assertStringContainsString('no-store', $cacheControl); + self::assertStringNotContainsString('public', $cacheControl); + } + + public function testAMangledLinkGetsTheFriendlyPageRatherThanA404(): void + { + $browser = self::createClient(); + + // A mail client wrapped the URL and cut the token in half + $browser->request('GET', '/password-reset/' . str_repeat('a', 30)); + + self::assertResponseIsSuccessful(); + self::assertStringContainsString('does not work', (string) $browser->getResponse()->getContent()); + } + + public function testTheTokenPageDoesNotLeakTheTokenThroughTheReferer(): void + { + $browser = self::createClient(); + + $browser->request('GET', '/password-reset/' . str_repeat('a', 64)); + + self::assertSame('no-referrer', $browser->getResponse()->headers->get('Referrer-Policy')); + } + + private function requestReset(KernelBrowser $browser, string $email): void + { + // Fresh client IP per request: the per-IP limiter's cache outlives the test + $browser->setServerParameter('REMOTE_ADDR', sprintf('203.0.113.%d', random_int(1, 254))); + + $crawler = $browser->request('GET', '/password-reset'); + $form = $crawler->selectButton('Email me a reset link')->form(); + + $browser->submit($form, ['email' => $email]); + } + + private function seedAccount(KernelBrowser $browser): string + { + $email = sprintf('reset.page+%s@example.com', bin2hex(random_bytes(4))); + $userAccount = new UserAccount(Uuid::uuid7(), 'msp|' . bin2hex(random_bytes(4)), $email, new DateTimeImmutable()); + $userAccount->changePassword(password_hash('the-real-password', PASSWORD_ARGON2ID)); + + $entityManager = $browser->getContainer()->get(EntityManagerInterface::class); + $entityManager->persist($userAccount); + $entityManager->flush(); + + return $email; + } +} From 57f450478a927c91f825b6ac528db710a8f3abe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Mike=C5=A1?= Date: Sat, 25 Jul 2026 01:21:55 +0200 Subject: [PATCH 7/9] Cover the remaining 2c-II endpoints at the HTTP layer (issue #147) An independent review pass found no security defect in the slice but did find a coverage gap: the handlers behind change-password, change-email and email verification were well tested, while the controllers in front of them were not - so a regression in an #[IsGranted] attribute, a window-A instanceof guard or the CSRF wiring would have gone unnoticed. - ChangeAccountCredentialsControllerTest: both pages closed to anonymous visitors, the current-password gate enforced through the form (and the stored hash / address left untouched when it fails), the new address starting unverified with its link going to the NEW inbox, and a legacy Auth0 session redirected rather than crashing on the #[CurrentUser] type. - VerifyEmailControllerTest: valid link confirms and grants no session, replay inside the lifetime still reads as success without moving the timestamp (D18), forged/missing/expired tokens each land on their own message, a link dies when the address it was issued for changes, and the page sets no cookie (#164). - The reset round trip end to end: the mailed link sets a new password, does not sign the browser in, and cannot be opened twice. Full suite: 1385 green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017o4pbdfkhHDB4vdJPNY3tc --- ...ChangeAccountCredentialsControllerTest.php | 200 ++++++++++++++++++ .../RequestPasswordResetControllerTest.php | 36 ++++ .../Controller/VerifyEmailControllerTest.php | 171 +++++++++++++++ 3 files changed, 407 insertions(+) create mode 100644 tests/Controller/ChangeAccountCredentialsControllerTest.php create mode 100644 tests/Controller/VerifyEmailControllerTest.php diff --git a/tests/Controller/ChangeAccountCredentialsControllerTest.php b/tests/Controller/ChangeAccountCredentialsControllerTest.php new file mode 100644 index 00000000..bba9244a --- /dev/null +++ b/tests/Controller/ChangeAccountCredentialsControllerTest.php @@ -0,0 +1,200 @@ +request('GET', '/en/edit-profile/change-password'); + + self::assertResponseStatusCodeSame(302); + self::assertSame( + [], + array_filter( + $browser->getResponse()->headers->getCookies(), + static fn ($cookie): bool => $cookie->getName() === 'PHPSESSID', + ), + 'An anonymous bounce must not hand out a session cookie of its own', + ); + } + + public function testChangeEmailPageIsClosedToAnonymousVisitors(): void + { + $browser = self::createClient(); + + $browser->request('GET', '/en/edit-profile/change-email'); + + self::assertResponseStatusCodeSame(302); + } + + public function testCorrectCurrentPasswordChangesThePassword(): void + { + $browser = self::createClient(); + $userAccount = $this->seedSignedInAccount($browser); + + $crawler = $browser->request('GET', '/en/edit-profile/change-password'); + self::assertResponseIsSuccessful(); + + $form = $crawler->selectButton('Change password')->form(); + $browser->submit($form, [ + $form->getName() . '[currentPassword]' => self::CURRENT_PASSWORD, + $form->getName() . '[newPassword]' => 'a-brand-new-passphrase', + ]); + + self::assertResponseRedirects('/en/edit-profile'); + + $hasher = $browser->getContainer()->get(UserPasswordHasherInterface::class); + $reloaded = $browser->getContainer()->get(UserAccountRepository::class) + ->findByUserId($userAccount->userId); + + self::assertNotNull($reloaded); + self::assertTrue($hasher->isPasswordValid($reloaded, 'a-brand-new-passphrase')); + self::assertFalse($hasher->isPasswordValid($reloaded, self::CURRENT_PASSWORD)); + } + + public function testWrongCurrentPasswordIsRefusedAtTheForm(): void + { + $browser = self::createClient(); + $userAccount = $this->seedSignedInAccount($browser); + + $crawler = $browser->request('GET', '/en/edit-profile/change-password'); + $form = $crawler->selectButton('Change password')->form(); + + $crawler = $browser->submit($form, [ + $form->getName() . '[currentPassword]' => 'not-the-current-passphrase', + $form->getName() . '[newPassword]' => 'a-brand-new-passphrase', + ]); + + self::assertResponseIsSuccessful(); + self::assertStringContainsString('not your current password', $crawler->filter('form')->text()); + + $hasher = $browser->getContainer()->get(UserPasswordHasherInterface::class); + $reloaded = $browser->getContainer()->get(UserAccountRepository::class) + ->findByUserId($userAccount->userId); + + self::assertNotNull($reloaded); + self::assertTrue($hasher->isPasswordValid($reloaded, self::CURRENT_PASSWORD)); + } + + public function testChangingTheEmailNeedsTheCurrentPasswordAndReVerifies(): void + { + $browser = self::createClient(); + $userAccount = $this->seedSignedInAccount($browser); + $newEmail = sprintf('changed+%s@example.com', bin2hex(random_bytes(4))); + + $crawler = $browser->request('GET', '/en/edit-profile/change-email'); + self::assertResponseIsSuccessful(); + + $form = $crawler->selectButton('Change email address')->form(); + $browser->submit($form, [ + $form->getName() . '[newEmail]' => $newEmail, + $form->getName() . '[currentPassword]' => self::CURRENT_PASSWORD, + ]); + + self::assertResponseRedirects('/en/edit-profile'); + + $reloaded = $browser->getContainer()->get(UserAccountRepository::class) + ->findByUserId($userAccount->userId); + + self::assertNotNull($reloaded); + self::assertSame($newEmail, $reloaded->email); + // The new address is unproven until its own link is clicked + self::assertNull($reloaded->emailVerifiedAt); + + // ... and that link goes to the NEW inbox + $messages = self::getMailerMessages(); + self::assertCount(1, $messages); + self::assertInstanceOf(Email::class, $messages[0]); + self::assertSame($newEmail, $messages[0]->getTo()[0]->getAddress()); + } + + public function testWrongCurrentPasswordDoesNotChangeTheEmail(): void + { + $browser = self::createClient(); + $userAccount = $this->seedSignedInAccount($browser); + $originalEmail = $userAccount->email; + + $crawler = $browser->request('GET', '/en/edit-profile/change-email'); + $form = $crawler->selectButton('Change email address')->form(); + + $crawler = $browser->submit($form, [ + $form->getName() . '[newEmail]' => sprintf('nope+%s@example.com', bin2hex(random_bytes(4))), + $form->getName() . '[currentPassword]' => 'not-the-current-passphrase', + ]); + + self::assertResponseIsSuccessful(); + self::assertStringContainsString('not your current password', $crawler->filter('form')->text()); + + $reloaded = $browser->getContainer()->get(UserAccountRepository::class) + ->findByUserId($userAccount->userId); + + self::assertNotNull($reloaded); + self::assertSame($originalEmail, $reloaded->email); + self::assertCount(0, self::getMailerMessages()); + } + + /** + * Window A: the session may hold an Auth0 bundle user, which has no + * user_account row behind it. These pages must show it the door rather than + * fail on the #[CurrentUser] type. + */ + public function testLegacyAuth0SessionIsRedirectedInsteadOfCrashing(): void + { + $browser = self::createClient(); + $browser->loginUser(new \Auth0\Symfony\Models\User(['sub' => 'auth0|legacy-credentials']), 'main'); + + $browser->request('GET', '/en/edit-profile/change-password'); + self::assertResponseRedirects('/en/edit-profile'); + + $browser->request('GET', '/en/edit-profile/change-email'); + self::assertResponseRedirects('/en/edit-profile'); + } + + private function seedSignedInAccount(KernelBrowser $browser): UserAccount + { + $email = sprintf('credentials+%s@example.com', bin2hex(random_bytes(4))); + $userId = 'msp|' . bin2hex(random_bytes(4)); + + $userAccount = new UserAccount(Uuid::uuid7(), $userId, $email, new DateTimeImmutable()); + $userAccount->changePassword(password_hash(self::CURRENT_PASSWORD, PASSWORD_ARGON2ID)); + $userAccount->markEmailVerified(new DateTimeImmutable()); + + $entityManager = $browser->getContainer()->get(EntityManagerInterface::class); + $entityManager->persist($userAccount); + $entityManager->persist( + new Player(Uuid::uuid7(), 'CRED' . bin2hex(random_bytes(2)), $userId, $email, null, new DateTimeImmutable()), + ); + $entityManager->flush(); + + $browser->loginUser($userAccount, 'main'); + + return $userAccount; + } +} diff --git a/tests/Controller/RequestPasswordResetControllerTest.php b/tests/Controller/RequestPasswordResetControllerTest.php index e2e26dd4..582b9789 100644 --- a/tests/Controller/RequestPasswordResetControllerTest.php +++ b/tests/Controller/RequestPasswordResetControllerTest.php @@ -8,9 +8,11 @@ use Doctrine\ORM\EntityManagerInterface; use Ramsey\Uuid\Uuid; use SpeedPuzzling\Web\Entity\UserAccount; +use SpeedPuzzling\Web\Repository\UserAccountRepository; use Symfony\Bundle\FrameworkBundle\KernelBrowser; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Component\Mime\Email; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; /** * "Forgot password?" (issue #147). The load-bearing property is that the page @@ -49,6 +51,40 @@ public function testKnownAddressGetsAResetLinkCarryingAUsableToken(): void self::assertStringNotContainsString('does not work', (string) $browser->getResponse()->getContent()); } + public function testTheMailedLinkActuallyResetsThePasswordAndThenDies(): void + { + $browser = self::createClient(); + $email = $this->seedAccount($browser); + + $this->requestReset($browser, $email); + + $messages = self::getMailerMessages(); + self::assertInstanceOf(Email::class, $messages[0]); + self::assertSame( + 1, + preg_match('#/password-reset/([0-9a-f]{64})#', (string) $messages[0]->getHtmlBody(), $matches), + ); + $resetUrl = '/password-reset/' . $matches[1]; + + $crawler = $browser->request('GET', $resetUrl); + $form = $crawler->selectButton('Save new password')->form(); + $browser->submit($form, [$form->getName() . '[plainPassword]' => 'a-brand-new-passphrase']); + + // Proving control of the mailbox resets the password; it does not sign the + // browser in - the user lands on the login page and uses the new password + self::assertResponseRedirects('/login'); + + $hasher = $browser->getContainer()->get(UserPasswordHasherInterface::class); + $userAccount = $browser->getContainer()->get(UserAccountRepository::class)->findByEmail($email); + self::assertNotNull($userAccount); + self::assertTrue($hasher->isPasswordValid($userAccount, 'a-brand-new-passphrase')); + self::assertFalse($hasher->isPasswordValid($userAccount, 'the-real-password')); + + // Single use: the same link must not open a second time + $browser->request('GET', $resetUrl); + self::assertStringContainsString('does not work', (string) $browser->getResponse()->getContent()); + } + public function testUnknownAddressLooksExactlyLikeAKnownOne(): void { $browser = self::createClient(); diff --git a/tests/Controller/VerifyEmailControllerTest.php b/tests/Controller/VerifyEmailControllerTest.php new file mode 100644 index 00000000..aab00e71 --- /dev/null +++ b/tests/Controller/VerifyEmailControllerTest.php @@ -0,0 +1,171 @@ +seedAccount($browser); + + $browser->request('GET', '/verify-email?token=' . $this->tokenFor($browser, $userAccount)); + + self::assertResponseIsSuccessful(); + self::assertStringContainsString('confirmed', (string) $browser->getResponse()->getContent()); + + $reloaded = $browser->getContainer()->get(UserAccountRepository::class)->findByUserId($userAccount->userId); + self::assertNotNull($reloaded); + self::assertNotNull($reloaded->emailVerifiedAt); + + // Proving you can read the mailbox confirms the address; it is not a login + self::assertNull($browser->getContainer()->get(TokenStorageInterface::class)->getToken()); + } + + /** + * D18 accepts that verification links are replayable inside their lifetime: + * the handler is idempotent, so a mail client prefetching the link - or a + * second click - must read as success, not as an error. + */ + public function testReplayingALiveLinkStillReadsAsSuccess(): void + { + $browser = self::createClient(); + $userAccount = $this->seedAccount($browser); + $token = $this->tokenFor($browser, $userAccount); + + $browser->request('GET', '/verify-email?token=' . $token); + $firstVerifiedAt = $this->reload($browser, $userAccount)?->emailVerifiedAt; + self::assertNotNull($firstVerifiedAt); + + $browser->request('GET', '/verify-email?token=' . $token); + + self::assertResponseIsSuccessful(); + self::assertStringContainsString('confirmed', (string) $browser->getResponse()->getContent()); + + // ... and the original timestamp is not moved by the replay. Compared to the + // second: the first read is the in-memory entity, the second comes back from a + // column that does not carry microseconds + $secondVerifiedAt = $this->reload($browser, $userAccount)?->emailVerifiedAt; + self::assertNotNull($secondVerifiedAt); + self::assertSame( + $firstVerifiedAt->format('Y-m-d H:i:s'), + $secondVerifiedAt->format('Y-m-d H:i:s'), + ); + } + + public function testAForgedTokenIsRejected(): void + { + $browser = self::createClient(); + + $browser->request('GET', '/verify-email?token=' . base64_encode('{"userId":"msp|forged"}') . '.forged'); + + self::assertResponseIsSuccessful(); + self::assertStringContainsString('does not work', (string) $browser->getResponse()->getContent()); + } + + public function testAMissingTokenIsRejected(): void + { + $browser = self::createClient(); + + $browser->request('GET', '/verify-email'); + + self::assertResponseIsSuccessful(); + self::assertStringContainsString('does not work', (string) $browser->getResponse()->getContent()); + } + + /** + * The token binds the address it was issued for, so a link stops working the + * moment the account moves to a different address - the old inbox must not be + * able to confirm the new one. + */ + public function testALinkDiesWhenTheAddressItWasIssuedForChanges(): void + { + $browser = self::createClient(); + $userAccount = $this->seedAccount($browser); + $token = $this->tokenFor($browser, $userAccount); + + $userAccount->changeEmail(sprintf('moved+%s@example.com', bin2hex(random_bytes(4)))); + $browser->getContainer()->get(EntityManagerInterface::class)->flush(); + + $browser->request('GET', '/verify-email?token=' . $token); + + self::assertResponseIsSuccessful(); + self::assertStringContainsString('does not work', (string) $browser->getResponse()->getContent()); + self::assertNull($this->reload($browser, $userAccount)?->emailVerifiedAt); + } + + public function testAnExpiredLinkSaysSoRatherThanLookingBroken(): void + { + $browser = self::createClient(); + $userAccount = $this->seedAccount($browser); + + $signer = $browser->getContainer()->get(EmailVerificationTokenSigner::class); + $expired = $signer->generate($userAccount, new DateTimeImmutable('-1 minute')); + + $browser->request('GET', '/verify-email?token=' . $expired); + + self::assertResponseIsSuccessful(); + self::assertStringContainsString('expired', (string) $browser->getResponse()->getContent()); + } + + public function testThePageStartsNoSessionAndStaysOutOfSharedCaches(): void + { + $browser = self::createClient(); + $userAccount = $this->seedAccount($browser); + + $browser->request('GET', '/verify-email?token=' . $this->tokenFor($browser, $userAccount)); + + // #164: even a successful confirmation must not put a cookie on the visitor + self::assertSame([], $browser->getResponse()->headers->getCookies()); + + $cacheControl = (string) $browser->getResponse()->headers->get('Cache-Control'); + self::assertStringContainsString('no-store', $cacheControl); + self::assertStringNotContainsString('public', $cacheControl); + } + + private function tokenFor(KernelBrowser $browser, UserAccount $userAccount): string + { + return $browser->getContainer()->get(EmailVerificationTokenSigner::class) + ->generate($userAccount, new DateTimeImmutable('+24 hours')); + } + + private function reload(KernelBrowser $browser, UserAccount $userAccount): null|UserAccount + { + return $browser->getContainer()->get(UserAccountRepository::class)->findByUserId($userAccount->userId); + } + + private function seedAccount(KernelBrowser $browser): UserAccount + { + $email = sprintf('verify.page+%s@example.com', bin2hex(random_bytes(4))); + $userAccount = new UserAccount( + Uuid::uuid7(), + 'msp|' . bin2hex(random_bytes(4)), + $email, + new DateTimeImmutable(), + ); + + $entityManager = $browser->getContainer()->get(EntityManagerInterface::class); + $entityManager->persist($userAccount); + $entityManager->flush(); + + return $userAccount; + } +} From ee7137ad5581c2f7e65434cef74c8a060509367c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Mike=C5=A1?= Date: Sat, 25 Jul 2026 01:42:48 +0200 Subject: [PATCH 8/9] Drop the cutover modal, and close the last live surface (issue #147) Two changes, both aimed at making this slice genuinely dark until Stage A. **Cutover modal dropped (D15 amendment, Jan's call 2026-07-25).** The site-wide notice strip shipped ahead of Stage A already reaches everyone, anonymous visitors included, and links to the explainer page. A modal repeating the same message on the login page is a second interruption with the same content. Removed the template, the Stimulus controller, the include and the copy in all six locales; the amendment is recorded in the README decision table, the implementation plan and the communication plan so the reasoning survives. The login page keeps UX-funnel layers 3, 4 and 6 - the permanent microcopy, the prominent sign-in-link CTA and the failure helper - which fire when somebody is actually stuck rather than on arrival. **Password reset is now gated too.** It was the one page this branch left live with both flags OFF, and because production's user_account table is empty until the Stage B import, it could only ever answer "if an account exists, a link is on its way" and then send nothing - a silent dead end, made silent precisely by the anti-enumeration uniformity it needs elsewhere. Both reset routes now redirect to /login until a native account can exist. Gated on registration OR login rather than on either alone, so a rollback that leaves login native does not take password reset down with it. With both flags OFF every route this branch adds now redirects to /login: /register, /password-reset, /password-reset/{token}, /welcome and the two credential-settings pages. Verified against the running container. Full suite: 1386 green; translations complete in all six locales. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017o4pbdfkhHDB4vdJPNY3tc --- .../controllers/cutover_modal_controller.js | 49 ----------------- docs/features/auth-migration/README.md | 10 ++-- .../auth-migration/communication-plan.md | 18 ++----- .../auth-migration/implementation-plan.md | 8 +-- src/Controller/PasswordResetController.php | 7 +++ .../RequestPasswordResetController.php | 11 ++++ templates/_cutover_modal.html.twig | 52 ------------------- templates/login.html.twig | 5 -- .../RequestPasswordResetControllerTest.php | 52 ++++++++++++++++--- translations/messages.cs.yml | 9 ---- translations/messages.de.yml | 9 ---- translations/messages.en.yml | 9 ---- translations/messages.es.yml | 9 ---- translations/messages.fr.yml | 9 ---- translations/messages.ja.yml | 9 ---- 15 files changed, 76 insertions(+), 190 deletions(-) delete mode 100644 assets/controllers/cutover_modal_controller.js delete mode 100644 templates/_cutover_modal.html.twig diff --git a/assets/controllers/cutover_modal_controller.js b/assets/controllers/cutover_modal_controller.js deleted file mode 100644 index d84ffaf6..00000000 --- a/assets/controllers/cutover_modal_controller.js +++ /dev/null @@ -1,49 +0,0 @@ -import { Controller } from '@hotwired/stimulus'; -import * as bootstrap from 'bootstrap'; - -/** - * The one-time "sign-in has moved home" explainer on the login page (D15, issue - * #147). Shown exactly where the confusion strikes, and only once per browser. - * - * localStorage, never the session: the login page is reached by anonymous - * visitors and must not gain a cookie (#164 anonymous-cacheability). The element - * starts hidden and is only revealed here, so a dismissed modal never flashes in - * for someone who has already read it. - */ -export default class extends Controller { - static STORAGE_KEY = 'sign-in-cutover-modal-dismissed'; - - connect() { - let dismissed = null; - - try { - dismissed = window.localStorage.getItem(this.constructor.STORAGE_KEY); - } catch (error) { - // Private mode or storage disabled: better to show it than to hide it - } - - if (dismissed !== null) { - this.element.remove(); - - return; - } - - this.modal = new bootstrap.Modal(this.element, {}); - this.modal.show(); - } - - dismiss() { - try { - window.localStorage.setItem(this.constructor.STORAGE_KEY, '1'); - } catch (error) { - // Nothing to remember it with: the modal simply comes back next time - } - } - - disconnect() { - if (this.modal) { - this.modal.dispose(); - this.modal = null; - } - } -} diff --git a/docs/features/auth-migration/README.md b/docs/features/auth-migration/README.md index dcbd847f..8940d641 100644 --- a/docs/features/auth-migration/README.md +++ b/docs/features/auth-migration/README.md @@ -21,7 +21,7 @@ Two user-visible stages instead of one big-bang cutover — sequencing insight: | Stage | What flips | Target | |---|---|---| | **Stage A** | New **registrations** go native (`msp\|` accounts); Auth0 database connection gets **Disable Sign Ups**; announcement email + banner + FAQ go live; **pay Essentials + open the hash-export ticket the same day** | ~1 week from start (build-gated) | -| **Stage B** | **Login** flips to the native form (invisible trickle fallback active); hash export imported; sessions flushed; explainer modal live on the login page | ~1 week after Stage A (export-gated; hard decision gate at day 14 — see risk register) | +| **Stage B** | **Login** flips to the native form (invisible trickle fallback active); hash export imported; sessions flushed | ~1 week after Stage A (export-gated; hard decision gate at day 14 — see risk register) | | Window | Trickle runs invisibly; metrics watched; straggler nudge at B+2w | 3–4 weeks after Stage B | | Decommission | Trickle + fork + tenant deleted | ~6–7 weeks from start | @@ -147,7 +147,7 @@ Rejected alternatives, for the record: **(a)** per-provider columns on `user_acc | Remember me | Signature-based (no storage, auto-invalidates on password change), `secure: true`, `samesite: lax` | core | | Login throttling | `login_throttling` (requires `symfony/rate-limiter`), default 5/min per username+IP | core | | Audit logging | Listeners on `LoginSuccessEvent` / `LoginFailureEvent` / `LogoutEvent` → Monolog/Sentry | core | -| Cutover explainer modal | One-time auto-modal on the login page post-Stage-B (localStorage-dismissed, works for anonymous visitors) — see UX funnel below | app | +| ~~Cutover explainer modal~~ | **Dropped 2026-07-25 (D15 amendment)** — the site-wide notice strip already reaches everyone, anonymous visitors included | — | | Post-login-link password setup | After a magic-link login on a `legacy_auth0` account: one-time skippable "set a fresh password" prompt — see UX funnel below | app | | Login-failure helper | On failed password attempts, an inline helper appears: password-manager tip + one-click "email me a sign-in link" (pre-filled) | app | @@ -160,7 +160,7 @@ Post-launch candidates (not blocking): Google/Facebook login (design settled — The single real user pain: managers saved the credential under `speedpuzzling.eu.auth0.com`, so autofill won't fire on the new login page and users conclude "I don't know my password". The design treats this as a funnel — every layer catches who the previous one missed: 1. **T-7d announcement email** — "your email and password stay the same" + the tip up front: *search your password manager for "speedpuzzling" or "auth0"* (the tenant subdomain contains "speedpuzzling", so vault search finds it in every manager). -2. **One-time modal on the login page** (post-Stage-B, localStorage-dismissed): same email+password, the vault-search tip, and the magic-link rescue — shown exactly where confusion strikes, including to logged-out/anonymous visitors. +2. ~~**One-time modal on the login page**~~ — **dropped 2026-07-25 (D15 amendment).** Its job is done by the site-wide notice strip, which went live ahead of Stage A, reaches anonymous visitors too, and links to the explainer page. Layers 3, 4 and 6 below carry the rest, and they fire when somebody is stuck rather than on arrival. 3. **Login form itself**: password login primary; **"Email me a sign-in link"** as a prominent, permanent secondary action (not buried); "Forgot password?" tertiary; persistent microcopy for dormant players returning months later. 4. **Failure-state helper**: a wrong-password attempt reveals an inline box — vault-search tip + one-click sign-in-link button pre-filled with the typed email. Shown on any failure (no account-existence signal → no enumeration leak). 5. **Post-magic-link password setup**: after a sign-in-link login on a `legacy_auth0` account, a one-time skippable prompt: "Set a fresh password so your manager saves it under myspeedpuzzling.com" — `autocomplete="new-password"` field (native manager password-generation UI) plus a "Suggest strong password" button that fills the field (visible, copyable, editable — refined from the readonly-input idea: readonly blocks users who want their own password, and managers capture on submit either way). Skipping keeps the old password working. @@ -197,8 +197,8 @@ The fork fixes + `AnonymousCacheHeadersSubscriber` (2026-07) made anonymous HTML | D12 | Email deliverability | Before cutover, verify seznam.cz SMTP limits/SPF/DKIM; auth emails (reset/verify/login-link) become critical-path. Consider a dedicated transactional provider if limits are tight | | D13 | Social-auth data model | `oauth_identity` table (one row per linked provider identity); password stays on `user_account`. Design settled now (see "Auth-method extensibility"), table ships with the first provider — not during migration | | D14 | Staged rollout | **Registrations flip first** (Stage A: native `/register` + Auth0 "Disable Sign Ups"), login flips ~1 week later (Stage B). Freezing signups before the export is generated → export covers 100% of Auth0 users. Decided 2026-07-23 | -| D15 | Cutover explainer | One-time auto-modal on the **login page** (localStorage-dismissed) + dismissable site-wide banner (~4 weeks) + FAQ page. Not a site-wide modal — visitors who never sign in shouldn't be interrupted | -| D16 | Public "why" tone | **Layered**: emails/banner/modal keep it simple ("sign-in now lives on our own site"); the FAQ/blog tells the honest full story — Auth0 enabled fast early development, but the unmaintained Symfony SDK + rejected upstream fixes forced us to maintain a fork and slow us down | +| D15 | Cutover explainer | ~~One-time auto-modal on the **login page** (localStorage-dismissed)~~ + dismissable site-wide banner (~4 weeks) + FAQ page. Not a site-wide modal — visitors who never sign in shouldn't be interrupted. **Amended 2026-07-25 (Jan): the modal is dropped.** The site-wide notice strip went live ahead of Stage A and already reaches everyone including anonymous visitors, linking to the explainer page — a modal repeating it on the login page is a second interruption with the same content. The login page keeps funnel layers 3/4/6 (permanent microcopy, prominent sign-in-link CTA, failure helper), which fire when someone is actually stuck rather than on arrival | +| D16 | Public "why" tone | **Layered**: emails/banner keep it simple ("sign-in now lives on our own site"); the FAQ/blog tells the honest full story — Auth0 enabled fast early development, but the unmaintained Symfony SDK + rejected upstream fixes forced us to maintain a fork and slow us down | | D17 | Locales | All 6 locales (en, cs, de, es, fr, ja) for every auth-facing page/modal/banner — matches #161 precedent; Auth0's Universal Login was auto-localized, English-only would regress. Emails per player `locale` | | D18 | Token replayability (decided 2026-07-24) | **Magic login links must be single-use**: Symfony's `login_link` is signature+expiry only (replayable within lifetime), so the implementation adds consumption storage (hashed-token row consumed at login, reset-request pattern). Email *verification* links stay stateless/replayable within their 24h lifetime — accepted deliberately: the handler is an idempotent no-op after first use, no session is created, and nothing privileged gates on `email_verified_at`. Password reset tokens were already single-use | diff --git a/docs/features/auth-migration/communication-plan.md b/docs/features/auth-migration/communication-plan.md index 372a7d44..cf083fed 100644 --- a/docs/features/auth-migration/communication-plan.md +++ b/docs/features/auth-migration/communication-plan.md @@ -16,7 +16,7 @@ The one real user pain: **password managers saved the credential under `speedpuz |---|---|---| | All registered users with an email | ~10k | Announcement email at Stage A — operational/service email (batched via Messenger + rate limiter; listmonk fallback per D12) | | Active players (90d) | ~3,300 | Same + in-app banner + straggler nudge | -| All visitors | — | Banner + FAQ page + login-page modal & microcopy | +| All visitors | — | Banner + FAQ page + login-page microcopy | All user-facing copy ships in **all 6 locales** (en, cs, de, es, fr, ja — D17); emails pick the player's `locale`. @@ -27,9 +27,9 @@ All user-facing copy ships in **all 6 locales** (en, cs, de, es, fr, ja — D17) | **Now** (2026-07-24, ahead of Stage A) | Advance notice live: site-wide banner + explainer page (`/en/sign-in-is-moving`, six locales). Copy leads with the honest cost — signed out once, password managers stop autofilling — then the "look at your address bar, it says speedpuzzling.eu.auth0.com" hook, why Auth0 was right at the start and no longer is, and why waiting hurts more puzzlers than acting. No date promised | | **Stage A day** (~Jul 29–31) | Announcement email to all users; FAQ page live; banner ON ("On {date}…"); native registrations quietly live; socials post | | Stage B − 1d | Banner switches to "tomorrow" wording | -| **Stage B day** (~Aug 6–12) | Cutover. Login-page modal live; banner switches to "changed" wording (stays ~4 weeks); login microcopy permanent; socials post | +| **Stage B day** (~Aug 6–12) | Cutover. Banner switches to "changed" wording (stays ~4 weeks); login microcopy permanent; socials post | | B + 2w | Straggler nudge email (active-in-6-months minus migrated) | -| B + 3–4w | Transition exit review (Phase 5 criteria); banner removed. Modal + microcopy + sign-in-link stay — dormant players return for months | +| B + 3–4w | Transition exit review (Phase 5 criteria); banner removed. Microcopy + sign-in-link stay — dormant players return for months | If Stage B slips past the export-delay gate, announced dates must say "week of {date}" — never promise a day we can't hold. @@ -59,17 +59,9 @@ Subject: **Sign-in is moving to myspeedpuzzling.com — same password, nothing t - B−1d: > 🔑 **Tomorrow** sign-in moves to myspeedpuzzling.com. You'll be signed out once — same email and password. [Details]({faq_url}) - Stage B → B+4w: > 🔑 Sign-in has a new home on myspeedpuzzling.com — same email and password as before. Trouble signing in? [Read this]({faq_url}) -### Login-page modal (Stage B, one-time per browser, localStorage-dismissed) +### ~~Login-page modal~~ — dropped 2026-07-25 (Jan) -Title: **Sign-in has moved home** - -> Signing in now happens right here on myspeedpuzzling.com — no more redirect to auth0.com. -> -> - **Same email, same password.** Nothing was reset. -> - **Password manager not offering it?** It saved your password under our old sign-in domain. Search it for "**speedpuzzling**" or "**auth0**" — it's there. -> - **Can't find it?** Use **Email me a sign-in link** below — one click and you're in. You can set a fresh password afterwards. -> -> [Got it] · [Why did this change?]({faq_url}) +The site-wide notice strip went live ahead of Stage A and already reaches everyone, anonymous visitors included, linking to the explainer page. A modal repeating the same message on the login page is a second interruption, so it was cut. The login page carries the permanent microcopy, the prominent sign-in-link CTA and the failure helper below instead — those fire when somebody is actually stuck rather than on arrival. ### Login-page microcopy (permanent) diff --git a/docs/features/auth-migration/implementation-plan.md b/docs/features/auth-migration/implementation-plan.md index 2ddd2444..8cfe36a5 100644 --- a/docs/features/auth-migration/implementation-plan.md +++ b/docs/features/auth-migration/implementation-plan.md @@ -11,7 +11,7 @@ Companion to [README.md](README.md) (analysis & decisions) and [communication-pl | Phase 0 prep + Phase 2 build | Jul 23 → ~Jul 29 | | **Stage A** deploy + announcement + **pay Auth0 + open ticket** | ~Jul 29–31 | | Hash export delivered (no SLA — estimate) | ~Aug 4–8 | -| **Stage B** cutover (login flips, modal live) | ~Aug 6–12 | +| **Stage B** cutover (login flips) | ~Aug 6–12 | | Hard gate if export still missing | Aug 14 (trickle-primary decision with Jan) | | Straggler nudge | Stage B + 2w (~Aug 26) | | Transition exit review | w/c Sep 1 | @@ -75,7 +75,7 @@ Sequencing matters: signups are frozen at Stage A, so a single export generated - [x] Login page: email+password, prominent "Email me a sign-in link" secondary CTA, "Forgot password?", registration link, persistent migration microcopy (communication-plan). *(Built in the 2c-I slice — `LoginController` + `templates/login.html.twig`, behind `native_login`; `/login` keeps its path and hands over to the Auth0 bundle controller while the flag is OFF. 2c-II filled in the "Forgot password?" and "Create an account" links now that their routes exist.)* - [x] **Login-failure helper** (UX funnel §4): failed attempt reveals inline box — vault-search tip ("search your password manager for speedpuzzling or auth0") + one-click sign-in-link button pre-filled with the typed email. Same rendering regardless of account existence. *(`templates/_login_failure_helper.html.twig`; the button re-posts the login form to the sign-in link endpoint via `form=`/`formaction`, so the typed address always travels with it.)* -- [x] **Cutover explainer modal** (D15, UX funnel §2): one-time auto-modal on the login page when `native_login` is on; localStorage-dismissed (works for anonymous); content per communication-plan. Respect the existing modal/Turbo patterns (`.claude/symfony-ux-hotwire-architecture-guide.md`). *(`templates/_cutover_modal.html.twig` + `assets/controllers/cutover_modal_controller.js`. It renders only inside `login.html.twig`, which only renders when `native_login` is ON — no second flag check needed. The controller reads localStorage in `connect()` and removes the element instead of showing it, so a dismissed modal never flashes; dismissal is written on both the ✕ and the "Got it" button.)* +- [x] ~~**Cutover explainer modal** (D15, UX funnel §2): one-time auto-modal on the login page when `native_login` is on.~~ **Dropped 2026-07-25 on Jan's call — see D15 amendment in README.** It was built and then removed in the same slice: the site-wide notice strip shipped ahead of Stage A already reaches *everyone*, anonymous visitors included, and links to the explainer page. A modal on top of that is a second interruption carrying the same message. The login page keeps the permanent microcopy, the sign-in-link CTA and the failure helper — layers 3, 4 and 6 of the UX funnel — which are the ones that fire exactly when someone is stuck. - [x] Site-wide dismissable banner (T-7d "coming" wording → Stage B "changed" wording → removed ~B+4w). Reuse the hint-dismissing pattern for logged-in users where it fits; localStorage for anonymous. *(Shipped **early**, ahead of Stage A, on Jan's call: nobody should meet this change unwarned. `SIGN_IN_CHANGES_NOTICE_ENABLED` (ON by default) + `templates/_sign_in_changes_notice.html.twig` + the explainer page `SignInChangesController` at `/en/sign-in-is-moving` (six locale paths), all six locales. Dismissal is localStorage for **everyone**, not the hint-dismissing DB pattern: the notice rides on pages that are shared-cacheable for anonymous visitors (#164) and must not add a session. An inline `` script hides it before first paint, so a dismissed notice never flashes in or shifts the page; `tests/Panther/SignInChangesNoticeTest.php` measures the real boxes in Chrome. The Stage B "changed" wording is still to write.)* - [x] Registration: form → `RegisterUser` command → handler creates `UserAccount` (`msp|`) + `Player` atomically; password `Compound` constraint — use the `StrongPassword` constraint built in 2c-I; verification email; programmatic login via `Security::login()`. - **Window-A collision (found 2026-07-24, must-fix in this item):** reject registration when the email already belongs to a **`Player`**, not only when it belongs to a `user_account`. In window A the `user_account` table holds native registrants only, so a returning user who forgot they had an account can register natively with their existing address — and at Stage B `ImportAuth0UserHandler` then *skips* their Auth0 identity (email already taken by another `user_id`, `src/MessageHandler/ImportAuth0UserHandler.php`), leaving their real profile and all their solving times unreachable while they sit on an empty new account. Point them at sign-in instead (same enumeration tradeoff as D8, already accepted for registration). *(Done — both checks in `RegisterUserHandler`, the `Player` one via the new `PlayerRepository::findByEmail()` which is `LOWER()`-matched and `setMaxResults(1)` because `player.email` is not unique — the 7 known duplicate pairs would otherwise throw `NonUniqueResultException`. The form error points at signing in rather than only saying "taken".)* @@ -102,7 +102,7 @@ Sequencing matters: signups are frozen at Stage A, so a single export generated ## Phase 3 — Communication (compressed: announcement = Stage A day) -See [communication-plan.md](communication-plan.md). Gates: Stage A may not ship until announcement email + FAQ + banner are translated (6 locales) and ready to go out the same day. Stage B may not flip until the modal + login microcopy are live-tested. +See [communication-plan.md](communication-plan.md). Gates: Stage A may not ship until announcement email + FAQ + banner are translated (6 locales) and ready to go out the same day. Stage B may not flip until the login microcopy is live-tested (the modal was dropped 2026-07-25 — see the D15 amendment). ## Phase 4A — Stage A runbook (~Jul 29–31) @@ -119,7 +119,7 @@ See [communication-plan.md](communication-plan.md). Gates: Stage A may not ship 1. [ ] Freeze other deploys; fresh **free** bulk user export (metadata/reconciliation delta). 2. [ ] Validate hash export (Phase 1.3); run import locally against a prod dump first, then on prod: `ssh lily.srv.thedevs.cz` → `cd /srv/myspeedpuzzling` → copy export files in → `docker compose exec web php bin/console myspeedpuzzling:import-auth0-users bulk.ndjson hashes.ndjson` → shred the copies. -3. [ ] Deploy with `native_login` ON (entry point + routes flip; modal + login microcopy live). +3. [ ] Deploy with `native_login` ON (entry point + routes flip; login microcopy live). 4. [ ] Force logout: `docker compose exec db psql -U speedpuzzling -d speedpuzzling -c 'DELETE FROM sessions;'` (one-time, pre-announced). 5. [ ] Smoke (production): login with legacy test account (bcrypt → verify argon2id rehash in DB), wrong-password → failure helper appears, sign-in link end-to-end + password prompt, password reset end-to-end, new registration, native change-password, OAuth2 authorize round-trip, PAT API call unaffected, admin voter, anonymous page still `public, s-maxage=60`. 6. [ ] Watch 48h: Sentry, login success/failure ratio, `trickle_used` counter, support inbox. diff --git a/src/Controller/PasswordResetController.php b/src/Controller/PasswordResetController.php index 7a1900b4..621d7b9e 100644 --- a/src/Controller/PasswordResetController.php +++ b/src/Controller/PasswordResetController.php @@ -36,6 +36,8 @@ public function __construct( private readonly ValidatePasswordResetToken $validatePasswordResetToken, private readonly TranslatorInterface $translator, private readonly LoggerInterface $logger, + private readonly bool $nativeRegistrationEnabled, + private readonly bool $nativeLoginEnabled, ) { } @@ -51,6 +53,11 @@ public function __construct( )] public function __invoke(Request $request, string $token): Response { + // No native accounts yet means no token we issued can be valid here + if ($this->nativeRegistrationEnabled === false && $this->nativeLoginEnabled === false) { + return $this->redirectToRoute('login'); + } + // Checked up front so a dead link says so immediately, instead of letting the // user pick a password and only then telling them it was wasted try { diff --git a/src/Controller/RequestPasswordResetController.php b/src/Controller/RequestPasswordResetController.php index 55892fc7..3bfd1c7a 100644 --- a/src/Controller/RequestPasswordResetController.php +++ b/src/Controller/RequestPasswordResetController.php @@ -36,6 +36,8 @@ public function __construct( private readonly LoggerInterface $logger, private readonly RateLimiterFactoryInterface $passwordResetEmailLimiter, private readonly RateLimiterFactoryInterface $passwordResetIpLimiter, + private readonly bool $nativeRegistrationEnabled, + private readonly bool $nativeLoginEnabled, ) { } @@ -47,6 +49,15 @@ public function __construct( )] public function __invoke(Request $request): Response { + // Reachable only once a native account can exist (Stage A onwards). Before + // that the user_account table is empty, so this page could only ever promise + // a mail it will not send - and its answer is uniform by design, so the dead + // end would be silent. Both flags, not just registration: a rollback that + // leaves login native must not take password reset down with it. + if ($this->nativeRegistrationEnabled === false && $this->nativeLoginEnabled === false) { + return $this->redirectToRoute('login'); + } + if ($request->isMethod('POST') === false) { return $this->render('request_password_reset.html.twig', [ 'email' => $request->query->getString('email'), diff --git a/templates/_cutover_modal.html.twig b/templates/_cutover_modal.html.twig deleted file mode 100644 index dab2575a..00000000 --- a/templates/_cutover_modal.html.twig +++ /dev/null @@ -1,52 +0,0 @@ -{# Cutover explainer (D15, UX funnel §2, issue #147): the one-time modal that - greets people on the login page once sign-in is native. Dismissal is - localStorage, so it works for anonymous visitors and adds no session - the - login page must stay session-free for the #164 constraint. - - Retires ~4 weeks after Stage B together with the site-wide notice. #} - diff --git a/templates/login.html.twig b/templates/login.html.twig index cd039bab..018e9b41 100644 --- a/templates/login.html.twig +++ b/templates/login.html.twig @@ -4,11 +4,6 @@ {% block robots %}{% endblock %} {% block content %} - {# D15: shown once per browser, on the page where the change is actually met. - Only after login went native - before that /login is the Auth0 redirect and - this template does not render at all. #} - {{ include('_cutover_modal.html.twig') }} -
{# The sign-in link buttons live in two places (the failure helper above the card, the secondary action inside it) and both submit the separate diff --git a/tests/Controller/RequestPasswordResetControllerTest.php b/tests/Controller/RequestPasswordResetControllerTest.php index 582b9789..a90318bb 100644 --- a/tests/Controller/RequestPasswordResetControllerTest.php +++ b/tests/Controller/RequestPasswordResetControllerTest.php @@ -9,6 +9,7 @@ use Ramsey\Uuid\Uuid; use SpeedPuzzling\Web\Entity\UserAccount; use SpeedPuzzling\Web\Repository\UserAccountRepository; +use SpeedPuzzling\Web\Tests\OverridesFeatureFlagEnv; use Symfony\Bundle\FrameworkBundle\KernelBrowser; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Component\Mime\Email; @@ -25,9 +26,36 @@ */ final class RequestPasswordResetControllerTest extends WebTestCase { - public function testKnownAddressGetsAResetLinkCarryingAUsableToken(): void + use OverridesFeatureFlagEnv; + + protected function tearDown(): void + { + $this->restoreFeatureFlagEnv(); + + parent::tearDown(); + } + + /** + * Before Stage A the user_account table is empty, so the page would only ever + * promise a mail it will not send - and its answer is uniform by design, so the + * dead end would be silent. It stays closed until a native account can exist. + */ + public function testTheWholeFlowIsClosedUntilNativeAuthIsLive(): void { + $this->overrideFeatureFlagEnv('NATIVE_REGISTRATION_ENABLED', false); + $this->overrideFeatureFlagEnv('NATIVE_LOGIN_ENABLED', false); $browser = self::createClient(); + + $browser->request('GET', '/password-reset'); + self::assertResponseRedirects('/login'); + + $browser->request('GET', '/password-reset/' . str_repeat('a', 64)); + self::assertResponseRedirects('/login'); + } + + public function testKnownAddressGetsAResetLinkCarryingAUsableToken(): void + { + $browser = $this->createClientWithNativeAuthLive(); $email = $this->seedAccount($browser); $this->requestReset($browser, $email); @@ -53,7 +81,7 @@ public function testKnownAddressGetsAResetLinkCarryingAUsableToken(): void public function testTheMailedLinkActuallyResetsThePasswordAndThenDies(): void { - $browser = self::createClient(); + $browser = $this->createClientWithNativeAuthLive(); $email = $this->seedAccount($browser); $this->requestReset($browser, $email); @@ -87,7 +115,7 @@ public function testTheMailedLinkActuallyResetsThePasswordAndThenDies(): void public function testUnknownAddressLooksExactlyLikeAKnownOne(): void { - $browser = self::createClient(); + $browser = $this->createClientWithNativeAuthLive(); $known = $this->seedAccount($browser); $this->requestReset($browser, $known); @@ -101,7 +129,7 @@ public function testUnknownAddressLooksExactlyLikeAKnownOne(): void public function testUnknownAddressSendsNoMail(): void { - $browser = self::createClient(); + $browser = $this->createClientWithNativeAuthLive(); $this->requestReset($browser, sprintf('nobody+%s@example.com', bin2hex(random_bytes(4)))); @@ -116,7 +144,7 @@ public function testUnknownAddressSendsNoMail(): void */ public function testThrottledRepeatLooksLikeSuccessAndSendsNothingExtra(): void { - $browser = self::createClient(); + $browser = $this->createClientWithNativeAuthLive(); $email = $this->seedAccount($browser); // Asserted before following the redirect: getMailerMessages() reads the @@ -134,7 +162,7 @@ public function testThrottledRepeatLooksLikeSuccessAndSendsNothingExtra(): void public function testTheFormPageStartsNoSessionAndStaysOutOfSharedCaches(): void { - $browser = self::createClient(); + $browser = $this->createClientWithNativeAuthLive(); $browser->request('GET', '/password-reset'); @@ -149,7 +177,7 @@ public function testTheFormPageStartsNoSessionAndStaysOutOfSharedCaches(): void public function testAMangledLinkGetsTheFriendlyPageRatherThanA404(): void { - $browser = self::createClient(); + $browser = $this->createClientWithNativeAuthLive(); // A mail client wrapped the URL and cut the token in half $browser->request('GET', '/password-reset/' . str_repeat('a', 30)); @@ -160,13 +188,21 @@ public function testAMangledLinkGetsTheFriendlyPageRatherThanA404(): void public function testTheTokenPageDoesNotLeakTheTokenThroughTheReferer(): void { - $browser = self::createClient(); + $browser = $this->createClientWithNativeAuthLive(); $browser->request('GET', '/password-reset/' . str_repeat('a', 64)); self::assertSame('no-referrer', $browser->getResponse()->headers->get('Referrer-Policy')); } + private function createClientWithNativeAuthLive(): KernelBrowser + { + // Stage A onwards: native accounts can exist, so the reset flow is open + $this->overrideFeatureFlagEnv('NATIVE_REGISTRATION_ENABLED', true); + + return self::createClient(); + } + private function requestReset(KernelBrowser $browser, string $email): void { // Fresh client IP per request: the per-IP limiter's cache outlives the test diff --git a/translations/messages.cs.yml b/translations/messages.cs.yml index d05c3694..6cf5020b 100644 --- a/translations/messages.cs.yml +++ b/translations/messages.cs.yml @@ -2928,15 +2928,6 @@ auth: resend_sent: "Potvrzovací e-mail jsme odeslali. Zkontrolujte svou schránku — i složku se spamem." resend_too_many: "Právě jsme jeden poslali. Počkejte prosím pár minut, než si vyžádáte další." resend_failed: "Potvrzovací e-mail se nepodařilo odeslat. Zkuste to prosím za chvíli znovu." - cutover_modal: - title: "Přihlašování se přestěhovalo domů" - intro: "Přihlašování teď probíhá přímo tady na myspeedpuzzling.com — už žádné přesměrování na auth0.com." - same_credentials: "Stejný e-mail, stejné heslo. Nic jsme neresetovali." - vault_tip: "Správce hesel vám ho nenabízí? Uložil si vaše heslo pod naší starou přihlašovací doménou (%old_domain%). Vyhledejte si v něm „speedpuzzling“ nebo „auth0“ — je tam." - sign_in_link: "Nemůžete ho najít? Použijte níže „Poslat mi přihlašovací odkaz e-mailem“ — jedno kliknutí a jste uvnitř. Nové heslo si pak můžete nastavit hned potom." - why: "Proč se to změnilo?" - got_it: "Rozumím" - dismiss: "Zavřít" password_reset: link: "Zapomenuté heslo?" request_title: "Obnovení hesla" diff --git a/translations/messages.de.yml b/translations/messages.de.yml index 786c772b..233350c0 100644 --- a/translations/messages.de.yml +++ b/translations/messages.de.yml @@ -2930,15 +2930,6 @@ auth: resend_sent: "Bestätigungs-E-Mail gesendet. Schauen Sie in Ihr Postfach – und in den Spam-Ordner." resend_too_many: "Wir haben gerade eine geschickt. Bitte warten Sie ein paar Minuten, bevor Sie eine weitere anfordern." resend_failed: "Wir konnten die Bestätigungs-E-Mail nicht senden. Bitte versuchen Sie es gleich noch einmal." - cutover_modal: - title: "Die Anmeldung ist umgezogen" - intro: "Die Anmeldung findet jetzt direkt hier auf myspeedpuzzling.com statt – keine Weiterleitung mehr zu auth0.com." - same_credentials: "Gleiche E-Mail, gleiches Passwort. Es wurde nichts zurückgesetzt." - vault_tip: "Ihr Passwort-Manager bietet es nicht an? Er hat Ihr Passwort unter unserer alten Anmelde-Domain (%old_domain%) gespeichert. Suchen Sie dort nach „speedpuzzling“ oder „auth0“ – es ist da." - sign_in_link: "Nicht zu finden? Nutzen Sie unten „Anmeldelink per E-Mail schicken“ – ein Klick und Sie sind drin. Ein frisches Passwort können Sie danach festlegen." - why: "Warum hat sich das geändert?" - got_it: "Alles klar" - dismiss: "Schließen" password_reset: link: "Passwort vergessen?" request_title: "Passwort zurücksetzen" diff --git a/translations/messages.en.yml b/translations/messages.en.yml index cdd99617..74cdf845 100644 --- a/translations/messages.en.yml +++ b/translations/messages.en.yml @@ -3217,15 +3217,6 @@ auth: resend_sent: "Confirmation email sent. Check your inbox — and your spam folder." resend_too_many: "We just sent one. Please wait a few minutes before asking for another." resend_failed: "We could not send the confirmation email. Please try again in a moment." - cutover_modal: - title: "Sign-in has moved home" - intro: "Signing in now happens right here on myspeedpuzzling.com — no more redirect to auth0.com." - same_credentials: "Same email, same password. Nothing was reset." - vault_tip: "Password manager not offering it? It saved your password under our old sign-in domain (%old_domain%). Search it for \"speedpuzzling\" or \"auth0\" — it is there." - sign_in_link: "Can't find it? Use \"Email me a sign-in link\" below — one click and you are in. You can set a fresh password afterwards." - why: "Why did this change?" - got_it: "Got it" - dismiss: "Close" password_reset: link: "Forgot password?" request_title: "Reset your password" diff --git a/translations/messages.es.yml b/translations/messages.es.yml index b915d4ee..4810ca79 100644 --- a/translations/messages.es.yml +++ b/translations/messages.es.yml @@ -2930,15 +2930,6 @@ fair_use_policy: "resend_sent": "Correo de confirmación enviado. Revisa tu bandeja de entrada y también la carpeta de spam." "resend_too_many": "Acabamos de enviarte uno. Espera unos minutos antes de pedir otro." "resend_failed": "No hemos podido enviar el correo de confirmación. Inténtalo de nuevo en un momento." - "cutover_modal": - "title": "El inicio de sesión se ha mudado a casa" - "intro": "Ahora el inicio de sesión ocurre aquí mismo, en myspeedpuzzling.com: se acabaron las redirecciones a auth0.com." - "same_credentials": "El mismo correo, la misma contraseña. No se ha restablecido nada." - "vault_tip": "¿Tu gestor de contraseñas no te la ofrece? Guardó tu contraseña bajo nuestro antiguo dominio de inicio de sesión (%old_domain%). Busca ahí \"speedpuzzling\" o \"auth0\": está ahí." - "sign_in_link": "¿No la encuentras? Usa \"Envíame un enlace de acceso por correo\" aquí abajo: un clic y ya estás dentro. Después puedes establecer una contraseña nueva." - "why": "¿Por qué ha cambiado esto?" - "got_it": "Entendido" - "dismiss": "Cerrar" "password_reset": "link": "¿Has olvidado tu contraseña?" "request_title": "Restablecer tu contraseña" diff --git a/translations/messages.fr.yml b/translations/messages.fr.yml index a973ee22..0b3b7706 100644 --- a/translations/messages.fr.yml +++ b/translations/messages.fr.yml @@ -2932,15 +2932,6 @@ edition: "resend_sent": "E-mail de confirmation envoyé. Vérifiez votre boîte de réception — et vos spams." "resend_too_many": "Nous venons d'en envoyer un. Patientez quelques minutes avant d'en demander un autre." "resend_failed": "Nous n'avons pas pu envoyer l'e-mail de confirmation. Veuillez réessayer dans un instant." - "cutover_modal": - "title": "La connexion a déménagé chez nous" - "intro": "La connexion se fait désormais ici même, sur myspeedpuzzling.com — plus de redirection vers auth0.com." - "same_credentials": "Même e-mail, même mot de passe. Rien n'a été réinitialisé." - "vault_tip": "Votre gestionnaire de mots de passe ne le propose pas ? Il a enregistré votre mot de passe sous notre ancien domaine de connexion (%old_domain%). Cherchez-y \"speedpuzzling\" ou \"auth0\" — il y est." - "sign_in_link": "Vous ne le retrouvez pas ? Utilisez « Recevoir un lien de connexion par e-mail » ci-dessous — un clic et vous êtes connecté. Vous pourrez définir un nouveau mot de passe ensuite." - "why": "Pourquoi ce changement ?" - "got_it": "J'ai compris" - "dismiss": "Fermer" "password_reset": "link": "Mot de passe oublié ?" "request_title": "Réinitialiser votre mot de passe" diff --git a/translations/messages.ja.yml b/translations/messages.ja.yml index 067bdb42..ea1f0424 100644 --- a/translations/messages.ja.yml +++ b/translations/messages.ja.yml @@ -2919,15 +2919,6 @@ qr_code: "resend_sent": "確認メールを送信しました。受信トレイと迷惑メールフォルダをご確認ください。" "resend_too_many": "たった今お送りしたばかりです。次のリクエストまで数分お待ちください。" "resend_failed": "確認メールを送信できませんでした。しばらくしてからもう一度お試しください。" - "cutover_modal": - "title": "サインインがこのサイトに移りました" - "intro": "サインインは、auth0.comへ移動することなく、このmyspeedpuzzling.com上で行われるようになりました。" - "same_credentials": "メールアドレスもパスワードもこれまでどおりです。リセットされたものはありません。" - "vault_tip": "パスワードマネージャーが提示してくれませんか?パスワードは以前のサインイン用ドメイン(%old_domain%)で保存されています。マネージャー内を「speedpuzzling」または「auth0」で検索すれば、きっと見つかります。" - "sign_in_link": "見つかりませんか?下の「サインインリンクをメールで受け取る」をご利用ください。クリックするだけでサインインできます。そのあとで新しいパスワードを設定することもできます。" - "why": "なぜ変わったのですか?" - "got_it": "了解しました" - "dismiss": "閉じる" "password_reset": "link": "パスワードをお忘れですか?" "request_title": "パスワードをリセット" From 8408c276bb3b0ea36bf038e8c515628a4192f496 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Mike=C5=A1?= Date: Sat, 25 Jul 2026 11:30:15 +0200 Subject: [PATCH 9/9] Pin profile settings on both sides of the migration (issue #147) /edit-profile is the one page real users meet that this slice changed, and nothing rendered it in a test. Two cases now do: - a legacy Auth0 session still gets the #161 "send password change email" form and none of the native cards - the offer ~10k existing users see is unchanged, which is the property that actually matters on merge day; - a native UserAccount session is still bounced to /my-profile. The second pins the known 2c-II -> 2d dependency instead of the end state: RetrieveLoggedUserProfile still recognises only the Auth0 user class, so a native session has a null profile and this controller's guard redirects it, leaving the native cards unreachable. Harmless while both flags are OFF (no native account exists in production), and the first task of slice 2d - at which point this test has to flip, as its docblock says. Full suite: 1388 green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017o4pbdfkhHDB4vdJPNY3tc --- .../Controller/EditProfileControllerTest.php | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 tests/Controller/EditProfileControllerTest.php diff --git a/tests/Controller/EditProfileControllerTest.php b/tests/Controller/EditProfileControllerTest.php new file mode 100644 index 00000000..cf134a6d --- /dev/null +++ b/tests/Controller/EditProfileControllerTest.php @@ -0,0 +1,94 @@ +request('GET', '/en/edit-profile'); + + self::assertResponseIsSuccessful(); + + // The #161 flow, untouched: a POST form to the Auth0 reset-email endpoint + self::assertCount(1, $crawler->filter('form[action="/en/change-password"]')); + self::assertStringContainsString( + 'Send password change email', + $crawler->filter('form[action="/en/change-password"]')->text(), + ); + + // ... and none of the native cards, which have nothing to act on here + self::assertCount(0, $crawler->filter('a[href$="/edit-profile/change-password"]')); + self::assertCount(0, $crawler->filter('a[href$="/edit-profile/change-email"]')); + } + + /** + * Pins the known 2c-II → 2d dependency rather than the end state. + * + * `RetrieveLoggedUserProfile::getProfile()` still tests only for the Auth0 user + * class, so a native session gets a null profile and this controller bounces it + * on its `$player === null` guard - which means the native credential cards + * built in 2c-II cannot be reached yet. Harmless today (both flags are OFF, so + * no native account exists in production), and the first task of slice 2d. + * + * **When 2d teaches RetrieveLoggedUserProfile about UserAccount, this test must + * flip**: assert the page renders with the two native cards and the resend + * button, and without the Auth0 form. + */ + public function testNativeAccountIsStillBouncedUntilSlice2dLands(): void + { + $browser = self::createClient(); + $userAccount = $this->seedNativeAccount($browser); + $browser->loginUser($userAccount, 'main'); + + $browser->request('GET', '/en/edit-profile'); + + self::assertResponseRedirects('/en/my-profile'); + } + + private function seedNativeAccount(KernelBrowser $browser): UserAccount + { + $email = sprintf('editprofile+%s@example.com', bin2hex(random_bytes(4))); + $userId = 'msp|' . bin2hex(random_bytes(4)); + + $userAccount = new UserAccount(Uuid::uuid7(), $userId, $email, new DateTimeImmutable()); + $userAccount->changePassword(password_hash('a-properly-long-passphrase', PASSWORD_ARGON2ID)); + + $entityManager = $browser->getContainer()->get(EntityManagerInterface::class); + $entityManager->persist($userAccount); + $entityManager->persist( + new Player(Uuid::uuid7(), 'EDPR' . bin2hex(random_bytes(2)), $userId, $email, null, new DateTimeImmutable()), + ); + $entityManager->flush(); + + return $userAccount; + } +}