Skip to content

feat(admin): CRUD for Organization (#76)#109

Open
martinydeAI wants to merge 2 commits into
developfrom
feature/issue-76-admin-organization-crud
Open

feat(admin): CRUD for Organization (#76)#109
martinydeAI wants to merge 2 commits into
developfrom
feature/issue-76-admin-organization-crud

Conversation

@martinydeAI

Copy link
Copy Markdown
Collaborator

Links to issues

Closes #76.

Description

Admin CRUD for Organization per ADR 003 ("custom CRUD with
Symfony Form + hand-written controllers and Twig templates").
List / create / edit / delete actions land in
App\Controller\Admin\OrganizationController under the
/admin/organization route prefix.

Highlights:

  • New dependencies: symfony/form + symfony/validator (the
    ADR mandates Symfony Form; the project didn't carry it yet).
  • App\Form\OrganizationType binds directly 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. emailDomains is presented as a
    textarea (one domain per line) and translated to / from the
    entity's list<string> via a CallbackTransformer.
  • Validation: NotBlank on name and defaultFramework,
    Count(min: 1) on emailDomains, so blank submissions fail
    validation before they hit the entity setters that require
    non-null strings.
  • Tailwind classes wired as attr defaults on each field so
    the templates stay terse — {{ form_row(form.field) }} per
    row.
  • Templates: list.html.twig (table with action links +
    CSRF-protected delete form), new.html.twig + edit.html.twig
    sharing _form.html.twig.
  • Danish UI copy under admin.organization.* in
    translations/messages.da.yaml.
  • Integration tests cover list, new (happy + invalid), edit
    (prefill + update), delete (success + invalid CSRF), and 404
    on unknown id.
  • Bootstrap update: tests/bootstrap_integration.php now
    loads OrganizationFixtures alongside the user + assistant
    ones 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

  • 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.

Verified locally:

  • task coding-standards-check — green (PHP-CS-Fixer, Twig CS
    Fixer, Prettier, markdownlint, composer validate + normalize).
  • task test-coverage111 tests, 381 assertions, 100%
    coverage
    .

Additional comments or questions

Auth gating is intentionally NOT in this PR. Anyone reaching
/admin/organization today can CRUD organisations. The agreed
plan was to ship the CRUD now and file a follow-up issue for the
ROLE_ADMIN (or moderator-role) gating; that follow-up is filed
alongside this PR.


Details - AI specificities

  • ADR 003 (docs/adr/003-admin-crud-tooling.md) explicitly
    picks "custom CRUD with Symfony Form" — implemented per spec.
  • Controller location: src/Controller/Admin/ to mirror the
    Admin/ namespace that the unmerged user-management PR (feat(admin): scoped user-management with approval queue (#64, #85) #91)
    also establishes. They co-exist cleanly.
  • CSRF protection: Symfony Form supplies its own token on
    new and edit; the delete action uses a manual
    csrf_token('admin-organization-delete') because it doesn't
    go through a Form object.
  • The empty_data factory is the conventional Symfony idiom
    when the bound entity has a constructor with required args.
  • emailDomains UX: textarea one-per-line is the cheapest
    multi-value UI that doesn't need JavaScript. If we later want a
    dynamic add/remove field, swap to CollectionType — the
    back-end shape (list<string> in the JSON column) doesn't
    change.

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>
@martinydeAI

Copy link
Copy Markdown
Collaborator Author

Follow-up filed to unify the two CSRF patterns the project now runs side by side: stateless (used by the new symfony/form recipe config/packages/csrf.yaml and the form-login firewall) vs. session-bound (used by the hand-rolled admin / profile / registration / assistant-create forms). See #111 — it migrates every hand-rolled form's intent into stateless_token_ids and adds the data-controller="csrf-protection" hook so the existing Stimulus controller mirrors the cookie into the field on submit. Not blocking for this PR — the admin-organization-delete form here keeps the session-bound pattern until #111 lands.

@martinyde martinyde self-requested a review June 23, 2026 08:38
/**
* Admin CRUD for {@see Organization} (issue #76).
*
* Per ADR 003 ("custom CRUD with Symfony Form + hand-written

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.

Don't mention ADR in docblocks

use Symfony\Component\Routing\Attribute\Route;

/**
* Admin CRUD for {@see Organization} (issue #76).

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.

Dont mention github issues or PRs in docblock

Comment thread src/Form/OrganizationType.php Outdated
use Symfony\Component\Validator\Constraints\NotBlank;

/**
* Form type backing the admin Organization CRUD per ADR 003.

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.

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">

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.

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">

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.

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">

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.

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">

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.

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>
@martinydeAI martinydeAI mentioned this pull request Jun 23, 2026
6 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: admin CRUD for Organization

2 participants