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/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/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 e3081d5c..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 |
@@ -67,29 +67,30 @@ 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.~~ **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.)*
-- [ ] 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
- [ ] `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.
@@ -101,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)
@@ -118,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/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`)
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..621d7b9e
--- /dev/null
+++ b/src/Controller/PasswordResetController.php
@@ -0,0 +1,132 @@
+ '[0-9a-zA-Z]{1,128}'],
+ defaults: [NativeAuthPageSubscriber::ROUTE_DEFAULT => true],
+ methods: ['GET', 'POST'],
+ )]
+ 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 {
+ $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..eb51543f
--- /dev/null
+++ b/src/Controller/RegisterController.php
@@ -0,0 +1,151 @@
+ 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) {
+ // 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
+ // 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.
+ //
+ // 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',
+ );
+
+ $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..3bfd1c7a
--- /dev/null
+++ b/src/Controller/RequestPasswordResetController.php
@@ -0,0 +1,132 @@
+ true],
+ methods: ['GET', 'POST'],
+ )]
+ 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'),
+ ]);
+ }
+
+ 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.
+ // 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();
+ }
+
+ $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);
+
+ 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..7edebb9c
--- /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' => EmailVerificationTokenSigner::LIFETIME_HOURS,
+ ]);
+ $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..011ff33d 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,53 @@ 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
+ {
+ $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;
+ }
+
+ /**
+ * "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/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 %}
+
+
+ {# 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 %}
+
+
+
+
+ {% 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. #}
+
+
+
+
- {# 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. #}
+
+ {% 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. #}
+
+
+ {# 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 }}
+
+
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.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/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: |
+
+ button: "新しいパスワードを設定する"
+ expires: "リンクは1回のみ有効で、%minutes%分で期限切れになります。"
+ not_requested: "お心当たりがない場合は、このメールは無視していただいて問題ありません。パスワードはそのまま変わりません。"
diff --git a/translations/messages.cs.yml b/translations/messages.cs.yml
index fc71197e..6cf5020b 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,59 @@ 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."
+ 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..233350c0 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,59 @@ 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."
+ 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.en.yml b/translations/messages.en.yml
index b1c4c95e..74cdf845 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,59 @@ 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."
+ 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."
diff --git a/translations/messages.es.yml b/translations/messages.es.yml
index 86fae4e2..4810ca79 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,59 @@ 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."
+ "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..0b3b7706 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,59 @@ 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."
+ "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..ea1f0424 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,59 @@ 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": "確認メールを送信できませんでした。しばらくしてからもう一度お試しください。"
+ "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": "すでに使用済みか、新しいリセットリンクに置き換えられた可能性があります。下記から新しいリンクをリクエストしてください。"