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;
|
namespace App\Http\Resources\Api\V1;
|
||||||
|
|
||||||
use App\Models\Person;
|
use App\Models\Person;
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\MfaService;
|
use App\Services\MfaService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
@@ -13,6 +14,11 @@ final class MeResource extends JsonResource
|
|||||||
{
|
{
|
||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $this->resource;
|
||||||
|
|
||||||
|
$contexts = $this->resolveContexts($user);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'first_name' => $this->first_name,
|
'first_name' => $this->first_name,
|
||||||
@@ -25,27 +31,32 @@ final class MeResource extends JsonResource
|
|||||||
'locale' => $this->locale,
|
'locale' => $this->locale,
|
||||||
'avatar' => $this->avatar,
|
'avatar' => $this->avatar,
|
||||||
'email_verified_at' => $this->email_verified_at?->toIso8601String(),
|
'email_verified_at' => $this->email_verified_at?->toIso8601String(),
|
||||||
'organisations' => $this->whenLoaded('organisations', fn () =>
|
'organisations' => $this->whenLoaded('organisations', fn () => $this->organisations->map(fn ($org) => [
|
||||||
$this->organisations->map(fn ($org) => [
|
'id' => $org->id,
|
||||||
'id' => $org->id,
|
'name' => $org->name,
|
||||||
'name' => $org->name,
|
'slug' => $org->slug,
|
||||||
'slug' => $org->slug,
|
'role' => $org->pivot->role,
|
||||||
'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(),
|
'app_roles' => $this->getRoleNames()->values()->all(),
|
||||||
'permissions' => $this->getAllPermissions()->pluck('name')->values()->all(),
|
'permissions' => $this->getAllPermissions()->pluck('name')->values()->all(),
|
||||||
'portal_events' => $this->whenLoaded('persons', fn () =>
|
'portal_events' => $this->whenLoaded('persons', fn () => $this->persons->map(fn (Person $person) => [
|
||||||
$this->persons->map(fn (Person $person) => [
|
'event_id' => $person->event_id,
|
||||||
'event_id' => $person->event_id,
|
'event_name' => $person->event->name,
|
||||||
'event_name' => $person->event->name,
|
'event_slug' => $person->event->slug,
|
||||||
'event_slug' => $person->event->slug,
|
'organisation_name' => $person->event->organisation->name,
|
||||||
'organisation_name' => $person->event->organisation->name,
|
'person_id' => $person->id,
|
||||||
'person_id' => $person->id,
|
'person_status' => $person->status,
|
||||||
'person_status' => $person->status,
|
'start_date' => $person->event->start_date?->toDateString(),
|
||||||
'start_date' => $person->event->start_date?->toDateString(),
|
'end_date' => $person->event->end_date?->toDateString(),
|
||||||
'end_date' => $person->event->end_date?->toDateString(),
|
])
|
||||||
])
|
|
||||||
),
|
),
|
||||||
'mfa' => [
|
'mfa' => [
|
||||||
'enabled' => $this->mfa_enabled,
|
'enabled' => $this->mfa_enabled,
|
||||||
@@ -53,6 +64,51 @@ final class MeResource extends JsonResource
|
|||||||
'confirmed_at' => $this->mfa_confirmed_at?->toIso8601String(),
|
'confirmed_at' => $this->mfa_confirmed_at?->toIso8601String(),
|
||||||
'setup_required' => app(MfaService::class)->isMfaRequired($this->resource) && ! $this->mfa_enabled,
|
'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;
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use App\Models\Person;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
use Illuminate\Support\Facades\Hash;
|
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'),
|
'date_of_birth' => fake()->dateTimeBetween('-50 years', '-18 years')->format('Y-m-d'),
|
||||||
'email' => fake()->unique()->safeEmail(),
|
'email' => fake()->unique()->safeEmail(),
|
||||||
'email_verified_at' => now(),
|
'email_verified_at' => now(),
|
||||||
'password' => static::$password ??= Hash::make('password'),
|
'password' => self::$password ??= Hash::make('password'),
|
||||||
'timezone' => 'Europe/Amsterdam',
|
'timezone' => 'Europe/Amsterdam',
|
||||||
'locale' => 'nl',
|
'locale' => 'nl',
|
||||||
'remember_token' => Str::random(10),
|
'remember_token' => Str::random(10),
|
||||||
@@ -34,4 +36,47 @@ final class UserFactory extends Factory
|
|||||||
{
|
{
|
||||||
return $this->state(fn () => ['email_verified_at' => null]);
|
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': 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-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> }>,
|
'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>>,
|
'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> }>,
|
'invitations-token': RouteRecordInfo<'invitations-token', '/invitations/:token', { token: ParamValue<true> }, { token: ParamValue<false> }>,
|
||||||
'login': RouteRecordInfo<'login', '/login', Record<never, never>, Record<never, never>>,
|
'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
|
### ~~TECH-02 — scopeForFestival helper op Event model~~ ✅ OPGELOST
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user