## Summary - Removes the legacy Tenant CRUD create page (`/admin/tenants/create`) so tenant creation is handled exclusively via the onboarding wizard. - Updates tenant selection flows and pages to prevent Livewire polling/notification-related 404s on workspace-scoped routes. - Aligns empty-state UX with enterprise patterns (avoid duplicate CTAs). ## Key changes - Tenant creation - Removed `CreateTenant` page + route from `TenantResource`. - `TenantResource::canCreate()` now returns `false` (CRUD creation disabled). - Tenants list now surfaces an **Add tenant** action that links to onboarding (`admin.onboarding`). - Onboarding wizard - Removed redundant legacy step-cards from the blade view (Wizard schema is the source of truth). - Disabled topbar on the onboarding page to avoid lazy-loaded notifications. - Choose tenant - Enterprise UI redesign + workspace context. - Uses Livewire `selectTenant()` instead of a form POST. - Disabled topbar and gated BODY_END hook to avoid background polling. - Baseline profiles - Hide header create action when table is empty to avoid duplicate CTAs. ## Tests - `vendor/bin/sail artisan test --compact --filter='Onboarding|ManagedTenantOnboarding'` - `vendor/bin/sail artisan test --compact --filter='ManagedTenantsLivewireUpdate'` - `vendor/bin/sail artisan test --compact --filter='TenantSetup|TenantResourceAuth|TenantAdminAuth|ListTenants'` - `vendor/bin/sail artisan test --compact --filter='BaselineProfile'` - `vendor/bin/sail artisan test --compact --filter='ChooseTenant|TenantMake|TenantScoping|AdminTenantScoped|AdminHomeRedirect|WorkspaceContext'` ## Notes - Filament v5 / Livewire v4 compatible. - No new assets introduced; no deploy pipeline changes required. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #131
359 lines
12 KiB
PHP
359 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\AuditLog;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Models\WorkspaceMembership;
|
|
use App\Support\Audit\AuditActionId;
|
|
use App\Support\Workspaces\WorkspaceContext;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
// --- T005: it_skips_chooser_when_single_workspace_membership ---
|
|
|
|
it('skips chooser when single workspace membership', function (): void {
|
|
$user = User::factory()->create();
|
|
$workspace = Workspace::factory()->create();
|
|
|
|
WorkspaceMembership::factory()->create([
|
|
'workspace_id' => $workspace->getKey(),
|
|
'user_id' => $user->getKey(),
|
|
'role' => 'owner',
|
|
]);
|
|
|
|
$response = $this->actingAs($user)->get('/admin/_test/workspace-context');
|
|
|
|
// Should redirect via tenant branching (not to chooser).
|
|
$response->assertRedirect();
|
|
|
|
$location = $response->headers->get('Location');
|
|
expect($location)->not->toContain('choose-workspace');
|
|
});
|
|
|
|
// --- T006: it_emits_audit_event_on_auto_selection_single_membership ---
|
|
|
|
it('emits audit event on auto selection single membership', function (): void {
|
|
$user = User::factory()->create();
|
|
$workspace = Workspace::factory()->create();
|
|
|
|
WorkspaceMembership::factory()->create([
|
|
'workspace_id' => $workspace->getKey(),
|
|
'user_id' => $user->getKey(),
|
|
'role' => 'owner',
|
|
]);
|
|
|
|
$this->actingAs($user)->get('/admin/_test/workspace-context');
|
|
|
|
$auditLog = AuditLog::query()
|
|
->where('action', AuditActionId::WorkspaceAutoSelected->value)
|
|
->where('workspace_id', $workspace->getKey())
|
|
->first();
|
|
|
|
expect($auditLog)->not->toBeNull();
|
|
expect($auditLog->metadata)->toMatchArray([
|
|
'method' => 'auto',
|
|
'reason' => 'single_membership',
|
|
]);
|
|
expect($auditLog->resource_type)->toBe('workspace');
|
|
expect($auditLog->resource_id)->toBe((string) $workspace->getKey());
|
|
});
|
|
|
|
// --- T007: it_redirects_via_tenant_count_branching_after_single_auto_resume ---
|
|
|
|
it('redirects to managed tenants index when single workspace has zero tenants', function (): void {
|
|
$user = User::factory()->create();
|
|
$workspace = Workspace::factory()->create();
|
|
|
|
WorkspaceMembership::factory()->create([
|
|
'workspace_id' => $workspace->getKey(),
|
|
'user_id' => $user->getKey(),
|
|
'role' => 'owner',
|
|
]);
|
|
|
|
$response = $this->actingAs($user)->get('/admin/_test/workspace-context');
|
|
|
|
$expectedRoute = route('admin.workspace.managed-tenants.index', [
|
|
'workspace' => $workspace->slug ?? $workspace->getKey(),
|
|
]);
|
|
|
|
$response->assertRedirect($expectedRoute);
|
|
});
|
|
|
|
it('redirects to tenant dashboard when single workspace has one active tenant', function (): void {
|
|
$user = User::factory()->create();
|
|
$workspace = Workspace::factory()->create();
|
|
|
|
WorkspaceMembership::factory()->create([
|
|
'workspace_id' => $workspace->getKey(),
|
|
'user_id' => $user->getKey(),
|
|
'role' => 'owner',
|
|
]);
|
|
|
|
$tenant = Tenant::factory()->create([
|
|
'workspace_id' => $workspace->getKey(),
|
|
'status' => 'active',
|
|
]);
|
|
|
|
$user->tenants()->syncWithoutDetaching([
|
|
$tenant->getKey() => ['role' => 'owner'],
|
|
]);
|
|
|
|
$response = $this->actingAs($user)->get('/admin/_test/workspace-context');
|
|
|
|
$response->assertRedirect();
|
|
$location = $response->headers->get('Location');
|
|
expect($location)->toContain('/admin/t/');
|
|
});
|
|
|
|
// --- T008: it_allows_request_when_session_workspace_is_valid ---
|
|
|
|
it('allows request when session workspace is valid', function (): void {
|
|
$user = User::factory()->create();
|
|
$workspace = Workspace::factory()->create();
|
|
|
|
WorkspaceMembership::factory()->create([
|
|
'workspace_id' => $workspace->getKey(),
|
|
'user_id' => $user->getKey(),
|
|
'role' => 'owner',
|
|
]);
|
|
|
|
$response = $this->actingAs($user)
|
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
|
->get('/admin/_test/workspace-context');
|
|
|
|
// Should pass through (200) since session is valid.
|
|
$response->assertOk();
|
|
$response->assertJson(['workspace_id' => (int) $workspace->getKey()]);
|
|
});
|
|
|
|
// --- T010: it_auto_resumes_to_last_used_workspace_when_membership_valid ---
|
|
|
|
it('auto resumes to last used workspace when membership valid', function (): void {
|
|
$user = User::factory()->create();
|
|
$workspaceA = Workspace::factory()->create();
|
|
$workspaceB = Workspace::factory()->create();
|
|
|
|
WorkspaceMembership::factory()->create([
|
|
'workspace_id' => $workspaceA->getKey(),
|
|
'user_id' => $user->getKey(),
|
|
'role' => 'owner',
|
|
]);
|
|
|
|
WorkspaceMembership::factory()->create([
|
|
'workspace_id' => $workspaceB->getKey(),
|
|
'user_id' => $user->getKey(),
|
|
'role' => 'operator',
|
|
]);
|
|
|
|
// Set last_workspace_id to workspaceB.
|
|
$user->forceFill(['last_workspace_id' => (int) $workspaceB->getKey()])->save();
|
|
|
|
$response = $this->actingAs($user)->get('/admin/_test/workspace-context');
|
|
|
|
// Should redirect via tenant branching (not to chooser).
|
|
$response->assertRedirect();
|
|
$location = $response->headers->get('Location');
|
|
expect($location)->not->toContain('choose-workspace');
|
|
});
|
|
|
|
// --- T011: it_emits_audit_event_on_auto_selection_last_used ---
|
|
|
|
it('emits audit event on auto selection last used', function (): void {
|
|
$user = User::factory()->create();
|
|
$workspaceA = Workspace::factory()->create();
|
|
$workspaceB = Workspace::factory()->create();
|
|
|
|
WorkspaceMembership::factory()->create([
|
|
'workspace_id' => $workspaceA->getKey(),
|
|
'user_id' => $user->getKey(),
|
|
'role' => 'owner',
|
|
]);
|
|
|
|
WorkspaceMembership::factory()->create([
|
|
'workspace_id' => $workspaceB->getKey(),
|
|
'user_id' => $user->getKey(),
|
|
'role' => 'operator',
|
|
]);
|
|
|
|
$user->forceFill(['last_workspace_id' => (int) $workspaceB->getKey()])->save();
|
|
|
|
$this->actingAs($user)->get('/admin/_test/workspace-context');
|
|
|
|
$auditLog = AuditLog::query()
|
|
->where('action', AuditActionId::WorkspaceAutoSelected->value)
|
|
->where('workspace_id', $workspaceB->getKey())
|
|
->first();
|
|
|
|
expect($auditLog)->not->toBeNull();
|
|
expect($auditLog->metadata)->toMatchArray([
|
|
'method' => 'auto',
|
|
'reason' => 'last_used',
|
|
]);
|
|
});
|
|
|
|
// --- T012: it_falls_back_to_chooser_when_multiple_workspaces_and_no_last_used ---
|
|
|
|
it('falls back to chooser when multiple workspaces and no last used', function (): void {
|
|
$user = User::factory()->create();
|
|
$user->forceFill(['last_workspace_id' => null])->save();
|
|
$workspaceA = Workspace::factory()->create();
|
|
$workspaceB = Workspace::factory()->create();
|
|
|
|
WorkspaceMembership::factory()->create([
|
|
'workspace_id' => $workspaceA->getKey(),
|
|
'user_id' => $user->getKey(),
|
|
'role' => 'owner',
|
|
]);
|
|
|
|
WorkspaceMembership::factory()->create([
|
|
'workspace_id' => $workspaceB->getKey(),
|
|
'user_id' => $user->getKey(),
|
|
'role' => 'operator',
|
|
]);
|
|
|
|
$response = $this->actingAs($user)->get('/admin/_test/workspace-context');
|
|
|
|
$response->assertRedirect('/admin/choose-workspace');
|
|
});
|
|
|
|
// --- T023: it_clears_session_when_active_workspace_membership_revoked ---
|
|
|
|
it('clears session when active workspace membership revoked', function (): void {
|
|
$user = User::factory()->create();
|
|
$workspace = Workspace::factory()->create();
|
|
|
|
// Set session but don't create membership — simulates revoked access.
|
|
$response = $this->actingAs($user)
|
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
|
->get('/admin/_test/workspace-context');
|
|
|
|
// Should redirect to no-access or chooser since user has no memberships.
|
|
$response->assertRedirect();
|
|
});
|
|
|
|
// --- T024: it_redirects_to_chooser_when_last_workspace_membership_revoked_and_shows_warning ---
|
|
|
|
it('redirects to chooser when last workspace membership revoked', function (): void {
|
|
$user = User::factory()->create();
|
|
$workspaceA = Workspace::factory()->create();
|
|
$workspaceB = Workspace::factory()->create();
|
|
$workspaceC = Workspace::factory()->create();
|
|
|
|
// User is member of A and C but NOT B. last_workspace_id points to B.
|
|
WorkspaceMembership::factory()->create([
|
|
'workspace_id' => $workspaceA->getKey(),
|
|
'user_id' => $user->getKey(),
|
|
'role' => 'owner',
|
|
]);
|
|
|
|
WorkspaceMembership::factory()->create([
|
|
'workspace_id' => $workspaceC->getKey(),
|
|
'user_id' => $user->getKey(),
|
|
'role' => 'operator',
|
|
]);
|
|
|
|
$user->forceFill(['last_workspace_id' => (int) $workspaceB->getKey()])->save();
|
|
|
|
$response = $this->actingAs($user)->get('/admin/_test/workspace-context');
|
|
|
|
// last_workspace_id should be cleared.
|
|
$user->refresh();
|
|
expect($user->last_workspace_id)->toBeNull();
|
|
|
|
// Should redirect to chooser since user has 2 valid workspaces and last_workspace was invalid.
|
|
$response->assertRedirect('/admin/choose-workspace');
|
|
});
|
|
|
|
it('redirects to chooser when last workspace is archived', function (): void {
|
|
$user = User::factory()->create();
|
|
$workspaceA = Workspace::factory()->create();
|
|
$workspaceB = Workspace::factory()->create(['archived_at' => now()]);
|
|
|
|
WorkspaceMembership::factory()->create([
|
|
'workspace_id' => $workspaceA->getKey(),
|
|
'user_id' => $user->getKey(),
|
|
'role' => 'owner',
|
|
]);
|
|
|
|
WorkspaceMembership::factory()->create([
|
|
'workspace_id' => $workspaceB->getKey(),
|
|
'user_id' => $user->getKey(),
|
|
'role' => 'operator',
|
|
]);
|
|
|
|
$user->forceFill(['last_workspace_id' => (int) $workspaceB->getKey()])->save();
|
|
|
|
$response = $this->actingAs($user)->get('/admin/_test/workspace-context');
|
|
|
|
// Step 5 auto-resumes to workspaceA (only selectable). setCurrentWorkspace updates last_workspace_id.
|
|
$user->refresh();
|
|
expect($user->last_workspace_id)->toBe((int) $workspaceA->getKey());
|
|
|
|
// Only workspaceA is selectable → single membership auto-resume.
|
|
$response->assertRedirect();
|
|
$location = $response->headers->get('Location');
|
|
expect($location)->not->toContain('choose-workspace');
|
|
});
|
|
|
|
// --- T025: it_handles_archived_workspace_in_session ---
|
|
|
|
it('handles archived workspace in session', function (): void {
|
|
$user = User::factory()->create();
|
|
$workspace = Workspace::factory()->create(['archived_at' => now()]);
|
|
|
|
WorkspaceMembership::factory()->create([
|
|
'workspace_id' => $workspace->getKey(),
|
|
'user_id' => $user->getKey(),
|
|
'role' => 'owner',
|
|
]);
|
|
|
|
$response = $this->actingAs($user)
|
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
|
->get('/admin/_test/workspace-context');
|
|
|
|
// Session with archived workspace should be treated as stale.
|
|
$response->assertRedirect();
|
|
$location = $response->headers->get('Location');
|
|
// Should redirect to chooser or no-access.
|
|
expect($location)->toMatch('/choose-workspace|no-access/');
|
|
});
|
|
|
|
// --- T030: it_forces_chooser_with_choose_param ---
|
|
|
|
it('forces chooser with choose param', function (): void {
|
|
$user = User::factory()->create();
|
|
$workspace = Workspace::factory()->create();
|
|
|
|
WorkspaceMembership::factory()->create([
|
|
'workspace_id' => $workspace->getKey(),
|
|
'user_id' => $user->getKey(),
|
|
'role' => 'owner',
|
|
]);
|
|
|
|
$response = $this->actingAs($user)
|
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
|
->get('/admin/_test/workspace-context?choose=1');
|
|
|
|
$response->assertRedirect('/admin/choose-workspace');
|
|
});
|
|
|
|
it('forces chooser with choose param even when single workspace', function (): void {
|
|
$user = User::factory()->create();
|
|
$workspace = Workspace::factory()->create();
|
|
|
|
WorkspaceMembership::factory()->create([
|
|
'workspace_id' => $workspace->getKey(),
|
|
'user_id' => $user->getKey(),
|
|
'role' => 'owner',
|
|
]);
|
|
|
|
// No session set — normally would auto-resume, but ?choose=1 forces chooser.
|
|
$response = $this->actingAs($user)->get('/admin/_test/workspace-context?choose=1');
|
|
|
|
$response->assertRedirect('/admin/choose-workspace');
|
|
});
|