From a2760ffd64ad200458ba91b8c0de1dc9894deeb2 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Tue, 5 May 2026 21:15:10 +0200 Subject: [PATCH] feat(auth): add contexts + platform.is_super_admin to /auth/me, factory role-category states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- api/app/Http/Resources/Api/V1/MeResource.php | 92 +++++++++++++++---- api/database/factories/UserFactory.php | 47 +++++++++- api/tests/Feature/Auth/AuthMeShapeTest.php | 96 ++++++++++++++++++++ apps/app/src/pages/forbidden.vue | 23 +++++ apps/app/typed-router.d.ts | 1 + dev-docs/BACKLOG.md | 32 +++++++ 6 files changed, 272 insertions(+), 19 deletions(-) create mode 100644 api/tests/Feature/Auth/AuthMeShapeTest.php create mode 100644 apps/app/src/pages/forbidden.vue diff --git a/api/app/Http/Resources/Api/V1/MeResource.php b/api/app/Http/Resources/Api/V1/MeResource.php index 76c1328e..1ac9b1c0 100644 --- a/api/app/Http/Resources/Api/V1/MeResource.php +++ b/api/app/Http/Resources/Api/V1/MeResource.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Resources\Api\V1; use App\Models\Person; +use App\Models\User; use App\Services\MfaService; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -13,6 +14,11 @@ final class MeResource extends JsonResource { public function toArray(Request $request): array { + /** @var User $user */ + $user = $this->resource; + + $contexts = $this->resolveContexts($user); + return [ 'id' => $this->id, 'first_name' => $this->first_name, @@ -25,27 +31,32 @@ final class MeResource extends JsonResource 'locale' => $this->locale, 'avatar' => $this->avatar, 'email_verified_at' => $this->email_verified_at?->toIso8601String(), - 'organisations' => $this->whenLoaded('organisations', fn () => - $this->organisations->map(fn ($org) => [ - 'id' => $org->id, - 'name' => $org->name, - 'slug' => $org->slug, - 'role' => $org->pivot->role, - ]) + 'organisations' => $this->whenLoaded('organisations', fn () => $this->organisations->map(fn ($org) => [ + 'id' => $org->id, + 'name' => $org->name, + 'slug' => $org->slug, + 'role' => $org->pivot->role, + // Forward-compatible array form. The pivot stores a single + // role today; B2a emits it as a 1-element array so the + // frontend can treat the field as multi-role from day one. + // Multi-role pivot resolution is tracked in BACKLOG.md as + // TECH-PIVOT-ROLES-MULTI (ARCH discussion, not just a + // schema-column expansion). + 'roles' => [$org->pivot->role], + ]) ), 'app_roles' => $this->getRoleNames()->values()->all(), 'permissions' => $this->getAllPermissions()->pluck('name')->values()->all(), - 'portal_events' => $this->whenLoaded('persons', fn () => - $this->persons->map(fn (Person $person) => [ - 'event_id' => $person->event_id, - 'event_name' => $person->event->name, - 'event_slug' => $person->event->slug, - 'organisation_name' => $person->event->organisation->name, - 'person_id' => $person->id, - 'person_status' => $person->status, - 'start_date' => $person->event->start_date?->toDateString(), - 'end_date' => $person->event->end_date?->toDateString(), - ]) + 'portal_events' => $this->whenLoaded('persons', fn () => $this->persons->map(fn (Person $person) => [ + 'event_id' => $person->event_id, + 'event_name' => $person->event->name, + 'event_slug' => $person->event->slug, + 'organisation_name' => $person->event->organisation->name, + 'person_id' => $person->id, + 'person_status' => $person->status, + 'start_date' => $person->event->start_date?->toDateString(), + 'end_date' => $person->event->end_date?->toDateString(), + ]) ), 'mfa' => [ 'enabled' => $this->mfa_enabled, @@ -53,6 +64,51 @@ final class MeResource extends JsonResource 'confirmed_at' => $this->mfa_confirmed_at?->toIso8601String(), 'setup_required' => app(MfaService::class)->isMfaRequired($this->resource) && ! $this->mfa_enabled, ], + 'platform' => [ + 'is_super_admin' => $user->hasRole('super_admin'), + ], + 'contexts' => $contexts, + ]; + } + + /** + * Compute available + default UI contexts for this user. + * + * - portal: user has at least one Person record (volunteer-side). + * - organizer: super_admin OR membership in any Organisation pivot. + * + * Default precedence: super_admin → organizer; otherwise the first + * available context wins (organizer before portal, mirroring the + * "familiar context wins on first login" rule from + * ARCH-CONSOLIDATION-2026-04 §4.3). When neither context is + * available, default falls back to 'portal' so the post-login + * landing logic has a safe target to resolve against. + * + * @return array{available: list, default: string} + */ + private function resolveContexts(User $user): array + { + $hasPortal = $user->persons->isNotEmpty(); + $hasOrganizer = $user->hasRole('super_admin') || $user->organisations->isNotEmpty(); + + $available = []; + if ($hasPortal) { + $available[] = 'portal'; + } + if ($hasOrganizer) { + $available[] = 'organizer'; + } + + $default = match (true) { + $user->hasRole('super_admin') => 'organizer', + $hasOrganizer => 'organizer', + $hasPortal => 'portal', + default => 'portal', + }; + + return [ + 'available' => $available, + 'default' => $default, ]; } } diff --git a/api/database/factories/UserFactory.php b/api/database/factories/UserFactory.php index 23719b3c..be9dec98 100644 --- a/api/database/factories/UserFactory.php +++ b/api/database/factories/UserFactory.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Database\Factories; +use App\Models\Organisation; +use App\Models\Person; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Hash; @@ -23,7 +25,7 @@ final class UserFactory extends Factory 'date_of_birth' => fake()->dateTimeBetween('-50 years', '-18 years')->format('Y-m-d'), 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), - 'password' => static::$password ??= Hash::make('password'), + 'password' => self::$password ??= Hash::make('password'), 'timezone' => 'Europe/Amsterdam', 'locale' => 'nl', 'remember_token' => Str::random(10), @@ -34,4 +36,47 @@ final class UserFactory extends Factory { return $this->state(fn () => ['email_verified_at' => null]); } + + /** + * Volunteer-only user — has a Person record (portal context), + * no Spatie role, no organisation membership. + */ + public function volunteer(): static + { + return $this->afterCreating(function (User $user): void { + Person::factory()->create(['user_id' => $user->id]); + }); + } + + /** + * Organizer-only user — attached to a fresh Organisation as `org_admin`, + * no Person record, no Spatie role. + */ + public function orgAdmin(): static + { + return $this->afterCreating(function (User $user): void { + $organisation = Organisation::factory()->create(); + $organisation->users()->attach($user, ['role' => 'org_admin']); + }); + } + + /** + * Multi-role user — has both a Person record AND organisation membership. + */ + public function volunteerAndOrganizer(): static + { + return $this->volunteer()->orgAdmin(); + } + + /** + * Platform admin — Spatie super_admin role. No org/person attachments + * by default (mirrors the production case where super_admins live above + * the org tree). + */ + public function superAdmin(): static + { + return $this->afterCreating(function (User $user): void { + $user->assignRole('super_admin'); + }); + } } diff --git a/api/tests/Feature/Auth/AuthMeShapeTest.php b/api/tests/Feature/Auth/AuthMeShapeTest.php new file mode 100644 index 00000000..b2075f78 --- /dev/null +++ b/api/tests/Feature/Auth/AuthMeShapeTest.php @@ -0,0 +1,96 @@ +seed(RoleSeeder::class); + } + + public function test_volunteer_only_user_sees_portal_context(): void + { + $user = User::factory()->volunteer()->create(); + + Sanctum::actingAs($user); + + $response = $this->getJson('/api/v1/auth/me'); + + $response->assertOk() + ->assertJsonStructure([ + 'data' => [ + 'platform' => ['is_super_admin'], + 'contexts' => ['available', 'default'], + ], + ]) + ->assertJsonPath('data.platform.is_super_admin', false) + ->assertJsonPath('data.contexts.available', ['portal']) + ->assertJsonPath('data.contexts.default', 'portal'); + } + + public function test_organizer_only_user_sees_organizer_context(): void + { + $user = User::factory()->orgAdmin()->create(); + + Sanctum::actingAs($user); + + $response = $this->getJson('/api/v1/auth/me'); + + $response->assertOk() + ->assertJsonPath('data.platform.is_super_admin', false) + ->assertJsonPath('data.contexts.available', ['organizer']) + ->assertJsonPath('data.contexts.default', 'organizer'); + } + + public function test_multi_role_user_sees_both_contexts_with_organizer_default(): void + { + $user = User::factory()->volunteerAndOrganizer()->create(); + + Sanctum::actingAs($user); + + $response = $this->getJson('/api/v1/auth/me'); + + $response->assertOk() + ->assertJsonPath('data.contexts.available', ['portal', 'organizer']) + ->assertJsonPath('data.contexts.default', 'organizer'); + } + + public function test_super_admin_sees_organizer_context(): void + { + $user = User::factory()->superAdmin()->create(); + + Sanctum::actingAs($user); + + $response = $this->getJson('/api/v1/auth/me'); + + $response->assertOk() + ->assertJsonPath('data.platform.is_super_admin', true) + ->assertJsonPath('data.contexts.available', ['organizer']) + ->assertJsonPath('data.contexts.default', 'organizer'); + } + + public function test_organisations_emit_roles_array_in_addition_to_scalar_role(): void + { + $user = User::factory()->orgAdmin()->create(); + + Sanctum::actingAs($user); + + $response = $this->getJson('/api/v1/auth/me'); + + $response->assertOk() + ->assertJsonPath('data.organisations.0.role', 'org_admin') + ->assertJsonPath('data.organisations.0.roles', ['org_admin']); + } +} diff --git a/apps/app/src/pages/forbidden.vue b/apps/app/src/pages/forbidden.vue new file mode 100644 index 00000000..fbdf066c --- /dev/null +++ b/apps/app/src/pages/forbidden.vue @@ -0,0 +1,23 @@ + + + diff --git a/apps/app/typed-router.d.ts b/apps/app/typed-router.d.ts index 46301a54..940bb6a5 100644 --- a/apps/app/typed-router.d.ts +++ b/apps/app/typed-router.d.ts @@ -33,6 +33,7 @@ declare module 'vue-router/auto-routes' { 'events-id-settings': RouteRecordInfo<'events-id-settings', '/events/:id/settings', { id: ParamValue }, { id: ParamValue }>, 'events-id-settings-registration-fields': RouteRecordInfo<'events-id-settings-registration-fields', '/events/:id/settings/registration-fields', { id: ParamValue }, { id: ParamValue }>, 'events-id-time-slots': RouteRecordInfo<'events-id-time-slots', '/events/:id/time-slots', { id: ParamValue }, { id: ParamValue }>, + 'forbidden': RouteRecordInfo<'forbidden', '/forbidden', Record, Record>, 'forgot-password': RouteRecordInfo<'forgot-password', '/forgot-password', Record, Record>, 'invitations-token': RouteRecordInfo<'invitations-token', '/invitations/:token', { token: ParamValue }, { token: ParamValue }>, 'login': RouteRecordInfo<'login', '/login', Record, Record>, diff --git a/dev-docs/BACKLOG.md b/dev-docs/BACKLOG.md index 5a47d8da..a659aa17 100644 --- a/dev-docs/BACKLOG.md +++ b/dev-docs/BACKLOG.md @@ -823,6 +823,38 @@ introduceert is het natuurlijke moment. --- +### TECH-PIVOT-ROLES-MULTI — Multi-role per (user, organisation) pivot + +**Aanleiding:** WS-3 PR-B2a maakt context-aware routing op +`me.contexts.available` en `me.organisations[].roles`. Het pivot-veld +`organisation_user.role` is vandaag een single string (één rol per user +per org). De resource emit `roles` als 1-element array zodat het +frontend-contract forward-compatible is, maar het schema ondersteunt +nog niet meerdere rollen per relatie. +**Wat:** Architectuur-discussie + design-document, niet een directe +schema-uitbreiding. Te beantwoorden vragen voordat dit gepland wordt: + +- Spatie-permission-integratie: blijft `organisation_user.role` + een free-form string of komt het onder `model_has_roles` met + team-id = organisation_id? Spatie's "teams" feature is bedoeld voor + precies dit scenario. +- Multi-role-precedence: als een user `org_admin` ÉN `event_manager` is + binnen dezelfde organisatie, hoe resolven policies? Hoogste + permissie-set? Meest restrictieve? Expliciete merge? +- Migratie-pad: bestaande pivot-rijen (single string) → array of + pivot-tabel naar `model_has_roles`? Backfill-strategie? +- Frontend impact: `organisations[].role` (scalar) blijft voorlopig + staan voor backward-compatibility. Wanneer mag dat veld weg? + +**Prioriteit:** Laag — geen blocker voor B2a, B2b of de 4 kern-workflows. +Pas oppakken wanneer een concrete use case multi-role per (user, org) +vereist (denkbaar: festival waarbij organizer ook als crew werkt). +**Belangrijk:** dit is GEEN simpel "voeg een kolom toe" werk. Pak het +niet op als drive-by tijdens een ander ticket; het verdient een eigen +ARCH-discussie en RFC. + +--- + ### ~~TECH-02 — scopeForFestival helper op Event model~~ ✅ OPGELOST ---