Skip to content

feat(registration): anonymous self-signup with email-domain allow-list (#62)#90

Open
martinydeAI wants to merge 7 commits into
feature/issue-63-account-status-checkerfrom
feature/issue-62-self-signup
Open

feat(registration): anonymous self-signup with email-domain allow-list (#62)#90
martinydeAI wants to merge 7 commits into
feature/issue-63-account-status-checkerfrom
feature/issue-62-self-signup

Conversation

@martinydeAI

@martinydeAI martinydeAI commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

Links to issues

Closes #62.

do-not-merge — depends on PR #88 (#63) which depends on PR #86
(#45 + #83).

This branch is stacked on feature/issue-63-account-status-checker
because:

  1. It creates users with status = Pending (needs the column from
    PR feat(user): add name and status fields per ADR 006 (#45, #83) #86).
  2. Without PR feat(security): reject pending and blocked users at login (#63) #88's UserCheckerInterface, a freshly-registered
    user could sign in before approval — which defeats the point.

Rebase order once those PRs land:

  1. PR feat(user): add name and status fields per ADR 006 (#45, #83) #86 lands → PR feat(security): reject pending and blocked users at login (#63) #88 rebases onto develop.
  2. PR feat(security): reject pending and blocked users at login (#63) #88 lands → this PR rebases onto develop, do-not-merge
    label removed.

Description

Anonymous self-signup at /register

  • GET /register — render the form (email, name, password, password
    confirmation, CSRF token).
  • POST /register — validate input, create the user with
    status = Pending, redirect to /register/pending. On validation
    failure: 422 + re-render with a localised error.
  • GET /register/pending — "thanks, your account is awaiting
    approval" page.

The validation logic lives in App\Security\Registration (service);
the controller stays thin per project conventions. Each failure
raises a RegistrationException carrying a translation key, which
the controller renders through |trans. CSRF-protected via Symfony's
csrf_token('register') helper.

Allow-list: comma-separated env var
REGISTRATION_ALLOWED_EMAIL_DOMAINS=aarhus.dk,kk.dk,…
Bound via %env(default:…:REGISTRATION_ALLOWED_EMAIL_DOMAINS)% with
example.test as the dev / test default so the app boots without
the var set. Parsing happens once in
App\Security\AllowedEmailDomains (lowercase + trim + dedup + drop
empties).

A pending user created by this route cannot sign in: the
integration test covers the hand-off through PR #88's
AccountStatusChecker.

Screenshot of the result

n/a — form re-uses the same Form/* components and base layout as
/login. Manual screenshot can be added on request.

Checklist

  • My code is covered by test cases.
  • My code passes our test (all our tests).
  • My code passes our static analysis suite.
  • My code passes our continuous integration process.

Additional comments or questions

The plan also lists a Domain entity follow-up: once PR #80
(Organization entity) lands, User.organization is added (separate
issue), and the registration allow-list switches from this env-var
to Organization.emailDomains. That swap is not in scope for
this PR.

UserManager::createUser() is reused as the persistence step — no
duplication of the password-hashing / unique-email logic.

The domain-extraction helper from PR #87 (App\Security\EmailDomain)
isn't pulled in here to keep the stack chain clean — the same one-
line extraction is inlined in Registration with a comment pointing
at PR #87 so it can be folded back once both PRs land.


Details - AI specificities

Adds the public `/register` route per ADR 006:

- `App\Controller\RegistrationController` — thin: validates CSRF,
  delegates to a service, renders the form / pending page. GET +
  POST on `/register`; GET on `/register/pending`. Both routes are
  added to `access_control` as `PUBLIC_ACCESS`. Already-authenticated
  visitors are bounced to `/`.
- `App\Security\Registration` — orchestrates email validation, the
  domain allow-list check, password-match check, name + password
  non-emptiness, and the persistence step via the existing
  `UserManager`. Persists with `status = Pending`. Each failure
  raises a `RegistrationException` carrying a translation key.
- `App\Security\AllowedEmailDomains` — value object that wraps the
  comma-separated env var `REGISTRATION_ALLOWED_EMAIL_DOMAINS`,
  normalising on construction (lowercase, trim, dedup, drop empties).
  Bound to services via `%env(default:…:REGISTRATION_ALLOWED_EMAIL_DOMAINS)%`
  with `example.test` as the default so dev / tests boot without
  needing the var set.
- Twig templates re-use the existing `Form/*`, `Eyebrow`, and
  `Layout/*` components; no new component families.
- Translations live in the existing `messages` domain under
  `register.*`.

A pending user created by this route cannot sign in (covered by the
checker from PR #88) — the integration test exercises that hand-off
end-to-end.

Sequenced as PR 4 of the User management milestone plan. Stacked on
PR 3 (#88) since `UserManager::createUser(..., status: Pending)` is
only useful when the checker rejects pending logins; PR 3 is itself
stacked on PR 1 (#86) for the `name` + `status` fields. Labelled
`do-not-merge` until both bases land.

The domain-extraction helper from PR #87 (`App\Security\EmailDomain`)
isn't pulled in here to keep the stack chain clean — the same
one-line extraction is inlined in `Registration` with a TODO to
fold back into the helper once PR #87 lands on `develop`.

Closes #62.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@martinydeAI martinydeAI added the do-not-merge Block merging until external dependency lands label Jun 19, 2026
Correct comment formatting for allowed email domains.
Removed inlined comments regarding EmailDomain dependency and clarified the self-signup rules.
}

#[Route(path: '/register', name: 'app_register', methods: ['GET', 'POST'])]
public function register(Request $request): Response

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need a helper class to keep controller thin as per CLAUD.md directions

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread templates/registration/register.html.twig
Comment thread tests/Integration/Controller/RegistrationControllerTest.php
Comment thread tests/Integration/Controller/RegistrationControllerTest.php
Comment thread tests/Unit/Security/AllowedEmailDomainsTest.php
Comment thread tests/Unit/Security/RegistrationTest.php
Per review on PR #90 - apply the test-comment convention to the
three new test files (RegistrationControllerTest,
AllowedEmailDomainsTest, RegistrationTest). Each `test...` method
now opens with a single-line "Tests ...", "Ensures ...", or
"Verifies ..." comment naming what it asserts.

Pure documentation change - no test logic touched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

do-not-merge Block merging until external dependency lands

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants