feat(admin): CRUD for Organization (#76)#109
Conversation
Implements the admin Organization moderation surface decided in
ADR 003 ("custom CRUD with Symfony Form + hand-written controllers
and Twig templates"). An admin can list every organisation, create
a new one, edit any field on an existing one, and delete one. The
operations land in `App\Controller\Admin\OrganizationController`
under the `/admin/organization` route prefix.
Form
- New `symfony/form` + `symfony/validator` dependencies (recipe
files: `config/packages/csrf.yaml`, `config/packages/validator.yaml`).
- `App\Form\OrganizationType` binds to the `Organization` entity
via `data_class` and supplies an `empty_data` factory so the
entity's all-required constructor works for the "create" path.
- The `emailDomains` field is presented as a textarea (one
domain per line) with a `CallbackTransformer` translating to /
from the entity's `list<string>` shape. Empty lines are dropped.
- `NotBlank` on `name` and `defaultFramework`; `Count(min: 1)` on
`emailDomains` so blank submissions fail validation before
hitting the entity setters (which require non-null strings).
- Tailwind classes wired as `attr` defaults on each field so the
template stays terse (`form_row` per field).
Templates
- `templates/admin/organization/list.html.twig` renders the table
with action links + a CSRF-protected delete form per row.
- `templates/admin/organization/new.html.twig` + `edit.html.twig`
share `_form.html.twig`.
- Danish copy under `admin.organization.*` in `messages.da.yaml`.
Tests
- New `tests/Integration/Controller/Admin/OrganizationControllerTest.php`
covers list, new (happy + invalid), edit (prefill + update),
delete (success + invalid CSRF), and 404 on unknown id.
- `tests/bootstrap_integration.php` now loads
`OrganizationFixtures` alongside the user + assistant ones, so
the admin tests have a baseline (Aarhus / Aalborg / Odense).
Auth gating intentionally NOT in this PR — tracked as a follow-up
issue per the agreed plan. Anyone reaching `/admin/organization`
today can CRUD organisations; that will be gated by `ROLE_ADMIN`
together with the broader "default deny" work (#97).
Verified locally:
- `task coding-standards-check` - all green.
- `task test-coverage` - 111 tests, 381 assertions, 100% coverage.
Closes #76.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Follow-up filed to unify the two CSRF patterns the project now runs side by side: stateless (used by the new |
| /** | ||
| * Admin CRUD for {@see Organization} (issue #76). | ||
| * | ||
| * Per ADR 003 ("custom CRUD with Symfony Form + hand-written |
There was a problem hiding this comment.
Don't mention ADR in docblocks
| use Symfony\Component\Routing\Attribute\Route; | ||
|
|
||
| /** | ||
| * Admin CRUD for {@see Organization} (issue #76). |
There was a problem hiding this comment.
Dont mention github issues or PRs in docblock
| use Symfony\Component\Validator\Constraints\NotBlank; | ||
|
|
||
| /** | ||
| * Form type backing the admin Organization CRUD per ADR 003. |
There was a problem hiding this comment.
Don't mention adr in docblocks
| {% block body %} | ||
| <section class="max-w-2xl"> | ||
| <twig:Eyebrow as="p" class="mb-4">{{ 'admin.organization.eyebrow'|trans }}</twig:Eyebrow> | ||
| <h1 class="mb-6 font-display text-[clamp(1.75rem,3vw,2.25rem)] font-medium leading-tight tracking-tight text-ink"> |
There was a problem hiding this comment.
Use twig header component
| {% block body %} | ||
| <section class="max-w-4xl"> | ||
| <twig:Eyebrow as="p" class="mb-4">{{ 'admin.organization.eyebrow'|trans }}</twig:Eyebrow> | ||
| <h1 class="mb-6 font-display text-[clamp(1.75rem,3vw,2.25rem)] font-medium leading-tight tracking-tight text-ink"> |
There was a problem hiding this comment.
Use twig header component
| </h1> | ||
|
|
||
| {% for flash in app.flashes('success') %} | ||
| <div class="mb-4 rounded-lg border border-line bg-surface-2 px-4 py-3 text-sm text-text" role="status"> |
There was a problem hiding this comment.
Use twig notification component
| {% block body %} | ||
| <section class="max-w-2xl"> | ||
| <twig:Eyebrow as="p" class="mb-4">{{ 'admin.organization.eyebrow'|trans }}</twig:Eyebrow> | ||
| <h1 class="mb-6 font-display text-[clamp(1.75rem,3vw,2.25rem)] font-medium leading-tight tracking-tight text-ink"> |
There was a problem hiding this comment.
Use twig header component
Address PR #109 review comments and follow-ups surfaced by an internal code review: - Drop docblock on `OrganizationController` (controllers don't carry PHPDoc per CLAUDE.md) and strip ADR / issue references from the form-type and test class docblocks. - Swap hand-rolled <h1> blocks for the shared `twig:Heading` component and the list success flash for `twig:Alert` so the admin templates align with the component library. - Add an optional `href` prop to `twig:Form:Button` so links can carry the button affordance without nesting `<a>` inside `<button>` (invalid HTML, blocked keyboard activation). The list "new organisation" affordance now uses it. - Move the `Count` constraint message to `validators.da.yaml` so it actually translates (constraint violations are translated through the `validators` domain, not the form's `translation_domain`). Drop the redundant `'translation_domain' => 'messages'` form option. - Add a regression test asserting the translated minMessage appears (and the raw key does not) on empty-domains submit. - Add a one-line rationale above the 422-on-invalid-submit responses in the controller. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Links to issues
Closes #76.
Description
Admin CRUD for
Organizationper ADR 003 ("custom CRUD withSymfony Form + hand-written controllers and Twig templates").
List / create / edit / delete actions land in
App\Controller\Admin\OrganizationControllerunder the/admin/organizationroute prefix.Highlights:
symfony/form+symfony/validator(theADR mandates Symfony Form; the project didn't carry it yet).
App\Form\OrganizationTypebinds directly to theOrganizationentity viadata_classand supplies anempty_datafactory so the entity's all-required constructorworks for the "create" path.
emailDomainsis presented as atextarea (one domain per line) and translated to / from the
entity's
list<string>via aCallbackTransformer.NotBlankonnameanddefaultFramework,Count(min: 1)onemailDomains, so blank submissions failvalidation before they hit the entity setters that require
non-null strings.
attrdefaults on each field sothe templates stay terse —
{{ form_row(form.field) }}perrow.
list.html.twig(table with action links +CSRF-protected delete form),
new.html.twig+edit.html.twigsharing
_form.html.twig.admin.organization.*intranslations/messages.da.yaml.(prefill + update), delete (success + invalid CSRF), and 404
on unknown id.
tests/bootstrap_integration.phpnowloads
OrganizationFixturesalongside the user + assistantones so the admin tests get a baseline (Aarhus / Aalborg /
Odense).
Screenshot of the result
n/a — admin surface, Tailwind on top of the standard chrome.
Manual screenshot can be added on request.
Checklist
Verified locally:
task coding-standards-check— green (PHP-CS-Fixer, Twig CSFixer, Prettier, markdownlint, composer validate + normalize).
task test-coverage— 111 tests, 381 assertions, 100%coverage.
Additional comments or questions
Auth gating is intentionally NOT in this PR. Anyone reaching
/admin/organizationtoday can CRUD organisations. The agreedplan was to ship the CRUD now and file a follow-up issue for the
ROLE_ADMIN(or moderator-role) gating; that follow-up is filedalongside this PR.
Details - AI specificities
docs/adr/003-admin-crud-tooling.md) explicitlypicks "custom CRUD with Symfony Form" — implemented per spec.
src/Controller/Admin/to mirror theAdmin/ namespace that the unmerged user-management PR (feat(admin): scoped user-management with approval queue (#64, #85) #91)
also establishes. They co-exist cleanly.
newandedit; thedeleteaction uses a manualcsrf_token('admin-organization-delete')because it doesn'tgo through a Form object.
empty_datafactory is the conventional Symfony idiomwhen the bound entity has a constructor with required args.
emailDomainsUX: textarea one-per-line is the cheapestmulti-value UI that doesn't need JavaScript. If we later want a
dynamic add/remove field, swap to
CollectionType— theback-end shape (
list<string>in the JSON column) doesn'tchange.