feat(auth): add contexts + platform.is_super_admin to /auth/me, factory role-category states
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>
This commit is contained in:
@@ -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<string>, 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
96
api/tests/Feature/Auth/AuthMeShapeTest.php
Normal file
96
api/tests/Feature/Auth/AuthMeShapeTest.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RoleSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AuthMeShapeTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->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']);
|
||||
}
|
||||
}
|
||||
23
apps/app/src/pages/forbidden.vue
Normal file
23
apps/app/src/pages/forbidden.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
definePage({
|
||||
name: 'forbidden',
|
||||
meta: { layout: 'PublicLayout', public: true },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="d-flex flex-column align-center justify-center pa-12">
|
||||
<h1 class="text-h3 mb-4">
|
||||
Geen toegang
|
||||
</h1>
|
||||
<p class="text-body-1 mb-6">
|
||||
Je hebt geen toegang tot deze pagina.
|
||||
</p>
|
||||
<VBtn
|
||||
:to="{ name: 'login' }"
|
||||
color="primary"
|
||||
>
|
||||
Naar inloggen
|
||||
</VBtn>
|
||||
</div>
|
||||
</template>
|
||||
1
apps/app/typed-router.d.ts
vendored
1
apps/app/typed-router.d.ts
vendored
@@ -33,6 +33,7 @@ declare module 'vue-router/auto-routes' {
|
||||
'events-id-settings': RouteRecordInfo<'events-id-settings', '/events/:id/settings', { id: ParamValue<true> }, { id: ParamValue<false> }>,
|
||||
'events-id-settings-registration-fields': RouteRecordInfo<'events-id-settings-registration-fields', '/events/:id/settings/registration-fields', { id: ParamValue<true> }, { id: ParamValue<false> }>,
|
||||
'events-id-time-slots': RouteRecordInfo<'events-id-time-slots', '/events/:id/time-slots', { id: ParamValue<true> }, { id: ParamValue<false> }>,
|
||||
'forbidden': RouteRecordInfo<'forbidden', '/forbidden', Record<never, never>, Record<never, never>>,
|
||||
'forgot-password': RouteRecordInfo<'forgot-password', '/forgot-password', Record<never, never>, Record<never, never>>,
|
||||
'invitations-token': RouteRecordInfo<'invitations-token', '/invitations/:token', { token: ParamValue<true> }, { token: ParamValue<false> }>,
|
||||
'login': RouteRecordInfo<'login', '/login', Record<never, never>, Record<never, never>>,
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user