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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user