The dual-cookie machinery (crewli_app_token + crewli_portal_token,
Origin-based resolution) was load-bearing only when the second SPA
existed. apps/portal/ was deleted in WS-3 PR-B1; the resolver code
has been carrying dead branches since then. Collapse to one cookie.
Cookie name retained as crewli_app_token — no session breakage on
deploy. crewli_portal_token is fully purged from the server-side.
CookieBearerToken middleware:
- COOKIE_NAMES array → single COOKIE_NAME constant
- resolveCookieName method (Origin/Referer parsing, host+port
matching against frontend_app_url/frontend_portal_url) → removed
- Body collapses to: skip if Authorization header present; else
read crewli_app_token cookie and inject Bearer header
SetAuthCookie trait:
- COOKIE_MAP / resolveCookieName / originMatches → removed
- makeAuthCookie / forgetAuthCookie now take only $token; the
cookie name is the trait's private constant
Five callers updated to drop the resolveCookieName($request) line
and the cookie-name argument: LoginController (3 sites),
MfaVerifyController (1 site), AuthRefreshController (1 site),
LogoutController (1 site), InvitationController (1 site — caller
list in the prompt missed this one but the same pattern applies).
frontend_portal_url config key retained (per Phase A directive Q1):
EmailChangeController, PasswordResetController, PersonController are
non-auth consumers that build per-app URL maps for outbound emails.
The map structure is now functionally redundant (production resolves
all FRONTEND_* env vars to the same host) but stays structurally
intact. Refactor tracked as TECH-FRONTEND-URL-CONSOLIDATE in the
upcoming docs commit.
HttpOnlyCookieAuthTest:
- Removed 4 dual-cookie tests (login_sets_portal_cookie_for_portal_origin,
app_cookie_does_not_authenticate_portal_requests,
portal_cookie_does_not_authenticate_app_requests,
correct_cookie_authenticates_with_matching_origin)
- Renamed login_sets_app_cookie_for_unknown_origin →
login_sets_app_cookie_regardless_of_origin; expanded to four
Origin variants (none, app, unknown, foreign) — pins the new
origin-agnostic contract
- Removed Origin headers from request calls in remaining tests
(now meaningless)
Backend test count: 1491 → 1487 (-4 deleted, dual-cookie tests
encoding the obsolete contract). Pint clean. Larastan clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Additive enrichment to MeResource — existing fields untouched, MeTest stays green.
New fields:
- contexts.available: list<'portal'|'organizer'> derived from Person + Organisation memberships
- contexts.default: precedence super_admin > organizer > portal > fallback portal
- platform.is_super_admin: bool promoted from app_roles
- organisations[].roles: 1-element array form alongside the legacy scalar role,
forward-compatible for the multi-role pivot work tracked in TECH-PIVOT-ROLES-MULTI
UserFactory gains volunteer(), orgAdmin(), volunteerAndOrganizer(), superAdmin()
state methods — codified role categories for reuse across future workstreams.
Adds forbidden.vue placeholder (PublicLayout) for the context-failure landing in
the upcoming guard rewrite.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the four production gaps that emerged from sessie 3b's admin UI.
What we ship here is final: no further rework planned before production.
Backend
- IndexFailuresRequest validates state/search/failed_at_from/failed_at_to/
listener_class. orgIndex + platformIndex apply them via a single
applyIndexFilters() helper. Search runs case-insensitive `LIKE` on
exception_message; SQL wildcards in user input are escaped.
- New /kpis aggregate endpoint per scope (orgKpis, platformKpis) returns
open / resolved_30d / dismissed_30d / total_submissions in O(1) COUNTs.
Replaces sessie 3b's client-side bucketing of an oversized list.
- Resource expansion: organisation_name, form_schema_label,
resolved_by_user_name, dismissed_by_user_name, exception_trace,
retry_history[]. Eager-loading via indexEagerLoads()/detailEagerLoads()
prevents N+1 (verified by query-count assertion in test).
- New 2026_04_28_181000 migration adds exception_trace (longtext nullable)
to form_submission_action_failures. ApplyBindingsOnFormSubmit listener
now captures $e->getTraceAsString() at failure time.
- New FormSubmissionActionFailureRetryAttemptResource exposes per-attempt
data (timestamp, actor name, outcome, exception details) inside
retry_history[]. Index payloads omit the field via whenLoaded() to keep
list responses lean.
Frontend (apps/app)
- Types updated to mirror the expanded resource shape and the new KPI
endpoint contract. FormFailuresKpis is now { open, resolved_30d,
dismissed_30d, total_submissions } (server-aggregate).
- useFormFailures composable forwards all 5 server filters via
buildIndexParams() (strips empty/whitespace). useFormFailuresKpis hits
the dedicated /kpis endpoint per scope.
- FormFailuresTable replaces client-side bucketing with server-side
filtering, adds listener_class + date-range filter inputs, and renames
the 4th KPI tile to "Submissions" (was "Totaal").
- FormFailureDetail renders organisation_name + form_schema_label in the
header, surfaces an expandable stack-trace card, names the resolved/
dismissed actor in the timeline, and replaces the "v1 placeholder"
retry-history card with a full per-attempt timeline.
ESLint config gap (apps/app)
- New .eslintrc.cjs adapted from the Vuexy reference, minus Vuexy-internal
rules. `pnpm lint` now runs successfully (was previously broken — the
package.json script referenced a missing config). The 80 baseline
violations across the codebase are pre-existing and out of scope for
this session.
Tests + gates
- 24 new backend tests across filter, kpis, and resource-shape suites.
Backend: 1462 → 1486 passing, 0 → 0 failing. Larastan clean. Rector
dry-run unchanged at 354 (pre-Task-1 baseline from f18b55b).
- 3 new vitest tests in apps/app (filter wiring, KPI endpoint, KPI tile
values from /kpis). Vitest: 38 → 41 passing. tsc clean. Portal
unchanged (113 vitest, tsc clean).
- 5 backfill rollback tests bumped --step counts +1 for the new migration.
- Ws6FoundationMigrationTest down/up chain now includes exception_trace
before the parent table is restored.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-attempt retry history (timestamp, user, outcome, exception detail
if failed) replaces the counter-only retry_count tracking.
Changes:
- New `form_submission_action_failure_retry_attempts` table (cascade on
parent delete, nullOnDelete on user). Explicit short FK names
(`fsafra_failure_fk`, `fsafra_user_fk`) — auto-generated names exceed
MySQL's 64-char identifier limit.
- New FormSubmissionActionFailureRetryAttempt model + factory +
succeeded() state.
- Parent FormSubmissionActionFailure gets retryAttempts() HasMany
relation (latest('attempted_at')).
- New FormFailureRetryService centralises the retry-flow logic. Both
the API controller and the artisan command delegate to it. Service
writes a retry_attempt record per attempt; parent's retry_count
stays as denormalised cache for index-view performance.
- Successful retry: attempt(succeeded) + parent.retry_count++ +
parent.resolved_at + parent.resolved_by_user_id + parent.resolved_note
("Geslaagde retry door {actor.name}" or "Geslaagde retry
(geautomatiseerd)" for command-line invocation without an actor).
- Failed retry: attempt(failed) with NEW exception details +
parent.retry_count++. Parent's exception_class/_message stay
audit-immutable — they represent the FIRST failure.
- canBeRetried() now correctly checks both resolved_at AND
dismissed_at (sessie 2's open question Q2 closure).
- New FailureNotRetriableException (controller → 422) and
ParentSubmissionGoneException (controller → 410) for cleaner
flow control.
12 new tests:
- FormSubmissionActionFailureRetryAttemptTest (5 unit tests)
- RetryFlowProducesRetryAttemptsTest (7 integration tests covering
succeeded path, failed path, resolved/dismissed blocking,
multiple-retries chronological ordering, canBeRetried truth tables)
Pre-existing tests touched:
- FormSubmissionActionFailureTest::test_can_be_retried_only_for_open_state
— updated to reflect Q2 closure (resolved now blocks too).
- Ws6FoundationMigrationTest::test_down_methods_clean_up_columns_and_table
— child table must drop before parent (FK constraint).
- 5 backfill test step-counts bumped +1 (new migration sits at top).
SCHEMA.md → v2.9. Schema dump regenerated.
Refs: RFC-WS-6.md §3 Q5 addendum, sessie 2 Q2
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RFC §4 V3 compliance — cross-tenant access to FormSubmissionActionFailure
endpoints returns 404, not 403, to prevent resource-existence
enumeration. The FormSubmissionActionFailurePolicy is the single tenant
gate; these tests assert the route-level integration end-to-end.
Production-code finding (in scope per "security gaps zijn altijd urgent"):
the orgIndex endpoint had a real IDOR gap. Original implementation called
`Gate::authorize('viewAny', ...)` which permits any org_admin in any org,
then filtered the result set by the URL's `{organisation}` param. orgB's
admin hitting `/organisations/{orgA}/form-failures` would get back orgA's
failures — leakage.
Fix:
- New policy method `viewAnyInOrganisation(User, Organisation)` that
requires super_admin OR org_admin on THIS specific organisation.
- Controller `orgIndex` calls `authorizeViewAnyInOrgOrNotFound()` which
translates a denied policy → 404 (matches the show/retry/resolve/dismiss
pattern).
- viewAny on the class level stays as the platformIndex gate (super_admin
+ any-org_admin enumeration is acceptable on the platform endpoint
because the role middleware already restricts to super_admin).
Test coverage (24 tests, all passing):
- 5 org-scoped endpoints × cross-tenant scenarios (all return 404)
- 5 platform endpoints × role-class scenarios (org_admin gets 403, never 404)
- Edge cases: soft-deleted parent submission, invalid ULID format,
non-existent ID, unauthenticated, authenticated-without-role on org
The 403 vs 404 distinction matters: role-gated endpoints return 403
(auth-class — "not allowed in this room"); ownership-gated endpoints
return 404 (IDOR-class — "this room doesn't exist for you").
Refs: RFC-WS-6.md §4 V3, ARCH-BINDINGS.md §8.2 (Task 3 of this session)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the manual `$request->route('formSubmissionActionFailure')` workaround
with type-hinted parameters. Implicit route model binding now resolves
FormSubmissionActionFailure correctly on both the platform admin route
(/admin/form-failures/{id}) and the org-scoped route
(/organisations/{organisation}/form-failures/{id}).
Root cause:
On the nested org-scoped route, Laravel's implicit binding triggers its
scoped-binding code path: for the second URL segment, it tries to resolve
the failure as a relation of the route's parent ({organisation}) by calling
`$organisation->formSubmissionActionFailures()`. Organisation has no such
relation (failures live under FormSubmission, not Organisation directly),
so the lookup silently fell through and the controller received a raw
string. PHP then raised a TypeError on the type-hinted parameter.
A second issue compounded it: with the controller method declaring
`(FormSubmissionActionFailure $formSubmissionActionFailure, ?Organisation $organisation)`
the parameter order did NOT match the URL parameter order
(/{organisation}/.../{formSubmissionActionFailure}), so Laravel's
resolveMethodDependencies — which falls back to positional binding when
parameter counts diverge — bound them to the wrong slots.
Fix:
- Register an explicit `Route::bind('formSubmissionActionFailure', ...)`
in AppServiceProvider that loads the model `withoutGlobalScopes()` and
throws ModelNotFoundException on miss. This sidesteps the scoped-binding
parent-relation lookup entirely.
- Add `->withoutScopedBindings()` to all four org-scoped routes (show,
retry, resolve, dismiss) as a belt-and-braces guarantee that Laravel
never enters the scoped-binding path for these nested routes.
- Reorder controller method signatures to put `?Organisation $organisation`
FIRST, matching URL parameter order so positional binding lands the
ULID strings on the correct method parameters.
- Drop the now-unused private `resolveFailure()` helper.
- Tenant scoping continues to be enforced by FormSubmissionActionFailurePolicy
via the failure.submission.organisation_id FK chain (RFC V3); cross-
tenant access still translates denied → 404, never 403.
Tests: all 9 controller tests pass (cross-tenant 404 contract verified for
view, dismiss, and resolve).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two route groups: /api/v1/admin/form-failures (super_admin platform) and
/api/v1/organisations/{organisation}/form-failures (org_admin scoped).
Same controller, policy authorises via FK chain (RFC V3). Cross-tenant
access returns 404 not 403 to prevent enumeration.
Resolve takes optional note; Dismiss requires DismissalReasonType
enum with conditional note (mandatory for 'other'). Both via
FormRequest validation with explicit i18n message keys.
Implementation note: Laravel implicit model binding for nested-namespace
ULID models doesn't pick up reliably across nested route groups. Using
manual resolveFailure() helper that loads withoutGlobalScopes() (so
cross-tenant access still reaches the policy, which translates denied →
404 per V3). Policy explicitly checks soft-delete via deleted_at since
withoutGlobalScopes bypasses SoftDeletes too. Policy registered
explicitly in AppServiceProvider — auto-discovery doesn't reliably
resolve App\Models\FormBuilder\* → App\Policies\FormBuilder\*.
NOT: admin UI (session 3). Not: public form routes (no API contract
notification needed).
Refs: RFC-WS-6.md §3 (Q5), §4 (V2, V3)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Atomic reader switch. All call paths that previously read
form_fields.options / form_field_library.options from the JSON column
now read through FormFieldOptionService::toJsonShape() via the
morphMany relation:
- FormFieldResource + FormFieldLibraryResource +
PublicFormSchemaResource emit the rich-shape array
- FilterRegistryController emits rich shape uniformly (no flat-array
carve-out for filter-UI compatibility — preflight scan confirmed
zero portal/app consumers, S5 territory)
- FormFieldRuleBuilder plucks values from the relation for in:options
rule construction
- FormSubmissionService::buildSnapshot writes rich-shape options into
snapshots and strips translations.{locale}.options from each field's
translations bag (defensive — commit 2 backfill already did the
bulk strip)
- Four FormFieldRequest variants accept array-of-spec-objects,
validate shape in after() via FormFieldOptionService::assertSpecsValid,
and hand off to FormFieldOptionService::replaceOptions for writes
- FormFieldService::create + update extract option specs from the
request data and route through the service after the FormField row
is persisted
FormField and FormFieldLibrary $casts no longer include 'options'; the
JSON column is no longer cast. Options removed from $fillable on both
models so ::create() / ::fill() / mass assignment can no longer touch
the legacy column. Both models gain a getOptionsAttribute() accessor
that resolves $model->options to the eager-loaded morphMany collection
— required because Eloquent's getAttribute() prefers a real DB column
over a relation method, and the JSON column lives on the table until
WS-5d commit 5 drops it.
Activity log — dual emit per §6.7 / §17.4.2 / §17.6.3:
- field.updated carries old.options / new.options diff via
toJsonShape() reconstruction, byte-equal JSON compare to avoid
cosmetic false positives. Field updates that don't touch options
omit the key entirely
- field.options_replaced emits inside replaceOptions() on FormField
subject only; library subject writes silent (mirrors the WS-5b /
WS-5c convention)
JSON columns (form_fields.options, form_field_library.options) remain
present but unread — column drops land atomically in commit 5.
Two pre-existing test fixtures that seeded options via the JSON column
(FormFieldApiTest + PublicFormValidationTest) migrated to the
spec-array path: FormField::factory()->withOptions([...]) where the
options live on the field, or explicit spec-array request bodies for
HTTP tests.
Tests: 1193 → 1206 green (+13 tests / +28 assertions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three byte-identical copies of `normaliseLegacyGroupShape` lived in
FormFieldService, StoreFormFieldRequest, and UpdateFormFieldRequest.
WS-5d (form_fields.options) would have been the fourth copy. Hoist
the helper to a single public static on FormFieldConditionalLogicService
and have all three call sites delegate.
Implementation:
- `FormFieldConditionalLogicService::normaliseLegacyShape(array)` —
pure recursive passthrough. Translates the ARCH §8 JSON group shape
(`{"all": [...]}` / `{"any": [...]}`) into the service's internal
`{"operator", "children"}` form. Does NOT validate; malformed shapes
return as-is and surface downstream as
`InvalidConditionalLogicSpecException` from `assertSpecsValid`.
- Group operator catalogue sourced from
`FormFieldConditionalLogicGroupOperator::values()` instead of an
`['all', 'any']` literal — single source of truth for future
operator additions.
- All three call sites switched to the static method. The two
FormRequests reach it via the existing `use` import; FormFieldService
sits in the same namespace.
Behaviour preserved exactly:
- Existing FormFieldApiTest (cyclic logic rejection),
FormFieldStrictConditionalLogicRequestTest (strict-validator
rejection paths), and FormFieldConditionalLogicServiceTest
(service-level paths) all green without modification.
New unit tests pin the passthrough contract (8 tests):
- Valid ALL / ANY translations
- Recursive nested-group translation (depth 2)
- Internal shape unchanged
- Condition leaf passthrough
- Unknown group key (`xor`) returned unchanged for downstream
`assertSpecsValid` to reject
- Empty array unchanged
- Non-array children stripped silently
Tests: 1150 → 1158 green (3110 → 3124 assertions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WS-5c commit 3 of 4. FormRequests (Store/Update) now reject bad
conditional_logic trees at the HTTP boundary — the `after()` hook
unwraps the `show_when` envelope, normalises legacy `{all|any: [...]}`
group shape to the service's internal form, and delegates to
`FormFieldConditionalLogicService::assertSpecsValid()`. Unknown
operators, root conditions, empty groups, and unknown field_slug
references produce a 422 with a readable error before any write.
`form_fields.conditional_logic` JSON column dropped. FormField model
`$fillable` and `$casts` no longer mention the column; factory default
no longer writes `null` to it. Snapshot fixtures in the dev seeder and
the legacy-forms migration command keep `conditional_logic` in their
snapshot JSON shape — that's the schema_snapshot contract, not the DB
column.
FormFieldController now maps InvalidConditionalLogicSpecException to
422 alongside FrozenSchemaException / CyclicDependencyException.
Rollback path: roll back WS-5c commits 1–3 together. Partial rollback
(drop-column reversed but backfill still applied) is not a supported
state — matching the WS-5a/b precedent on the family's full-rollback
contract.
Tests: 6 new (strict FormRequest rejection cases + JSON-column drop
assertion). Rollback step counts in WS-5a/b migration tests bumped +1
for the drop_conditional_logic_json_column migration. Baseline
1142 → 1148 green (3085 → 3099 assertions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WS-5c commit 2 of 4 — the service layer, backfill migration, and
read-path switch. Per addendum Q3, conditional_logic applies to
FormField only — no library mirror and no copyLogic on
FormFieldService::insertFromLibrary.
FormFieldConditionalLogicService owns every write:
- logicFor(field): depth-limited eager-load of the tree
- replaceLogic(field, tree): transactional structure + operator +
field_slug validation + cycle check + activity-log emit
(field.conditional_logic_replaced)
- toJsonShape(root): reconstructs the canonical ARCH §8
`{show_when: {...}}` shape — single source of truth for the
snapshot writer + API resources
- assertSpecsValid(tree): public boundary guard for the FormRequest
strict validator (WS-5c commit 3 wires this up)
- assertNoCycles(field, tree): contract preserved from
FormFieldService::assertNoConditionalCycle, implementation now
reads the relational adjacency.
Backfill migration translates pre-WS-5c conditional_logic JSON to
rows. Strict dispatch: unknown operators / unknown top-level keys /
malformed groups FAIL the migration — Phase A seed-scan confirmed
the catalogue parity, so any drift is a data bug to fix at source,
not silently absorb. Rollback rebuilds canonical JSON and clears
the relational tree.
FormFieldService.create/update route `conditional_logic` through
the new service (matching the extract-and-delegate pattern from
WS-5a bindings and WS-5b validation rules). Snapshot writer + both
resources (FormFieldResource, PublicFormSchemaResource) read via
`toJsonShape(rootConditionalLogicGroup())` — byte-for-byte parity
with the pre-WS-5c JSON contract.
InvalidConditionalLogicSpecException handled in FormFieldController
as 422, same as FrozenSchemaException / CyclicDependencyException.
Tests: 20 new under tests/Feature/FormBuilder/ConditionalLogic/
(service, cycle detection, backfill forward+rollback+failure cases,
snapshot + resource parity). FormFieldApiTest cyclic rejection test
rewritten to use the new factory state. Rollback step counts in
WS-5a/b migration tests bumped +1 for the new backfill migration.
Baseline 1122 → 1142 green (3032 → 3085 assertions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WS-5a commit 2 of 4.
FormFieldBindingService owns all writes to the relational binding table.
Validation against config/form_binding.php entity-column registry lives here
(ARCH §6.2).
FormFieldService::insertFromLibrary now calls copyBindings instead of
hydrating JSON — the Q3 row-copy mandate. Library and field bindings share
the same table; insertion is a row-clone operation.
Snapshot writer (FormSubmissionService::buildSnapshot) serialises bindings
via toJsonShape so schema_snapshot JSON keeps its ARCH §4.6.1 / §6.3
contract. No snapshot format change.
API resources source binding output from the relational table via the same
serialiser — external shape preserved.
Tests: service transactional behaviour, copyBindings preservation,
snapshot parity, API resource parity.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refactors OrganisationScope to support a declarative, recursive FK-chain
resolver and registers the scope on 14 models that previously relied on
caller-discipline for tenant isolation.
Scope resolver (app/Models/Scopes/OrganisationScope.php):
Models now declare their strategy via:
public static function tenantScopeStrategy(): array
{
return ['column' => 'organisation_id']; // terminal
// OR
return ['via' => FormSchema::class, 'fk' => 'form_schema_id'];
}
The apply() path walks the chain recursively, building whereIn subqueries
against parent models until it hits a column-based strategy. Max 3 hops;
deeper chains raise App\Exceptions\TenantScopeResolutionException. The
walker accepts BOTH the new tenantScopeStrategy() and the legacy
$organisationScopeColumn property at every hop — so PersonIdentityMatch
can chain via Person, which still uses the legacy event_id bridge, without
requiring Person/Event/Shift/FestivalSection/TimeSlot to migrate to the
new convention in this work package. That migration is a separate
backlog ticket — explicitly scope-controlled per the addendum.
Fourteen newly-scoped models:
Form-builder child models (D-03):
FormSchemaSection via FormSchema (1 hop)
FormField via FormSchema (1 hop)
FormSubmission column organisation_id (Commit 2)
FormValue via FormSubmission (1 hop)
FormValueOption via FormValue -> FormSubmission (2 hops)
FormSubmissionSectionStatus via FormSubmission (1 hop)
FormSubmissionDelegation via FormSubmission (1 hop)
FormSchemaWebhook via FormSchema (1 hop)
FormWebhookDelivery via FormSubmission (1 hop)
Event-data models (D-04 event-data subset):
ShiftAssignment via Shift (legacy festival_section_id)
ShiftWaitlist via Shift
VolunteerAvailability via TimeSlot (legacy event_id)
PersonSectionPreference via FestivalSection (legacy event_id)
PersonIdentityMatch via Person (legacy event_id)
Note — task directive specified VolunteerAvailability "via: Event, fk: event_id",
but the table has no event_id column (only person_id + time_slot_id).
Rerouted via TimeSlot, which carries the legacy event_id bridge; same
end result, correct FK.
Security-relevant callers made explicit:
PublicFormSchemaResource::toArray() now eagerly loads fields + sections
with withoutGlobalScope(OrganisationScope::class). Prior to this commit
the public form endpoint silently relied on those relations being
unscoped. The PublicFormCrossOrgScopeTest pre-existing assertions still
pass — behaviour unchanged, intent now explicit.
Test fix: FormSchemaApiTest::test_publish_sets_is_published_true was
flaky (factory randomly picked EVENT_REGISTRATION which requires
bindings). Pinned to USER_PROFILE for determinism; PurposeSchemaLifecycleTest
covers the binding-enforcement path.
Test flip: MultiTenancyTest::test_form_schema_webhook_is_not_globally_scoped
renamed to is_scoped_via_fk_chain and asserts the new behaviour: scope
filters by route org, withoutGlobalScope() still exposes cross-org rows.
The test's original purpose ("pin current behaviour so a future refactor
is intentional") is now satisfied by Commit 3 being that intentional
refactor.
Docs:
SCHEMA.md §3.5.11 Rule 5 — tenantScopeStrategy() convention documented;
the 14 newly-scoped models enumerated; link to addendum Q2.
ARCH-FORM-BUILDER.md §4.14 — new section "Multi-tenancy scope chain"
with the hop-count table for all 14 chains and the withoutGlobalScope
pattern for cross-org callers.
Tests: tests/Feature/MultiTenancy/ScopeLeakageTest.php — two orgs with
fully-populated record chains down to each of the 14 leaf models; asserts
scoped queries never cross, withoutGlobalScope still does. Plus: three-
hop chain (FormValueOption) explicitly exercised, legacy-column bridge
verified, over-deep chain raises TenantScopeResolutionException. 16 tests /
31 new assertions. Full suite: 1000 passed (2706 assertions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Q6 of ARCH-CONSOLIDATION-ADDENDUM-2026-04-24: the allowed
`form_submissions.subject_type` values are now derived from
`PurposeRegistry::allSubjectTypes()` instead of the parallel
`config/form_subjects.php` file.
- CreateFormSubmissionRequest validates `subject_type` against the
registry via constructor-injected PurposeRegistry.
- FormSubmissionController and FormValueService resolve the subject
FQCN through `Relation::getMorphedModel()` — the morph-map is the
single source of truth for alias → model mapping.
- `config/form_subjects.php` is deleted. `MorphMapAlignmentTest` keeps
the registry and morph-map aligned going forward.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reduces the FormPurpose vocabulary from 22 variants + a `custom` escape
to the seven v1.0 purposes registered in the new PurposeRegistry.
- Purge migration deletes any form_schemas row whose `purpose` is not
in the v1.0 set (cascades through form_fields, form_submissions,
form_values, form_value_options, form_schema_sections,
form_submission_section_statuses, form_submission_delegations,
form_schema_webhooks, form_webhook_deliveries via existing FK).
- Drop migration removes the `custom_purpose_slug` column + its index.
- Both migrations declare their `down()` as a hard failure — we do not
support reversing a purge (pre-launch, no production data).
- `FormPurpose` enum slims to the seven cases; the legacy helpers
(defaultSubmissionMode / defaultSubjectType / allowsPublicAccess)
now delegate to PurposeRegistry so callers keep working.
- FormSchema fillable / FormSchemaResource / StoreFormSchemaRequest /
UpdateFormSchemaRequest / FormSchemaFactory drop every reference to
`custom_purpose_slug` and the `custom` purpose.
- VerifyFormsDataIntegrity drops the custom-slug mismatch check and
sources the subject-type allow-list from PurposeRegistry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Informational hint on the confirmation page when the same email has
already submitted the form. Not a block — the submission proceeds
normally. Privacy-safe: only shown to the submitter themselves.
Scope: same form_schema_id only. Cross-form/cross-event detection
would leak info about other forms.
- New FormSubmissionDuplicateDetector service queries by
form_submissions.public_submitter_email (trim + case-insensitive)
scoped to the schema, status=submitted, excluding the current
submission. Errors are swallowed + logged so a detector failure
never blocks the submit response.
- PublicFormSubmissionController enriches the submit response by
setting a transient duplicate_submission_data attribute on the
submission before resource serialisation.
- PublicFormSubmissionResource serialises a duplicate_submission
block with count, first_submitted_at, plus backend-authored
Dutch title + body (plural-agreement + IntlDateFormatter for
"23 april 2026"-style long-form dates). Null when no priors,
no email, or detector error.
- DuplicateSubmissionHint.vue (warning-typed tonal VAlert) above
IdentityMatchBanner on FormConfirmation. Prefers backend copy
with Intl-based Dutch date fallback for safety.
- 16 new backend assertions across the detector and the full
submit-response flow; 5 new Vitest assertions for the hint.
Note on scope: spec suggested extracting email from values via
schema binding; the codebase's public flow captures submitter
email in a guaranteed column (public_submitter_email) populated
by the stepper's Contactgegevens step. Using that directly is
both simpler and more correct for the duplicate-by-submitter
semantic. When FORM-05's binding-based extractor lands, this
detector can migrate without changing its public API.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Seed AVAILABILITY_PICKER and SECTION_PRIORITY demo fields in the
event_registration showcase, and augment seedEchtFeesten with a
parent-level VOLUNTEER time slot pair + a standard registration-
visible section whose name duplicates a child section so the
PublicFormController dedup path is exercised end-to-end.
- Validate SECTION_PRIORITY value shape in FormValueService: arrays of
{ section_id, priority } with unique section_ids + priorities in 1..5,
max 5 entries, and section_ids scoped to the schema's event tree
(parent + children). Error envelope is the standard VALIDATION_FAILED
FieldValidationException shape so the portal renders errors next to
the field.
- Enrich admin-facing FormSubmissionResource with a nested identity_match
block mirroring the PublicFormSubmissionResource contract (status only;
leaves room for future matched_user_id / confidence).
- Lock in the FORM-05 stub contract with 6 tests against the existing
TriggerPersonIdentityMatchOnFormSubmit listener (no new listener was
needed — the current one already writes 'pending' for public
event_registration submissions per ARCH §31.1).
- 24 new backend assertions across seeder, shape validation, listener
state matrix, and resource serialisation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
S3a PR 1 frontend sends public_submitter_name and public_submitter_email
on draft saves (PUT) and final submit (POST /submit), but the matching
SavePublicDraftRequest and SubmitPublicSubmissionRequest did not whitelist
these fields — Laravel's validated() silently stripped them, preventing
mid-form name/email updates from persisting.
Align both form requests with StartPublicDraftRequest to accept the same
submitter fields with identical rules (string, max:150 / email, max:255,
nullable). Controller copies present keys onto the submission model and
saves when dirty, matching standard Laravel update() semantics — missing
keys leave prior values untouched.
Closes the backend gap identified in PR 1 smoke test.
Five models that the public form endpoints touch carry a global
OrganisationScope: FormSchema, Event, TimeSlot, FestivalSection,
PersonTag. The initial S2c implementation relied on the scope no-opping
because /public/forms/* has no `{organisation}` route parameter and
OrganisationScope::resolveOrganisationId returns null in that case.
That's accidentally-correct. Any middleware that sets an implicit org
context later (route model binding for platform admin, impersonation,
default-org fallback on an authed Sanctum session) would start
filtering public schema resolution by the wrong org.
- PublicFormTokenResolver: both FormSchema::query() calls now pass
withoutGlobalScope(OrganisationScope::class). public_token is
globally unique so this is safe.
- PublicFormController::timeSlots() / sections() / festivalEventIds():
Event, TimeSlot, FestivalSection queries all explicit now, including
the eager-loaded event relation on time-slots.
- PublicFormController::ownerEvent(): narrowed from
Event::withoutGlobalScopes() to withoutGlobalScope(OrganisationScope)
so future scopes (soft-delete, archived) aren't accidentally
stripped.
- PublicFormSchemaResource::availableTagsByCategory: same narrowing on
the PersonTag query.
PublicFormCrossOrgScopeTest pins the expectation — 4 cases hit every
public endpoint under a stashed foreign-org route parameter and assert
the owner-org data still surfaces. Verified the tests fail when the
fix is reverted (all 4 return `SCHEMA_NOT_FOUND` with the bypass
absent).
Full suite 893 → 897 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
S2c D5 completion: schema_version_at_open column + drift semantics.
- Migration 2026_04_22_100002 adds unsignedInteger schema_version_at_open.
Recorded by FormSubmissionService::createDraft at the moment the
portal first renders the form.
- PublicFormSubmissionResource.schema_drift now compares
schema_version_at_open vs schema_version_at_submit (or
schema.version for active drafts) so organiser edits during an
open draft surface as drift on subsequent PUT/submit responses.
- PublicFormSubmissionController::update routes through
FormSubmissionService::saveDraft so auto_save_count increments
and the FormSubmissionDraftUpdated event fires per PUT.
- bootstrap/app.php: FormRequest ValidationException on
/api/v1/public/forms/* is now re-wrapped into the D6 envelope with
code=VALIDATION_FAILED, so public endpoints emit one consistent
error shape regardless of layer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
S2c D2, D3, D4, D8 — the meat of the public API rewrite.
Draft / save / submit split (D4):
- POST /public/forms/{public_token}/submissions
Creates a draft. idempotency_key is now REQUIRED; second POST with
the same key returns the existing draft (HTTP 200 vs 201 for fresh).
UniqueConstraintViolationException caught for race-safe replay.
- PUT /public/forms/{public_token}/submissions/{submission_id}
Auto-save. Partial updates only — each PUT writes just the
slugs in the body. Status stays 'draft'; auto_save_count++.
- POST /public/forms/{public_token}/submissions/{submission_id}/submit
Final submission. Merges body values with already-saved values,
runs strict rule set against the merged map, then calls
FormSubmissionService::submit which fires the lifecycle events
(tag sync, identity match). Rate-limited per IP per token per hour.
Access rules: submission must belong to the resolved schema; status
must be 'draft' (409 SUBMISSION_ALREADY_SUBMITTED otherwise); schema
still accepting submissions.
Sub-endpoints (D2, D3):
- GET /public/forms/{public_token}/time-slots
Volunteer-only, festival-aware (parent + children). Reads straight
from TimeSlot model — no org-coupled service to extract from. Out:
{id, name, date, start_time, end_time, duration_hours, event_id,
event_name}.
- GET /public/forms/{public_token}/sections
show_in_registration=true, type=standard, deduplicated by name
across festival children.
Dynamic per-field validation (D8):
- FormFieldRuleBuilder builds Laravel rule arrays from form_fields.
strict() enforces is_required + in:options + type rules (email,
url, numeric, date, boolean, phone regex); relaxed() is the
auto-save variant that drops required-ness.
- StartPublicDraftRequest (required idempotency_key),
SavePublicDraftRequest (relaxed rules, values optional),
SubmitPublicSubmissionRequest (relaxed rules at body level — the
controller merges the body with saved values and runs the strict
validator on the full map so submit with an empty body still
passes when everything was auto-saved).
- FormValueService backs the request layer up with deeper enforcement
of validation_rules JSON (min/max/regex) + is_unique. Throws
FieldValidationException (422) which renders via the D6 envelope.
PublicFormTokenResolver centralises the grace-window logic; every
public endpoint resolves through it so the standardised exceptions
bubble uniformly.
Routes: 6 total under /public/forms/ (up from 2). Tests:
PublicFormApiTest's existing submit test retrofitted to the three-step
flow; 857 tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
S2c D1, D5, D7.
PublicFormSchemaResource:
- Carries available_tags on every TAG_PICKER field, respecting
validation_rules.tag_categories when set. Tags prefetched once per
org to avoid N+1.
- Surfaces the schema version and an opened_at timestamp so the portal
can detect drift between GET-time and submit-time.
PublicFormSubmissionResource (new): the submission envelope the portal
sees on every lifecycle endpoint. No PII echo — public_submitter_name,
public_submitter_email, public_submitter_ip never appear. Admin
metadata (review_status, reviewed_by, schema_snapshot, submitter user
id) is filtered out. Includes schema_drift computed from
schema.version vs schema_version_at_submit, and identity_match read
from the §31.1 listener's column with a Dutch message per state
(pending|matched|none).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 5 of S2b. Ten thin controllers plus route registration under the
existing organisations/{organisation} prefix and two unauthenticated
public endpoints.
Controllers (api/app/Http/Controllers/Api/V1/FormBuilder/):
- FormSchemaController: CRUD + duplicate/publish/unpublish/rotate-token/
edit-lock. Returns 410 via PublicFormController when a rotated token is
past its 7-day grace window.
- FormFieldController: CRUD + reorder + insert-from-library. 422 on
binding-change / frozen / cyclic conditional_logic.
- FormSubmissionController: index/store/show/submit/destroy.
- FormValueController: bulk upsert draft values; 403 when
FieldAccessService rejects a write.
- FormSubmissionReviewController, FormSubmissionDelegationController.
- FormTemplateController, FormFieldLibraryController (deactivate on
DELETE for is_active records).
- FormSchemaWebhookController (url/secret never leak — only url_host +
has_secret in responses).
- FilterRegistryController: cached entity_column + tags + form_field
source list for Personen-module (ARCH §7.3–§7.5).
- PublicFormController: GET schema + POST submission. Turnstile captcha
for public_complaint/public_press_request. Rate-limited per
IP+public_token. 410 when token expired.
Routes: grouped under organisations/{organisation}/forms/ for auth'd
routes and public/forms/{public_token}/... with throttle:30,1 for the
public pair. Policies auto-discovered from the namespaced location.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4 of S2b. Nine resources that shape the universal form builder
responses. FieldAccessService::filterVisibleFields gates every field
array — the primary defence tested by FormResourceSecurityTest (§22.9).
- FormSchemaResource: includes fields_count, submissions_count,
has_submissions, is_locked (derived from edit_lock_*), public_form_url
when public_token is set, and filtered fields collection.
- FormSchemaSummaryResource: lean list-endpoint variant.
- FormFieldResource: effective_label / help_text / options resolved via
FormLocaleResolver + translations JSON, plus TAG_PICKER available_tags
filtered by validation_rules.tag_categories.
- FormSubmissionResource: values keyed by field slug with FieldAccessService
filtering, section_statuses, active delegations, review_info,
submitted_in_locale, submission_duration_seconds.
- FormSubmissionSummaryResource: lean list variant.
- FormTemplateResource, FormFieldLibraryResource.
- PublicFormSchemaResource: strictly limited per §10 — only
is_portal_visible=true AND is_admin_only=false fields, no PII hints,
no role_restrictions, no submissions_count.
- FormSchemaWebhookResource: url/secret never returned; only url_host +
has_secret boolean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3 of S2b. Six policies and fifteen form requests for the universal
form builder. Every exists: rule is scoped to the route's organisation
or form_schema to close the A01-5..18 findings from SECURITY_AUDIT.md.
Policies (api/app/Policies/FormBuilder/):
- FormSchemaPolicy, FormFieldPolicy, FormFieldLibraryPolicy,
FormTemplatePolicy, FormSubmissionPolicy, FormSchemaWebhookPolicy.
- FormSubmissionPolicy honours subject-self (user / person.user_id
match / submitted_by_user_id) and active delegations, per §18.3.
- No `return true` placeholders — each method checks org membership and
role via Spatie's hasRole().
Form Requests (api/app/Http/Requests/Api/V1/FormBuilder/):
- Schema: Store/UpdateFormSchemaRequest, RotatePublicTokenRequest.
- Fields: Store/UpdateFormFieldRequest, ReorderFormFieldsRequest (field
ids scoped to the route schema), InsertLibraryFieldRequest (library
scoped to the route organisation).
- Templates: Store/UpdateFormTemplateRequest.
- Field library: Store/UpdateFormFieldLibraryRequest.
- Submissions: CreateFormSubmissionRequest, UpsertFormValuesRequest
(slug allow-list derived from schema), SubmitFormSubmissionRequest,
ReviewFormSubmissionRequest, DelegateFormSubmissionRequest (delegatee
scoped to organisation pivot).
- Webhooks: Store/UpdateFormSchemaWebhookRequest.
- Public: PublicSubmissionRequest (captcha_token collected here,
enforcement in controller per config('form_builder.captcha')).
All enum validation routes through the existing PHP enums from S1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GET /organisations/{organisation}/dashboard-stats returns members,
events (with status breakdown + active count), persons, the first five
members sorted by join date, and the five most recent activity log
entries. Business logic lives in OrganisationDashboardService; access
follows OrganisationPolicy@view.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add contact_name, contact_email, phone, website columns. Wire the new
fields through the Organisation model, update request validation,
response resource, and the TypeScript Organisation interface. Needed by
the upcoming dashboard + form-builder binding registry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Missing from UpdateEventRequest rules, so the fields were stripped from
validated() and the uploaded URLs never persisted — the preview showed
briefly in the upload component but disappeared on reload because the
event record still had null.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Restructure field editor dialog: move Options section to bottom with
divider and subheader, fix delete button with flex layout
- Change tag_category (single string) to tag_categories (JSON array)
supporting multiple category selection in tag picker fields
- Portal tag picker now groups tags by category with subheaders
- Add generic file upload endpoint (FileUploadService + UploadController)
- Replace email branding logo URL text field with ImageUploadField
- Update Partner crowd type default icon to tabler-affiliate
- Apply changes consistently to both field and template dialogs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the per-field `section` text property with a dedicated HEADING field type that
organizers add as a separate block for visual grouping. Also fixes duplicate heading bug
on portal radio fields, replaces cramped VBtnToggle with VSelect for field width, and
adds grouped field type dropdown with structure/input categories.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add configurable column widths (full/half) and optional descriptions
for radio/select/checkbox options on registration form fields.
- Migration adds display_width column to both tables
- FieldDisplayWidth enum with smart defaults per field type
- normalized_options accessor for backwards-compatible option format
- Portal form renderer uses display_width for VRow/VCol grid layout
- Radio/select/checkbox options render with descriptions
- Admin field editor supports display_width toggle and description input
- System templates updated with appropriate widths and descriptions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Removes password from the volunteer registration form. Account
creation is now deferred to the approval step:
Backend:
- Registration creates Person without User (user_id=null)
- On approval, system finds or creates User by person.email
- New accounts get a "set password" email with activation link
- Existing accounts get a portal link email
- Added registration_source column to persons (self/organizer)
- Fuzzy name matching skipped for self-registered persons
- person.email is always source of truth for account linking
Frontend:
- Registration form no longer collects password
- Email check shows info alert with login suggestion
- New wachtwoord-instellen.vue page for account activation
- PasswordRequirements.vue component (reused on reset page)
- Success page updated with activation messaging
Tests: 837 passed (all updated for new flow)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Banner: white elevated button for contrast, fixed 48px height,
layout top padding offset so content isn't obscured
- Middleware: allow GET me/profile (viewing), block mutations only;
add auth/refresh to blocked routes
- Navigation: hide Platform section during impersonation; hide
org-dependent items when impersonated user has no organisation
- Test: add read-only routes allowed test, auth/refresh blocked test
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SECURITY: A user with MFA enabled could bypass the MFA challenge by
using a pre-existing auth cookie from a previous session.
Vulnerability chain:
1. Auth::attempt() in LoginController created a Laravel session
(unnecessary side effect — only credential validation was needed)
2. When MFA was required, the response did NOT revoke existing
Sanctum tokens or expire the auth cookie
3. If the MFA session expired, the user could navigate directly to
any page and the old auth cookie would authenticate them
Fixes:
- Replace Auth::attempt() with Hash::check() — no session created
- Revoke ALL existing Sanctum tokens when MFA is required, so old
sessions cannot bypass the challenge
- Expire the auth cookie in the MFA-required response via
forgetAuthCookie(), ensuring the browser discards stale tokens
- Auth is now ONLY issued after successful MFA verification in
MfaVerifyController
New security tests (11 added):
- MFA login returns no auth token or user data
- MFA login expires the auth cookie
- MFA login revokes all existing tokens
- Old token returns 401 after MFA login
- MFA session token cannot be used as Bearer token
- MFA session consumed after successful verify (no replay)
- MFA session survives failed verify (user can retry)
- Auth cookie only issued on successful MFA verify
- MFA session expires after TTL (10 minutes)
- Email codes consumed after use (no replay)
- Trusted device expires after 30 days
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds the ability for users to change their preferred/primary MFA method
when both TOTP and email are available.
Backend:
- Add PUT /auth/mfa/preferred-method endpoint with validation
(method must be totp/email, MFA must be enabled, TOTP must be
configured if selecting totp)
- Add totp_configured and email_configured fields to MFA status
endpoint (totp = has secret + enabled, email = always when enabled)
- Fix setupEmail() to preserve mfa_secret so TOTP config survives
when email is set up as a second method
Frontend (organizer + portal):
- Add useSetPreferredMethod() composable to useMfa.ts
- Add totp_configured/email_configured to MfaStatus type
- SecurityTab method cards now show "Primaire methode" chip on the
preferred method and "Als primair instellen" button on the other
- Portal security section shows per-method rows with status chips
and primary switching
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root cause: the MFA status endpoint returned `mfa_enabled` as the JSON
key but the TypeScript MfaStatus interface expected `enabled`. At
runtime, `mfaStatus.value?.enabled` was always `undefined`, so
`isEnabled` was always false — the banner never hid and the method
cards never showed "Geconfigureerd".
Additionally, the auth store had no way to re-fetch /auth/me after
initialization, so `mfaSetupRequired` was never properly refreshed
from the backend after MFA setup.
Fixes:
- Rename `mfa_enabled` → `enabled` in the MFA status endpoint response
to match the TypeScript type (and the /auth/me MeResource which
already used `enabled`)
- Add `refreshUser()` to the auth store for post-initialization
re-fetching of /auth/me
- Call `refreshUser()` in onSetupCompleted so the store reflects the
backend state without a full page reload
- Update backend tests to match the renamed response key
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three verification methods (TOTP authenticator, email code, backup codes),
trusted device management with 30-day expiry, role-based enforcement for
super_admin and org_admin, admin reset capability, and full test coverage
(46 tests). Modifies login flow to support MFA challenge/response with
temporary session tokens stored in cache.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds the full transactional email system:
- Redis queue (QUEUE_CONNECTION=redis), SES config in .env.example
- 3 migrations: organisation_email_settings, organisation_email_templates, email_logs
- EmailTemplateType and EmailLogStatus enums with Dutch defaults
- EmailService as central entry point for all email sending
- SendTransactionalEmail queued job with retries and idempotency
- TransactionalMail mailable with responsive HTML + plain text templates
- Organisation-level branding (colors, logo, footer, reply-to)
- Per-type template overrides with {variable} substitution
- Email log with filtering by status, type, date range, recipient
- Preview and send-test endpoints for template management
- API endpoints: email-settings, email-templates (CRUD), email-logs (read-only)
- Integrated into existing flows: invitations, password reset, email
verification, registration approval/rejection
- 37 new tests across 4 test files, all existing tests updated
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add member management to the platform admin organisation detail page:
- Backend: invite (creates invitation or directly adds existing user),
remove member, update member role endpoints on AdminOrganisationController
- Backend: show endpoint now returns members alongside organisation data
- Frontend: members table with inline role editing, invite dialog,
remove confirmation dialog on /platform/organisations/[id]
- Tests: 7 new tests covering happy paths and edge cases (self-removal,
existing member, non-super_admin denied)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
LoginController used UserResource (returns `roles`) but the frontend
authStore.setUser() expects MeResponse format with `app_roles`. After
login, appRoles was set to undefined, making isSuperAdmin always false.
Combined with isInitialized staying true after the initial failed
/auth/me call, the correct /auth/me was never re-fetched after login.
Fix: use MeResource in LoginController (same as MeController) so the
login response includes app_roles, permissions, and portal_events.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>