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>
Rebuilds the tag-sync flow purged in S2a, now listener-driven against the
universal FormBuilder (ARCH §31.10).
- SyncTagPickerSelectionsOnSubmit listener: ShouldQueue on connection=redis
queue=default. Filters to event_registration + person subjects with at
least one TAG_PICKER form_value. Logs on failure, never rethrows so
sibling listeners keep running.
- AppServiceProvider registers the listener via Event::listen alongside
the existing S1 observers.
- PersonIdentityService::confirmMatch now calls
FormTagSyncService::rebuildForPerson after setting person.user_id — the
deferred-sync path for persons who filled in TAG_PICKER fields before
their account was linked.
- ARCH-FORM-BUILDER.md §31.10 rewritten with the authoritative contract
block from this session. Header bumped to v1.2.1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UserProfileTest: belongs-to user, fillable/non-fillable boundaries,
settings cast, lastSubmittedAt accessor (null + max from user-subject
submissions only, ignoring drafts and is_test rows).
FormSchemaTest: ULID PK, OrganisationScope filtering, polymorphic owner
resolution to Event, purpose enum cast, hasMany fields/submissions, and
logSchemaChange() actually creates an activity-log entry.
FormFieldTest: belongs-to schema, field_type stored as string (not DB
enum), binding/translations array casts, hasMany values, soft-delete
preserves historical values, logFieldChange() creates an entry.
FormSubmissionTest: belongs-to schema, polymorphic subject resolution,
status enum cast, schema_snapshot array cast, hasMany values.
FormValueTest: belongs-to submission/field, value array cast, hasMany
options pivot rebuilt by observer, unique-pair DB constraint enforced.
MultiTenancyTest: OrganisationScope correctly filters FormSchema /
FormTemplate / FormFieldLibrary by route-resolved organisation. Pins
the FormSchemaWebhook un-scoped behaviour explicitly so a future scope
addition is an intentional decision, not an accident.
MigrationRollbackTest (group 'slow'): full migrate:fresh → rollback 14
S1 steps → assert all 13 form-builder tables dropped + legacy tables
intentionally retained → re-migrate and assert table list matches
snapshot. Plus a separate test exercising the populate-user-profiles
migration's down().
Supporting tweaks:
- UserProfile::lastSubmittedAt accessor now returns Carbon|null instead
of a raw timestamp string — testable, and matches Eloquent convention.
- UserProfileFactory cooperates with UserObserver via newModel override
(updates the auto-created row instead of inserting a duplicate).
- AppServiceProvider morph map extended with all 12 form-builder model
keys so logSchemaChange/logFieldChange resolve under enforceMorphMap.
Suite: 945 passed (was 911), 2671 assertions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
FormValueObserver: value_indexed is filter-driven per ARCH §4.4, not
hint-driven. Populating it for every string-hint field produced dead
weight in the partial index and made FilterQueryBuilder logic murkier.
Behaviour after fix:
hint=string, is_filterable=true → populate value_indexed
hint=string, is_filterable=false → leave null
hint=number/date/bool, any filterable → populate typed column (unchanged)
hint=json, any filterable → leave typed columns null (unchanged)
value_number / value_date / value_bool remain hint-driven — they serve
display and sorting beyond filtering. Only value_indexed is gated.
VerifyFormsDataIntegrity: "value_indexed set on non-filterable field"
is now a FAIL (was WARN) — it means the observer didn't run correctly,
which is a real integrity issue.
Observer tests: split the old "string hint populates value_indexed"
case into filterable/non-filterable pair. Full suite 911/911.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds UserObserver::created() that firstOrCreate's a user_profiles row
for every User. Registered in AppServiceProvider alongside PersonObserver.
Covers DevSeeder (3 scattered User::create sites: DatabaseSeeder super admin,
DevSeeder org staff, DevSeeder volunteer users) and all future creation
paths (invite/register/import) with zero per-caller boilerplate.
New FormBuilderDevSeeder seeder class holds canonical 16-field registration
template (borrowed from the legacy RegistrationFieldTemplateService list so
test data stays recognisable). Produces per-org:
- 16 form_templates (system, schema_snapshot per ARCH §4.6.1)
- 1 FormSchema per event (event_registration, owner=event, draft_single
mode, is_published mirrors event.status lifecycle)
- 16 FormFields per schema
- 1 FormSubmission per person whose status ∈ applied/approved/no_show
(same rule as MigrateLegacyFormsData), with 6 realistic FormValues each
DevSeeder::run() now wraps the whole seed body in
ActivityLog::suppressed(...) so the ~80 field creates + ~277 submission
lifecycle triggers don't flood activity_log. Also removes the legacy
RegistrationFieldTemplateService::seedSystemTemplates call — the 16
system templates now land directly in form_templates.
Post-seed totals (dev DB):
5 form_schemas, 80 form_fields, 277 form_submissions, 1662 form_values,
16 form_templates, 270 user_profiles (1:1 with users).
forms:verify-data-integrity on freshly seeded DB: exit 0.
php artisan test: 910/910.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
forms:migrate-legacy-data {--dry-run} {--verify-only}
Per-org transaction (outer loop); inside each org, one form_schema per
distinct event_id in registration_form_fields, one form_field per legacy
field (with lowercase→uppercase field_type mapping and PII heuristic),
one form_submission per distinct person_field_values author, one form_value
per legacy row. form_templates derive schema_snapshot in ARCH §4.6.1 shape.
Idempotent via existence checks; skips if registration_form_fields absent.
Wrapped in App\Support\ActivityLog::suppressed() so --dry-run and re-runs
don't storm the activity log.
forms:verify-data-integrity {--strict}
Nine coherence checks: schemas/fields/submissions/values/user_profiles
structure, data migration counts (skipped when legacy tables absent),
orphans, section/schema relation consistency, and strict reachability
(opt-in). Runs all checks to completion; exit 1 on any failure.
Validates binding JSON against config/form_binding.php registry and
field_type against FormFieldType::values() ∪ custom_field_types config.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4 of S1.
Models (app/Models/FormBuilder/): FormSchema, FormSchemaSection, FormField,
FormSubmission, FormValue, FormValueOption, FormTemplate, FormFieldLibrary,
FormSchemaWebhook, FormWebhookDelivery, FormSubmissionSectionStatus,
FormSubmissionDelegation. Plus UserProfile at app/Models/ (user-universal).
OrganisationScope applied on: FormSchema, FormTemplate, FormFieldLibrary.
FormSchemaWebhook documents inherited-scope discipline (OrganisationScope's
strategies — organisation_id/event_id/festival_section_id — don't cover
form_schema_id; direct queries would leak across orgs, so must go via
$schema->webhooks()).
User::profile()/getOrCreateProfile(), Event::formSchemas() (morphMany),
Person::formSubmissions() (morphMany).
Morph map enforced in AppServiceProvider with 28 keys covering every model
that appears as activitylog subject/causer. Also updated
OrganisationDashboardService (and its test) to query activitylog via
getMorphClass() instead of FQCN.
Activity log strategy: nuanced explicit calls (logSchemaChange on FormSchema,
logFieldChange on FormField) — no LogsActivity trait. Suppression for bulk
fixtures via App\Support\ActivityLog::suppressed(fn() => ...) which flips
config('activitylog.enabled') around a callback. Both our explicit calls
and spatie's trait on Organisation respect the flag via ActivityLogger::log().
FormValueObserver (app/Observers/FormBuilder/) populates value_indexed/
value_number/value_date/value_bool on save per field.value_storage_hint,
rebuilds form_value_options pivot on multi-value filterable fields, cleans
up on delete. Memoised field cache avoids N+1. Registered in AppServiceProvider.
9 lightweight event classes (app/Events/FormBuilder/) as SerializesModels
containers — submission lifecycle signatures lock in for S2 services, no
listeners yet.
Factories for all models with Dutch fake data (fake('nl_NL')). FormSchema
factory uses defaultSubmissionMode(); FormField factory uses
recommendedValueStorageHint().
Tests: 9 new observer tests (all pass); full suite 910/910 (up from 901).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
9 backed string enums covering purpose, field type, submission status/mode/review,
field width, value storage hint, snapshot mode, webhook delivery status.
FormPurpose/FormFieldType include helper methods per ARCH §3/§5. All with
declare(strict_types=1) and values() helpers for validation rules.
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 spatie/laravel-activitylog LogsActivity trait tracking per-field
dirty changes on name, slug, contact_name, contact_email, phone, and
website. Log name "organisation", skip empty logs. Used by the dashboard
recent-activity feed.
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>
When MFA was already enabled and the user clicked "Opnieuw instellen"
on the TOTP card, setupTotp() unconditionally set mfa_confirmed_at to
null. If the user then cancelled the dialog without confirming, the
login controller's check `mfa_enabled && mfa_confirmed_at` evaluated
to false (true && null), silently skipping the MFA challenge.
Fix: only set mfa_method and mfa_confirmed_at when MFA is not yet
enabled (first-time setup). For re-setup or adding TOTP as a second
method, only rotate the mfa_secret — matching the guard already
applied to setupEmail().
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>
Restructure the festival hierarchy end-to-end:
Seeder: Remove duplicate festival-level VOLUNTEER time slots, keep only CREW
operational slots. Rename sub-events to "Dag 1/2/3 — ..." pattern. Change
Nachtsecurity to Security (cross_event). EHBO/Security shifts now use sub-event
time slots via cross_event exception. Add flat event "Braderie Dorpstown 2026".
API: Add ?include_children=true to TimeSlotController for festivals, returning
all sub-event time slots with source and event_name fields. Update
StoreShiftRequest and UpdateShiftRequest to accept child time slots for
cross_event sections.
Frontend: Create useTimeSlotDropdown composable with 4-scenario dropdown logic.
Replace AppSelect with VAutocomplete in CreateShiftDialog with grouped items,
dimmed festival slots, and info tooltips. Add InfoTooltip reusable component.
Show festival context labels on cross_event sections in sub-event section lists.
Add read-only festival time slots on sub-event time-slots page. Add cross_event
context banner with "Bekijk alle diensten" link.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds two new API endpoints to quickly add organisation members as event
persons with user_id pre-linked and status approved:
- GET /organisations/{org}/members/available-for-event/{event}
- POST /organisations/{org}/events/{event}/persons/from-member
Includes frontend dialog with member search, crowd type selection, and
click-to-add behavior in the Personen tab.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The approve() and bulkApprove() methods in ShiftAssignmentService
hard-blocked with a 422 when all slots were filled. This was incorrect
for organiser actions — only volunteer claims (portal self-service)
should enforce capacity limits. Organiser assign() already allowed
overbooking, making the approve block inconsistent.
Changes:
- Remove capacity hard-block from approve() and bulkApprove(), replace
with audit log entry (shift.overbooked_approval)
- Add overbook confirmation dialog in ShiftDetailPanel before approving
a full shift (single + bulk approve)
- Add onError handlers to all mutations in ShiftDetailPanel (approve,
reject, cancel, bulk-approve) so errors display in the snackbar
- Add global 422 validation error display in axios interceptor via
useNotificationStore as safety net for all components
- Add PHPUnit test for approve-when-full scenario
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root cause: browsers don't scope cookies by port. With SESSION_DOMAIN=
localhost, all three SPAs share cookies. The CookieBearerToken middleware
iterated all cookie names and picked the first match, so logging into
the organizer app (port 5174) also authenticated the portal (port 5175).
Fix: CookieBearerToken now resolves the correct cookie name from the
Origin header (same logic as SetAuthCookie trait). It only reads the
cookie matching the requesting app — portal origin reads only
crewli_portal_token, app origin reads only crewli_app_token, etc.
Falls back to first-available cookie when no Origin header is present
(server-to-server requests, tests without explicit Origin).
Added 3 cross-app isolation tests:
- app cookie does NOT authenticate portal requests
- portal cookie does NOT authenticate app requests
- correct cookie + matching origin = authenticated
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
API middleware:
- SecurityHeaders now sets Content-Security-Policy from config/security.php
- Default API policy: "default-src 'none'; frame-ancestors 'none'"
- Supports report-only mode via CSP_REPORT_ONLY env var
- Policy value configurable via CSP_POLICY env var
Nginx deployment configs (deploy/nginx/):
- security-headers.conf: shared headers for all server blocks
- csp-api.conf: restrictive JSON-only policy for api.crewli.app
- csp-spa.conf: SPA policy for app/admin (self + unsafe-inline styles)
- csp-portal.conf: portal policy matching SPA
Development:
- CSP meta tags added to all three index.html files
- Includes 'unsafe-inline' + 'unsafe-eval' for Vite HMR/loader script
- Each app allows its own ws:// port for HMR websocket
Resolves security finding A13-9.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Backend:
- CookieBearerToken middleware reads httpOnly cookie and injects Authorization
header before Sanctum validates (prepended to API middleware group)
- SetAuthCookie trait provides cookie creation/expiry helpers with per-app
cookie names (crewli_admin_token, crewli_app_token, crewli_portal_token)
- LoginController sets token via Set-Cookie, removes it from JSON body
- LogoutController expires the auth cookie on logout
- AuthRefreshController (POST /auth/refresh) rotates tokens with new cookie
- InvitationController accept also sets token via cookie, not JSON body
- All cookies: httpOnly, SameSite=Strict, Secure (in production)
Frontend (all three SPAs):
- Removed all localStorage token storage (apps/app, apps/portal)
- Removed all JS-readable cookie token storage (apps/admin)
- Removed Authorization: Bearer header interceptors from axios
- Auth stores now rely on GET /auth/me to validate httpOnly cookie
- Admin app: new Pinia auth store replaces useCookie-based auth pattern
- withCredentials: true ensures browser sends cookies automatically
Fixes security findings A13-1 (localStorage tokens) and A13-2 (admin
cookie flags). Tokens are now invisible to JavaScript.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Password reset: multi-app support with custom notification linking to correct
frontend (app/portal/admin). Email change: self-service with password
confirmation and admin-initiated, both sending verification to new address
with 24h expiry. Confirmation sent to old email on completion. Password
change: authenticated endpoint revoking other sessions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
GET /portal/my-shifts aggregates shift assignments across all events
the logged-in user is linked to via Person records. Groups by event
then date, showing only active assignments (approved/pending_approval)
for approved/pending persons.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a pending identity match is detected after volunteer registration,
the API now returns has_existing_account in the response. The success
page shows a login suggestion card so the volunteer can link their
registration to their existing account.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implements the full identity matching engine: email matching (HIGH confidence),
fuzzy name matching with Levenshtein distance (MEDIUM confidence, upgradable to
HIGH with DOB tiebreaker), manual link/unlink, revert confirmed matches, and
automatic detection via PersonObserver. Includes 33 comprehensive tests, frontend
integration with confirm/dismiss/unlink UI, and match indicators in the persons list.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move all authenticated organiser-facing event sub-resource routes from
/events/{event}/... to /organisations/{organisation}/events/{event}/...
to enforce multi-tenancy at the routing layer.
Changes:
- Routes: restructured api.php to nest all event sub-resources under
the existing organisation prefix group
- Controllers: added Organisation parameter and VerifiesOrganisationEvent
trait to all 12 affected controllers (sections, time-slots, shifts,
persons, crowd-lists, locations, shift-assignments, registration-fields,
availabilities, field-values, section-preferences, stats)
- Tests: updated all 20 feature test files with new route paths
- Frontend: updated 8 API composables and 20 Vue components/pages
- API.md: updated documentation to reflect new route structure
Portal routes, public routes (volunteer-register), and invitation routes
remain unchanged as they operate without organisation context.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Token generation:
- Replace Str::ulid() with bin2hex(random_bytes(32)) for 256-bit entropy
- Store SHA-256 hash in database, never plaintext tokens
- Hash input before lookup on all token endpoints
Invitation tokens:
- InvitationService: generate crypto random, store hash, pass plain
token transiently for email URL via UserInvitation::$plainToken
- InvitationController show/accept: hash input before DB lookup
- AcceptInvitationRequest: hash token before invitation lookup
- Migration: widen user_invitations.token and artists.portal_token
from char(26) to char(64) for SHA-256 hex digests
Portal token auth:
- PortalTokenController: remove Schema::hasTable() runtime checks,
hash token before lookup, return shaped response via PortalEventResource
instead of raw model data
- Create PortalEventResource (name, dates, status only — no internals)
- Handle missing production_requests table gracefully via try/catch
Portal token middleware:
- Implement full token validation: extract from Bearer header or ?token=
query param, hash, look up in artists/production_requests, verify
event exists and is not draft/closed, set portal context on request
- Return generic 401 on any failure (no information leakage)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add throttle middleware to login (5/min), portal/token-auth (10/min),
volunteer-register (5/min), and invitation routes (10/min)
- Set Sanctum token expiration to 7 days
- Remove billing_status from UpdateOrganisationRequest (super_admin only)
- Revoke all Sanctum tokens on password reset
- Strengthen password rules: min 8 chars, mixed case, numbers
- Create SecurityHeaders middleware (X-Content-Type-Options, X-Frame-Options,
HSTS, Referrer-Policy, Permissions-Policy)
- Fix open redirect on all 3 login pages (validate ?to= starts with /)
- Set APP_DEBUG=false in .env.example
- Log failed login attempts with email, IP, user-agent
- Log authorization failures (403) with user, IP, path, method
- Harden mass assignment: remove user_id from Person, audit fields from
ShiftAssignment, system fields from UserInvitation $fillable
- Replace real DB records with factory make() in mail preview routes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix session persistence: add loading state to App.vue, hydrate portal store
in router guards so page refresh preserves auth + event context
- Fix shift visibility for festivals: query child event time slots so shifts
on sub-events appear in the portal
- Add profile page with editable personal info and password change
- Add backend endpoints: PUT /portal/profile and PUT /portal/password
- Fix registration form: make first_name/last_name editable for logged-in users
- Restyle login page: remove Vuexy illustration, center form with Crewli branding
- Improve dashboard StatusCard with action cards, icons, and upcoming shift count
- Enhance shift cards with status border colors and availability progress bars
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>