From e15eee8f264bab74f57a359d70f0b81fcdb41932 Mon Sep 17 00:00:00 2001 From: ahmido Date: Sun, 22 Feb 2026 19:54:24 +0000 Subject: [PATCH] fix: consolidate tenant creation + harden selection flows (#131) ## 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 Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/131 --- .github/agents/copilot-instructions.md | 5 +- app/Filament/Pages/ChooseTenant.php | 11 + app/Filament/Pages/ChooseWorkspace.php | 117 +++--- .../ManagedTenantOnboardingWizard.php | 12 + .../Workspaces/ManagedTenantsLanding.php | 14 + .../Pages/ListBaselineProfiles.php | 3 +- app/Filament/Resources/TenantResource.php | 28 +- .../TenantResource/Pages/CreateTenant.php | 41 -- .../TenantResource/Pages/ListTenants.php | 14 +- .../Controllers/SwitchWorkspaceController.php | 46 ++- .../Middleware/EnsureWorkspaceSelected.php | 194 ++++++++-- app/Providers/Filament/AdminPanelProvider.php | 28 +- app/Support/Audit/AuditActionId.php | 4 + .../Workspaces/WorkspaceRedirectResolver.php | 68 ++++ .../filament/pages/choose-tenant.blade.php | 179 ++++++--- .../filament/pages/choose-workspace.blade.php | 209 +++++++--- ...managed-tenant-onboarding-wizard.blade.php | 167 -------- .../managed-tenants-landing.blade.php | 185 +++++---- routes/web.php | 23 +- .../checklists/requirements.md | 47 +++ .../107-workspace-chooser/contracts/routes.md | 126 ++++++ specs/107-workspace-chooser/data-model.md | 142 +++++++ specs/107-workspace-chooser/plan.md | 113 ++++++ specs/107-workspace-chooser/quickstart.md | 87 +++++ specs/107-workspace-chooser/research.md | 106 ++++++ specs/107-workspace-chooser/spec.md | 311 +++++++++++++++ specs/107-workspace-chooser/tasks.md | 287 ++++++++++++++ ...oChooseTenantWhenWorkspaceSelectedTest.php | 11 +- ...tyStateRegisterTenantCtaVisibilityTest.php | 6 +- ...oseWorkspaceWhenMultipleWorkspacesTest.php | 31 +- tests/Feature/Filament/TenantSetupTest.php | 28 +- tests/Feature/OpsUx/OperateHubShellTest.php | 5 +- .../AuthorizationSemanticsTest.php | 3 +- .../LegacyRedirectTest.php | 7 +- ...iderConnectionAuthorizationSpec081Test.php | 4 +- .../ProviderConnectionAuthorizationTest.php | 9 + .../TenantlessListRouteTest.php | 4 +- .../Rbac/TenantAdminAuthorizationTest.php | 8 +- .../Rbac/TenantResourceAuthorizationTest.php | 4 +- .../RequiredPermissionsAccessTest.php | 4 +- .../RequiredPermissionsSidebarTest.php | 6 +- ...rkspaceManagedTenantAdminMigrationTest.php | 8 +- .../Spec085/DenyAsNotFoundSemanticsTest.php | 6 +- .../Workspaces/ChooseWorkspacePageTest.php | 297 +++++++++++++++ .../EnsureWorkspaceSelectedMiddlewareTest.php | 358 ++++++++++++++++++ .../ManagedTenantsLivewireUpdateTest.php | 81 ++++ ...sToTenantRegistrationWhenNoTenantsTest.php | 12 +- .../Workspaces/WorkspaceAuditTrailTest.php | 211 +++++++++++ .../WorkspaceRedirectResolverTest.php | 121 ++++++ .../WorkspaceSwitchUserMenuTest.php | 73 ++++ 50 files changed, 3244 insertions(+), 620 deletions(-) delete mode 100644 app/Filament/Resources/TenantResource/Pages/CreateTenant.php create mode 100644 app/Support/Workspaces/WorkspaceRedirectResolver.php create mode 100644 specs/107-workspace-chooser/checklists/requirements.md create mode 100644 specs/107-workspace-chooser/contracts/routes.md create mode 100644 specs/107-workspace-chooser/data-model.md create mode 100644 specs/107-workspace-chooser/plan.md create mode 100644 specs/107-workspace-chooser/quickstart.md create mode 100644 specs/107-workspace-chooser/research.md create mode 100644 specs/107-workspace-chooser/spec.md create mode 100644 specs/107-workspace-chooser/tasks.md create mode 100644 tests/Feature/Workspaces/ChooseWorkspacePageTest.php create mode 100644 tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php create mode 100644 tests/Feature/Workspaces/ManagedTenantsLivewireUpdateTest.php create mode 100644 tests/Feature/Workspaces/WorkspaceAuditTrailTest.php create mode 100644 tests/Feature/Workspaces/WorkspaceRedirectResolverTest.php create mode 100644 tests/Feature/Workspaces/WorkspaceSwitchUserMenuTest.php diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 5de2d9b..58bb7b6 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -35,6 +35,8 @@ ## Active Technologies - PostgreSQL — no schema changes (103-ia-scope-filter-semantics) - PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Pest v4 (104-provider-permission-posture) - PostgreSQL (via Sail), JSONB for stored report payloads and finding evidence (104-provider-permission-posture) +- PHP 8.4 / Laravel 12 + Filament v5, Livewire v4, Tailwind CSS v4 (107-workspace-chooser) +- PostgreSQL (existing tables: `workspaces`, `workspace_memberships`, `users`, `audit_logs`) (107-workspace-chooser) - PHP 8.4.15 (feat/005-bulk-operations) @@ -54,9 +56,8 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 107-workspace-chooser: Added PHP 8.4 / Laravel 12 + Filament v5, Livewire v4, Tailwind CSS v4 - 106-required-permissions-sidebar-context: Middleware sidebar-context fix for workspace-scoped pages - 105-entra-admin-roles-evidence-findings: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Pest v4 -- 104-provider-permission-posture: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Pest v4 -- 103-ia-scope-filter-semantics: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, `OperateHubShell` support class diff --git a/app/Filament/Pages/ChooseTenant.php b/app/Filament/Pages/ChooseTenant.php index f4bedc3..ae2b0eb 100644 --- a/app/Filament/Pages/ChooseTenant.php +++ b/app/Filament/Pages/ChooseTenant.php @@ -27,6 +27,17 @@ class ChooseTenant extends Page protected string $view = 'filament.pages.choose-tenant'; + /** + * Disable the simple-layout topbar to prevent lazy-loaded + * DatabaseNotifications from triggering Livewire update 404s. + */ + protected function getLayoutData(): array + { + return [ + 'hasTopbar' => false, + ]; + } + /** * @return Collection */ diff --git a/app/Filament/Pages/ChooseWorkspace.php b/app/Filament/Pages/ChooseWorkspace.php index ec0ffe7..e4dee0a 100644 --- a/app/Filament/Pages/ChooseWorkspace.php +++ b/app/Filament/Pages/ChooseWorkspace.php @@ -7,10 +7,11 @@ use App\Models\User; use App\Models\Workspace; use App\Models\WorkspaceMembership; +use App\Services\Audit\WorkspaceAuditLogger; +use App\Support\Audit\AuditActionId; use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceIntendedUrl; -use Filament\Actions\Action; -use Filament\Forms\Components\TextInput; +use App\Support\Workspaces\WorkspaceRedirectResolver; use Filament\Notifications\Notification; use Filament\Pages\Page; use Illuminate\Database\Eloquent\Collection; @@ -30,33 +31,18 @@ class ChooseWorkspace extends Page protected string $view = 'filament.pages.choose-workspace'; /** - * @return array + * Workspace roles keyed by workspace_id. + * + * @var array + */ + public array $workspaceRoles = []; + + /** + * @return array<\Filament\Actions\Action> */ protected function getHeaderActions(): array { - return [ - Action::make('createWorkspace') - ->label('Create workspace') - ->modalHeading('Create workspace') - ->visible(function (): bool { - $user = auth()->user(); - - return $user instanceof User - && $user->can('create', Workspace::class); - }) - ->form([ - TextInput::make('name') - ->required() - ->maxLength(255), - TextInput::make('slug') - ->helperText('Optional. Used in URLs if set.') - ->maxLength(255) - ->rules(['nullable', 'string', 'max:255', 'alpha_dash', 'unique:workspaces,slug']) - ->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null) - ->dehydrated(fn ($state) => filled($state)), - ]) - ->action(fn (array $data) => $this->createWorkspace($data)), - ]; + return []; } /** @@ -70,15 +56,28 @@ public function getWorkspaces(): Collection return Workspace::query()->whereRaw('1 = 0')->get(); } - return Workspace::query() + $workspaces = Workspace::query() ->whereIn('id', function ($query) use ($user): void { $query->from('workspace_memberships') ->select('workspace_id') ->where('user_id', $user->getKey()); }) ->whereNull('archived_at') + ->withCount(['tenants' => function ($query): void { + $query->where('status', 'active'); + }]) ->orderBy('name') ->get(); + + // Build roles map from memberships. + $memberships = WorkspaceMembership::query() + ->where('user_id', $user->getKey()) + ->whereIn('workspace_id', $workspaces->pluck('id')) + ->pluck('role', 'workspace_id'); + + $this->workspaceRoles = $memberships->mapWithKeys(fn ($role, $id) => [(int) $id => (string) $role])->all(); + + return $workspaces; } public function selectWorkspace(int $workspaceId): void @@ -105,11 +104,35 @@ public function selectWorkspace(int $workspaceId): void abort(404); } + $prevWorkspaceId = $context->currentWorkspaceId(request()); + $context->setCurrentWorkspace($workspace, $user, request()); + // Audit: manual workspace selection. + /** @var WorkspaceAuditLogger $logger */ + $logger = app(WorkspaceAuditLogger::class); + + $logger->log( + workspace: $workspace, + action: AuditActionId::WorkspaceSelected->value, + context: [ + 'metadata' => [ + 'method' => 'manual', + 'reason' => 'chooser', + 'prev_workspace_id' => $prevWorkspaceId, + ], + ], + actor: $user, + resourceType: 'workspace', + resourceId: (string) $workspace->getKey(), + ); + $intendedUrl = WorkspaceIntendedUrl::consume(request()); - $this->redirect($intendedUrl ?: $this->redirectAfterWorkspaceSelected($user)); + /** @var WorkspaceRedirectResolver $resolver */ + $resolver = app(WorkspaceRedirectResolver::class); + + $this->redirect($intendedUrl ?: $resolver->resolve($workspace, $user)); } /** @@ -147,41 +170,9 @@ public function createWorkspace(array $data): void $intendedUrl = WorkspaceIntendedUrl::consume(request()); - $this->redirect($intendedUrl ?: $this->redirectAfterWorkspaceSelected($user)); - } + /** @var WorkspaceRedirectResolver $resolver */ + $resolver = app(WorkspaceRedirectResolver::class); - private function redirectAfterWorkspaceSelected(User $user): string - { - $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(); - - if ($workspaceId === null) { - return self::getUrl(); - } - - $workspace = Workspace::query()->whereKey($workspaceId)->first(); - - if (! $workspace instanceof Workspace) { - return self::getUrl(); - } - - $tenantsQuery = $user->tenants() - ->where('workspace_id', $workspace->getKey()) - ->where('status', 'active'); - - $tenantCount = (int) $tenantsQuery->count(); - - if ($tenantCount === 0) { - return route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()]); - } - - if ($tenantCount === 1) { - $tenant = $tenantsQuery->first(); - - if ($tenant !== null) { - return TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant); - } - } - - return ChooseTenant::getUrl(); + $this->redirect($intendedUrl ?: $resolver->resolve($workspace, $user)); } } diff --git a/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php b/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php index e916043..c6b62e9 100644 --- a/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php +++ b/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php @@ -75,6 +75,18 @@ class ManagedTenantOnboardingWizard extends Page protected static ?string $slug = 'onboarding'; + /** + * Disable the simple-layout topbar to prevent lazy-loaded + * DatabaseNotifications from triggering Livewire update 404s + * on this workspace-scoped route. + */ + protected function getLayoutData(): array + { + return [ + 'hasTopbar' => false, + ]; + } + public Workspace $workspace; public ?Tenant $managedTenant = null; diff --git a/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php b/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php index 85a5b1c..e7ad193 100644 --- a/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php +++ b/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php @@ -18,12 +18,26 @@ class ManagedTenantsLanding extends Page protected static bool $isDiscovered = false; + protected static string $layout = 'filament-panels::components.layout.simple'; + protected static ?string $title = 'Managed tenants'; protected string $view = 'filament.pages.workspaces.managed-tenants-landing'; public Workspace $workspace; + /** + * The Filament simple layout renders the topbar by default, which includes + * lazy-loaded database notifications. On this workspace-scoped landing page, + * those background Livewire requests currently 404. + */ + protected function getLayoutData(): array + { + return [ + 'hasTopbar' => false, + ]; + } + public function mount(Workspace $workspace): void { $this->workspace = $workspace; diff --git a/app/Filament/Resources/BaselineProfileResource/Pages/ListBaselineProfiles.php b/app/Filament/Resources/BaselineProfileResource/Pages/ListBaselineProfiles.php index 7abf445..4b61dcb 100644 --- a/app/Filament/Resources/BaselineProfileResource/Pages/ListBaselineProfiles.php +++ b/app/Filament/Resources/BaselineProfileResource/Pages/ListBaselineProfiles.php @@ -17,7 +17,8 @@ protected function getHeaderActions(): array return [ CreateAction::make() ->label('Create baseline profile') - ->disabled(fn (): bool => ! BaselineProfileResource::canCreate()), + ->disabled(fn (): bool => ! BaselineProfileResource::canCreate()) + ->visible(fn (): bool => $this->getTableRecords()->count() > 0), ]; } } diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index 3103cd7..bef43b1 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -12,7 +12,6 @@ use App\Models\ProviderConnection; use App\Models\Tenant; use App\Models\User; -use App\Models\WorkspaceMembership; use App\Services\Auth\CapabilityResolver; use App\Services\Auth\RoleCapabilityMap; use App\Services\Directory\EntraGroupLabelResolver; @@ -76,29 +75,13 @@ class TenantResource extends Resource protected static string|UnitEnum|null $navigationGroup = 'Settings'; + /** + * Tenant creation is handled exclusively by the onboarding wizard. + * The CRUD create page has been removed. + */ public static function canCreate(): bool { - $user = auth()->user(); - - if (! $user instanceof User) { - return false; - } - - if (static::userCanManageAnyTenant($user)) { - return true; - } - - $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(); - - if ($workspaceId === null) { - return false; - } - - return WorkspaceMembership::query() - ->where('workspace_id', $workspaceId) - ->where('user_id', $user->getKey()) - ->whereIn('role', ['owner', 'manager']) - ->exists(); + return false; } public static function canEdit(Model $record): bool @@ -999,7 +982,6 @@ public static function getPages(): array { return [ 'index' => Pages\ListTenants::route('/'), - 'create' => Pages\CreateTenant::route('/create'), 'view' => Pages\ViewTenant::route('/{record}'), 'edit' => Pages\EditTenant::route('/{record}/edit'), 'memberships' => Pages\ManageTenantMemberships::route('/{record}/memberships'), diff --git a/app/Filament/Resources/TenantResource/Pages/CreateTenant.php b/app/Filament/Resources/TenantResource/Pages/CreateTenant.php deleted file mode 100644 index c5fec3e..0000000 --- a/app/Filament/Resources/TenantResource/Pages/CreateTenant.php +++ /dev/null @@ -1,41 +0,0 @@ - $data - * @return array - */ - protected function mutateFormDataBeforeCreate(array $data): array - { - $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(); - - if ($workspaceId !== null) { - $data['workspace_id'] = $workspaceId; - } - - return $data; - } - - protected function afterCreate(): void - { - $user = auth()->user(); - - if (! $user instanceof User) { - return; - } - - $user->tenants()->syncWithoutDetaching([ - $this->record->getKey() => ['role' => 'owner'], - ]); - } -} diff --git a/app/Filament/Resources/TenantResource/Pages/ListTenants.php b/app/Filament/Resources/TenantResource/Pages/ListTenants.php index ebb01d8..6f69899 100644 --- a/app/Filament/Resources/TenantResource/Pages/ListTenants.php +++ b/app/Filament/Resources/TenantResource/Pages/ListTenants.php @@ -13,9 +13,10 @@ class ListTenants extends ListRecords protected function getHeaderActions(): array { return [ - Actions\CreateAction::make() - ->disabled(fn (): bool => ! TenantResource::canCreate()) - ->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to register tenants.') + Actions\Action::make('add_tenant') + ->label('Add tenant') + ->icon('heroicon-m-plus') + ->url(route('admin.onboarding')) ->visible(fn (): bool => $this->getTableRecords()->count() > 0), ]; } @@ -23,9 +24,10 @@ protected function getHeaderActions(): array protected function getTableEmptyStateActions(): array { return [ - Actions\CreateAction::make() - ->disabled(fn (): bool => ! TenantResource::canCreate()) - ->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to register tenants.'), + Actions\Action::make('add_tenant') + ->label('Add tenant') + ->icon('heroicon-m-plus') + ->url(route('admin.onboarding')), ]; } } diff --git a/app/Http/Controllers/SwitchWorkspaceController.php b/app/Http/Controllers/SwitchWorkspaceController.php index 956077d..bedfa44 100644 --- a/app/Http/Controllers/SwitchWorkspaceController.php +++ b/app/Http/Controllers/SwitchWorkspaceController.php @@ -4,12 +4,13 @@ namespace App\Http\Controllers; -use App\Filament\Pages\ChooseTenant; -use App\Filament\Pages\TenantDashboard; use App\Models\User; use App\Models\Workspace; +use App\Services\Audit\WorkspaceAuditLogger; +use App\Support\Audit\AuditActionId; use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceIntendedUrl; +use App\Support\Workspaces\WorkspaceRedirectResolver; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -43,32 +44,37 @@ public function __invoke(Request $request): RedirectResponse abort(404); } + $prevWorkspaceId = $context->currentWorkspaceId($request); + $context->setCurrentWorkspace($workspace, $user, $request); + /** @var WorkspaceAuditLogger $auditLogger */ + $auditLogger = app(WorkspaceAuditLogger::class); + + $auditLogger->log( + workspace: $workspace, + action: AuditActionId::WorkspaceSelected->value, + context: [ + 'metadata' => [ + 'method' => 'manual', + 'reason' => 'context_bar', + 'prev_workspace_id' => $prevWorkspaceId, + ], + ], + actor: $user, + resourceType: 'workspace', + resourceId: (string) $workspace->getKey(), + ); + $intendedUrl = WorkspaceIntendedUrl::consume($request); if ($intendedUrl !== null) { return redirect()->to($intendedUrl); } - $tenantsQuery = $user->tenants() - ->where('workspace_id', $workspace->getKey()) - ->where('status', 'active'); + /** @var WorkspaceRedirectResolver $resolver */ + $resolver = app(WorkspaceRedirectResolver::class); - $tenantCount = (int) $tenantsQuery->count(); - - if ($tenantCount === 0) { - return redirect()->route('admin.onboarding'); - } - - if ($tenantCount === 1) { - $tenant = $tenantsQuery->first(); - - if ($tenant !== null) { - return redirect()->to(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant)); - } - } - - return redirect()->to(ChooseTenant::getUrl()); + return redirect()->to($resolver->resolve($workspace, $user)); } } diff --git a/app/Http/Middleware/EnsureWorkspaceSelected.php b/app/Http/Middleware/EnsureWorkspaceSelected.php index 0ab9470..e9fdfa7 100644 --- a/app/Http/Middleware/EnsureWorkspaceSelected.php +++ b/app/Http/Middleware/EnsureWorkspaceSelected.php @@ -1,17 +1,20 @@ route()?->getName(); if (is_string($routeName) && str_contains($routeName, '.auth.')) { @@ -31,10 +44,12 @@ public function handle(Request $request, Closure $next): Response $path = '/'.ltrim($request->path(), '/'); + // --- Step 1: workspace-optional bypass --- if ($this->isWorkspaceOptionalPath($request, $path)) { return $next($request); } + // Tenant-scoped routes are handled separately. if (str_starts_with($path, '/admin/t/')) { return $next($request); } @@ -48,44 +63,105 @@ public function handle(Request $request, Closure $next): Response /** @var WorkspaceContext $context */ $context = app(WorkspaceContext::class); - $workspace = $context->resolveInitialWorkspaceFor($user, $request); - - if ($workspace !== null) { - return $next($request); + // --- Step 2: forced chooser via ?choose=1 --- + if ($request->query('choose') === '1') { + return $this->redirectToChooser(); } - $membershipQuery = WorkspaceMembership::query()->where('user_id', $user->getKey()); + // --- Step 3: validate active session --- + $currentId = $context->currentWorkspaceId($request); - $hasAnyActiveMembership = Schema::hasColumn('workspaces', 'archived_at') - ? $membershipQuery - ->join('workspaces', 'workspace_memberships.workspace_id', '=', 'workspaces.id') - ->whereNull('workspaces.archived_at') - ->exists() - : $membershipQuery->exists(); + if ($currentId !== null) { + $workspace = Workspace::query()->whereKey($currentId)->first(); - $canCreateWorkspace = Gate::forUser($user)->check('create', Workspace::class); + if ( + $workspace instanceof Workspace + && empty($workspace->archived_at) + && $context->isMember($user, $workspace) + ) { + return $next($request); + } - if (! $hasAnyActiveMembership && $this->isOperateHubPath($path)) { - abort(404); + // Stale session — clear and warn. + $this->clearStaleSession($context, $user, $request, $workspace); + + return $this->redirectToChooser(); } - if (! $hasAnyActiveMembership && str_starts_with($path, '/admin/tenants')) { - abort(404); + // --- Step 4: load selectable workspace memberships --- + $selectableMemberships = WorkspaceMembership::query() + ->where('user_id', $user->getKey()) + ->join('workspaces', 'workspace_memberships.workspace_id', '=', 'workspaces.id') + ->whereNull('workspaces.archived_at') + ->select('workspace_memberships.*') + ->get(); + + // --- Step 5: single membership auto-resume --- + if ($selectableMemberships->count() === 1) { + /** @var WorkspaceMembership $membership */ + $membership = $selectableMemberships->first(); + $workspace = Workspace::query()->whereKey($membership->workspace_id)->first(); + + if ($workspace instanceof Workspace) { + $context->setCurrentWorkspace($workspace, $user, $request); + + $this->emitAuditEvent( + workspace: $workspace, + user: $user, + actionId: AuditActionId::WorkspaceAutoSelected, + method: 'auto', + reason: 'single_membership', + ); + + return $this->redirectViaTenantBranching($workspace, $user); + } } - if (! $hasAnyActiveMembership && str_starts_with($path, '/admin/provider-connections')) { - abort(404); + // --- Step 6: last_workspace_id auto-resume --- + if ($user->last_workspace_id !== null) { + $lastWorkspace = Workspace::query()->whereKey($user->last_workspace_id)->first(); + + if ( + $lastWorkspace instanceof Workspace + && empty($lastWorkspace->archived_at) + && $context->isMember($user, $lastWorkspace) + ) { + $context->setCurrentWorkspace($lastWorkspace, $user, $request); + + $this->emitAuditEvent( + workspace: $lastWorkspace, + user: $user, + actionId: AuditActionId::WorkspaceAutoSelected, + method: 'auto', + reason: 'last_used', + ); + + return $this->redirectViaTenantBranching($lastWorkspace, $user); + } + + // Stale last_workspace_id — clear and warn. + $workspaceName = $lastWorkspace?->name; + $user->forceFill(['last_workspace_id' => null])->save(); + + if ($workspaceName !== null) { + Notification::make() + ->title("Your access to {$workspaceName} was removed.") + ->danger() + ->send(); + } } - $target = ($hasAnyActiveMembership || $canCreateWorkspace) - ? '/admin/choose-workspace' - : '/admin/no-access'; - - if ($target === '/admin/choose-workspace') { + // --- Step 7: fallback to chooser --- + if ($selectableMemberships->isNotEmpty()) { WorkspaceIntendedUrl::storeFromRequest($request); } - return new HttpResponse('', 302, ['Location' => $target]); + $canCreate = $user->can('create', Workspace::class); + $target = ($selectableMemberships->isNotEmpty() || $canCreate) + ? '/admin/choose-workspace' + : '/admin/no-access'; + + return new \Illuminate\Http\Response('', 302, ['Location' => $target]); } private function isWorkspaceOptionalPath(Request $request, string $path): bool @@ -110,12 +186,64 @@ private function isWorkspaceOptionalPath(Request $request, string $path): bool return preg_match('#^/admin/operations/[^/]+$#', $path) === 1; } - private function isOperateHubPath(string $path): bool + private function redirectToChooser(): Response { - return in_array($path, [ - '/admin/operations', - '/admin/alerts', - '/admin/audit-log', - ], true); + return new \Illuminate\Http\Response('', 302, ['Location' => '/admin/choose-workspace']); + } + + private function redirectViaTenantBranching(Workspace $workspace, User $user): Response + { + /** @var WorkspaceRedirectResolver $resolver */ + $resolver = app(WorkspaceRedirectResolver::class); + + $url = $resolver->resolve($workspace, $user); + + return new \Illuminate\Http\Response('', 302, ['Location' => $url]); + } + + private function clearStaleSession(WorkspaceContext $context, User $user, Request $request, ?Workspace $workspace): void + { + $workspaceName = $workspace?->name; + + $session = $request->hasSession() ? $request->session() : session(); + $session->forget(WorkspaceContext::SESSION_KEY); + + if ($user->last_workspace_id !== null && $context->currentWorkspaceId($request) === null) { + $user->forceFill(['last_workspace_id' => null])->save(); + } + + if ($workspaceName !== null) { + Notification::make() + ->title("Your access to {$workspaceName} was removed.") + ->danger() + ->send(); + } + } + + private function emitAuditEvent( + Workspace $workspace, + User $user, + AuditActionId $actionId, + string $method, + string $reason, + ?int $prevWorkspaceId = null, + ): void { + /** @var WorkspaceAuditLogger $logger */ + $logger = app(WorkspaceAuditLogger::class); + + $logger->log( + workspace: $workspace, + action: $actionId->value, + context: [ + 'metadata' => [ + 'method' => $method, + 'reason' => $reason, + 'prev_workspace_id' => $prevWorkspaceId, + ], + ], + actor: $user, + resourceType: 'workspace', + resourceId: (string) $workspace->getKey(), + ); } } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 79123e1..3ee9abf 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -25,6 +25,7 @@ use App\Services\Auth\WorkspaceRoleCapabilityMap; use App\Support\Auth\Capabilities; use App\Support\Workspaces\WorkspaceContext; +use Filament\Actions\Action; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\DisableBladeIconComponents; @@ -133,6 +134,25 @@ public function panel(Panel $panel): Panel ->group('Monitoring') ->sort(30), ]) + ->userMenuItems([ + Action::make('switch-workspace') + ->label('Switch workspace') + ->url(fn (): string => ChooseWorkspace::getUrl(panel: 'admin').'?choose=1') + ->icon('heroicon-o-arrows-right-left') + ->visible(function (): bool { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + return WorkspaceMembership::query() + ->where('user_id', (int) $user->getKey()) + ->join('workspaces', 'workspace_memberships.workspace_id', '=', 'workspaces.id') + ->whereNull('workspaces.archived_at') + ->count() > 1; + }), + ]) ->renderHook( PanelsRenderHook::HEAD_END, fn () => view('filament.partials.livewire-intercept-shim')->render() @@ -143,9 +163,11 @@ public function panel(Panel $panel): Panel ) ->renderHook( PanelsRenderHook::BODY_END, - fn () => (bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true) - ? view('livewire.bulk-operation-progress-wrapper')->render() - : '' + fn (): string => request()->routeIs('admin.workspace.managed-tenants.index', 'admin.onboarding', 'filament.admin.pages.choose-tenant') + ? '' + : ((bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true) + ? view('livewire.bulk-operation-progress-wrapper')->render() + : '') ) ->resources([ TenantResource::class, diff --git a/app/Support/Audit/AuditActionId.php b/app/Support/Audit/AuditActionId.php index fd6d8a6..dd0a17f 100644 --- a/app/Support/Audit/AuditActionId.php +++ b/app/Support/Audit/AuditActionId.php @@ -59,4 +59,8 @@ enum AuditActionId: string case BaselineAssignmentCreated = 'baseline_assignment.created'; case BaselineAssignmentUpdated = 'baseline_assignment.updated'; case BaselineAssignmentDeleted = 'baseline_assignment.deleted'; + + // Workspace selection / switch events (Spec 107). + case WorkspaceAutoSelected = 'workspace.auto_selected'; + case WorkspaceSelected = 'workspace.selected'; } diff --git a/app/Support/Workspaces/WorkspaceRedirectResolver.php b/app/Support/Workspaces/WorkspaceRedirectResolver.php new file mode 100644 index 0000000..f83a60f --- /dev/null +++ b/app/Support/Workspaces/WorkspaceRedirectResolver.php @@ -0,0 +1,68 @@ +1 tenants → Choose Tenant page + */ +final class WorkspaceRedirectResolver +{ + /** + * Resolve the redirect URL for the given workspace + user. + * + * Returns a fully qualified URL string. + */ + public function resolve(Workspace $workspace, User $user): string + { + $tenantsQuery = $user->tenants() + ->where('workspace_id', $workspace->getKey()) + ->where('status', 'active'); + + $tenantCount = (int) $tenantsQuery->count(); + + if ($tenantCount === 0) { + return route('admin.workspace.managed-tenants.index', [ + 'workspace' => $workspace->slug ?? $workspace->getKey(), + ]); + } + + if ($tenantCount === 1) { + $tenant = $tenantsQuery->first(); + + if ($tenant !== null) { + return TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant); + } + } + + return ChooseTenant::getUrl(); + } + + /** + * Resolve the redirect URL using a workspace ID. + * + * Falls back to the chooser page if the workspace cannot be resolved. + */ + public function resolveFromId(int $workspaceId, User $user): string + { + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + return ChooseWorkspace::getUrl(); + } + + return $this->resolve($workspace, $user); + } +} diff --git a/resources/views/filament/pages/choose-tenant.blade.php b/resources/views/filament/pages/choose-tenant.blade.php index fc2cda6..843240e 100644 --- a/resources/views/filament/pages/choose-tenant.blade.php +++ b/resources/views/filament/pages/choose-tenant.blade.php @@ -1,60 +1,133 @@ - -
-
- Select a tenant to continue. + @php + $tenants = $this->getTenants(); + $workspace = app(\App\Support\Workspaces\WorkspaceContext::class)->currentWorkspace(); + @endphp + + @if ($tenants->isEmpty()) + {{-- Empty state --}} +
+
+ @if ($workspace) +
+ + {{ $workspace->name }} +
+ @endif + +
+ +
+ +

No tenants available

+

+ There are no active tenants in this workspace yet. Add one via onboarding, or switch to a different workspace. +

+ +
+ + Add tenant + + + + + Switch workspace + +
+
+
+ @else + {{-- Tenant list --}} +
+ {{-- Header row --}} +
+
+ @if ($workspace) +
+ + {{ $workspace->name }} +
+ @endif + + · {{ $tenants->count() }} {{ \Illuminate\Support\Str::plural('tenant', $tenants->count()) }} + +
- @php - $tenants = $this->getTenants(); - @endphp +

Select a tenant to continue.

- @if ($tenants->isEmpty()) -
-
No tenants are available for your account.
-
- Switch workspaces, or contact an administrator. -
- -
- - Change workspace - -
-
- @else -
- @foreach ($tenants as $tenant) -
-
- @csrf - -
- {{ $tenant->name }} -
- - - Continue - -
+ {{-- Tenant cards --}} +
+ @foreach ($tenants as $tenant) +
- @endif + +
+
+ +
+
+

+ {{ $tenant->name }} +

+ @if ($tenant->domain) +

+ {{ $tenant->domain }} +

+ @endif + @if ($tenant->environment) + + {{ strtoupper($tenant->environment) }} + + @endif +
+
+ + {{-- Hover arrow --}} +
+ +
+ + @endforeach +
+ + {{-- Footer links --}} +
- + @endif diff --git a/resources/views/filament/pages/choose-workspace.blade.php b/resources/views/filament/pages/choose-workspace.blade.php index 913b9ba..190a61b 100644 --- a/resources/views/filament/pages/choose-workspace.blade.php +++ b/resources/views/filament/pages/choose-workspace.blade.php @@ -1,70 +1,155 @@ - -
-
+ @php + $workspaces = $this->getWorkspaces(); + $workspaceRoles = $this->workspaceRoles; + + $user = auth()->user(); + $recommendedWorkspaceId = $user instanceof \App\Models\User ? (int) ($user->last_workspace_id ?? 0) : 0; + + if ($recommendedWorkspaceId > 0) { + [$recommended, $other] = $workspaces->partition(fn ($workspace) => (int) $workspace->id === $recommendedWorkspaceId); + $workspaces = $recommended->concat($other)->values(); + } + + $roleColorMap = [ + 'owner' => 'primary', + 'manager' => 'info', + 'operator' => 'gray', + 'readonly' => 'gray', + ]; + + $roleIconMap = [ + 'owner' => 'heroicon-m-shield-check', + 'manager' => 'heroicon-m-cog-6-tooth', + 'operator' => 'heroicon-m-wrench-screwdriver', + 'readonly' => 'heroicon-m-eye', + ]; + + $canManageWorkspaces = false; + if ($user instanceof \App\Models\User && $workspaces->count() > 0) { + foreach ($workspaces as $ws) { + if (($workspaceRoles[(int) $ws->id] ?? null) === 'owner') { + $canManageWorkspaces = true; + break; + } + } + } + @endphp + + @if ($workspaces->isEmpty()) +
+
+
+ +
+

No workspaces available

+

+ You don't have access to any workspace yet. Contact your administrator to get started. +

+
+
+ @else +
+

Select a workspace to continue. +

+ +
+ @foreach ($workspaces as $workspace) + @php + $isRecommended = $recommendedWorkspaceId > 0 && (int) $workspace->id === $recommendedWorkspaceId; + $role = $workspaceRoles[(int) $workspace->id] ?? null; + $tenantCount = (int) ($workspace->tenants_count ?? 0); + @endphp + + + @endforeach
- @php - $workspaces = $this->getWorkspaces(); - - $user = auth()->user(); - $recommendedWorkspaceId = $user instanceof \App\Models\User ? (int) ($user->last_workspace_id ?? 0) : 0; - - if ($recommendedWorkspaceId > 0) { - [$recommended, $other] = $workspaces->partition(fn ($workspace) => (int) $workspace->id === $recommendedWorkspaceId); - $workspaces = $recommended->concat($other)->values(); - } - @endphp - - @if ($workspaces->isEmpty()) -
- No active workspaces are available for your account. - You can create one using the button above. -
- @else -
- @foreach ($workspaces as $workspace) - @php - $isRecommended = $recommendedWorkspaceId > 0 && (int) $workspace->id === $recommendedWorkspaceId; - @endphp - -
-
- @csrf - - -
-
- {{ $workspace->name }} -
- - @if ($isRecommended) -
- - Last used - -
- @endif -
- - - Continue - -
-
- @endforeach + @if ($canManageWorkspaces) + @endif
- + @endif diff --git a/resources/views/filament/pages/workspaces/managed-tenant-onboarding-wizard.blade.php b/resources/views/filament/pages/workspaces/managed-tenant-onboarding-wizard.blade.php index b786d58..cc61477 100644 --- a/resources/views/filament/pages/workspaces/managed-tenant-onboarding-wizard.blade.php +++ b/resources/views/filament/pages/workspaces/managed-tenant-onboarding-wizard.blade.php @@ -1,170 +1,3 @@ - -
-
- Workspace: {{ $this->workspace->name }} -
-
-
- Managed tenant onboarding -
-
- This wizard will guide you through identifying a managed tenant and verifying access. -
-
- - @if ($this->managedTenant) -
-
Identified tenant
-
-
-
Name
-
{{ $this->managedTenant->name }}
-
-
-
Tenant ID
-
{{ $this->managedTenant->tenant_id }}
-
-
-
- @endif - - @php - $verificationSucceeded = $this->verificationSucceeded(); - $hasTenant = (bool) $this->managedTenant; - $hasConnection = $hasTenant && is_int($this->selectedProviderConnectionId) && $this->selectedProviderConnectionId > 0; - @endphp - -
-
-
-
-
Step 1 — Identify managed tenant
-
Provide tenant ID + display name to start or resume the flow.
-
-
- {{ $hasTenant ? 'Done' : 'Pending' }} -
-
- -
- - {{ $hasTenant ? 'Change tenant' : 'Identify tenant' }} - -
-
- -
-
-
-
Step 2 — Provider connection
-
Create or pick the connection used to verify access.
-
-
- {{ $hasConnection ? 'Selected' : ($hasTenant ? 'Pending' : 'Locked') }} -
-
- - @if ($hasTenant) -
- Selected connection ID -
{{ $this->selectedProviderConnectionId ?? '—' }}
-
- -
- - Create connection - - - - Select connection - -
- @endif -
- -
-
-
-
Step 3 — Verify access
-
Runs a verification operation and records the result.
-
-
- {{ $verificationSucceeded ? 'Succeeded' : ($hasConnection ? 'Pending' : 'Locked') }} -
-
- -
- - Run verification - -
-
- -
-
-
-
Step 4 — Bootstrap (optional)
-
Start inventory/compliance sync after verification.
-
-
- {{ $verificationSucceeded ? 'Available' : 'Locked' }} -
-
- -
- - Start bootstrap - -
-
-
- -
-
-
-
Step 5 — Complete onboarding
-
Marks the tenant as active after successful verification.
-
-
- {{ $verificationSucceeded ? 'Ready' : 'Locked' }} -
-
- -
- - Complete onboarding - -
-
-
-
diff --git a/resources/views/filament/pages/workspaces/managed-tenants-landing.blade.php b/resources/views/filament/pages/workspaces/managed-tenants-landing.blade.php index 6c7a2ba..a1bce92 100644 --- a/resources/views/filament/pages/workspaces/managed-tenants-landing.blade.php +++ b/resources/views/filament/pages/workspaces/managed-tenants-landing.blade.php @@ -1,76 +1,131 @@ - -
-
- Workspace: {{ $this->workspace->name }} + @php + $tenants = $this->getTenants(); + @endphp + + @if ($tenants->isEmpty()) + {{-- Empty state — enterprise-grade --}} +
+
+ {{-- Workspace context badge --}} +
+ + {{ $this->workspace->name }} +
+ + {{-- Icon --}} +
+ +
+ +

No managed tenants yet

+

+ Connect your first Microsoft Entra tenant to start managing inventory, backups, drift detection, and policies. +

+ +
+ + Add tenant + + + + + Switch workspace + +
+
+
+ @else + {{-- Tenant list --}} +
+ {{-- Header row --}} +
+
+
+ + {{ $this->workspace->name }} +
+ + · {{ $tenants->count() }} {{ \Illuminate\Support\Str::plural('tenant', $tenants->count()) }} + +
+ + + Choose tenant +
- @php - $tenants = $this->getTenants(); - @endphp - - @if ($tenants->isEmpty()) -
-
No managed tenants yet.
-
- Add a managed tenant to start inventory, drift, backups, and policy management. -
- -
- - Start onboarding - - - - Change workspace - -
-
- @else -
-
- {{ $tenants->count() }} managed tenant{{ $tenants->count() === 1 ? '' : 's' }} -
- - + @foreach ($tenants as $tenant) +
+ {{-- Loading overlay --}} +
+ +
-
- @foreach ($tenants as $tenant) -
-
-
+
+
+ +
+
+

{{ $tenant->name }} -

- - - Open - + +

+ {{ $tenant->external_id ?? 'No external ID' }} +

- @endforeach -
- @endif + + {{-- Hover arrow --}} +
+ +
+ + @endforeach +
+ + {{-- Footer links --}} +
- + @endif diff --git a/routes/web.php b/routes/web.php index 14ad6d2..e4bb747 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,5 @@ to('/admin/choose-workspace'); } - $tenantsQuery = $user->tenants() - ->where('workspace_id', $workspace->getKey()) - ->where('status', 'active'); + /** @var WorkspaceRedirectResolver $resolver */ + $resolver = app(WorkspaceRedirectResolver::class); - $tenantCount = (int) $tenantsQuery->count(); - - if ($tenantCount === 0) { - return redirect()->to('/admin/onboarding'); - } - - if ($tenantCount === 1) { - $tenant = $tenantsQuery->first(); - - if ($tenant !== null) { - return redirect()->to(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant)); - } - } - - return redirect()->to('/admin/choose-tenant'); + return redirect()->to($resolver->resolve($workspace, $user)); }) ->name('admin.home'); diff --git a/specs/107-workspace-chooser/checklists/requirements.md b/specs/107-workspace-chooser/checklists/requirements.md new file mode 100644 index 0000000..957ebc2 --- /dev/null +++ b/specs/107-workspace-chooser/checklists/requirements.md @@ -0,0 +1,47 @@ +# Specification Quality Checklist: Workspace Chooser v1 (Enterprise) + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-02-22 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) + - Note: Spec references existing codebase components by name for context (WorkspaceContext, AuditActionId) but requirements are behavior-focused. Framework names appear in UI Action Matrix as required by template. +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders (user stories are plain language) +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified (zero memberships, archived workspace, stale session, forced chooser) +- [x] Scope is clearly bounded (v1 vs v2 backlog explicit) +- [x] Dependencies and assumptions identified (existing infrastructure documented in Context section) + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows (auto-resume single, auto-resume last-used, chooser fallback, revoked membership, manual switch, audit) +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification (behavior-first language throughout) + +## Constitution Alignment + +- [x] RBAC-UX: authorization plane stated (workspace /admin scope) +- [x] RBAC-UX: 404 vs 403 semantics defined (non-member → 404) +- [x] RBAC-UX: server-side enforcement described (middleware) +- [x] RBAC-UX: capability registry referenced (Capabilities::WORKSPACE_MANAGE) +- [x] Audit: audit events defined with payloads +- [x] UX-001: exemption documented (context selector, not CRUD page) +- [x] UI Action Matrix completed + +## Notes + +- Spec deliberately uses `users.last_workspace_id` (existing column) instead of proposing a new `user_preferences` table, aligning with current codebase patterns. +- Middleware algorithm is specified in detail because it's the core business logic of the feature; still expressed as behavior rules, not code. +- All items pass. Spec is ready for `/speckit.plan`. diff --git a/specs/107-workspace-chooser/contracts/routes.md b/specs/107-workspace-chooser/contracts/routes.md new file mode 100644 index 0000000..d01f1b9 --- /dev/null +++ b/specs/107-workspace-chooser/contracts/routes.md @@ -0,0 +1,126 @@ +# Routes Contract: Workspace Chooser v1 + +**Feature**: 107-workspace-chooser | **Date**: 2026-02-22 + +## Routes (existing, behavior changes) + +### `GET /admin` (named: `admin.home`) + +**Change**: After workspace auto-resume, redirect uses the shared `WorkspaceRedirectResolver` instead of inline branching. + +**Middleware**: `web`, `panel:admin`, `ensure-correct-guard:web`, `FilamentAuthenticate`, `ensure-workspace-selected` + +**Behavior (updated)**: +1. `ensure-workspace-selected` middleware handles auto-resume (may set workspace + redirect before this handler runs). +2. If workspace is resolved, apply tenant-count branching. +3. If no workspace, redirect to `/admin/choose-workspace`. + +--- + +### `GET /admin/choose-workspace` (Filament Page: `ChooseWorkspace`) + +**Change**: Page now displays metadata (role, tenant count), cleaner empty state, "Manage workspaces" link instead of "Create workspace" header action. + +**Middleware**: Standard admin panel middleware. `ensure-workspace-selected` allows this path (exempted in `isWorkspaceOptionalPath()`). + +**Query params**: +- `?choose=1` — forces chooser display (bypasses auto-resume). The middleware redirects here when this param is present. + +**Response**: Filament page with workspace cards. + +**Livewire actions**: +- `selectWorkspace(int $workspaceId)` — validates membership, sets workspace context, emits audit event, redirects via tenant-count branching. + +--- + +### `POST /admin/switch-workspace` (named: `admin.switch-workspace`) + +**Change**: Redirect logic replaced with `WorkspaceRedirectResolver`. Audit logging added via `WorkspaceAuditLogger::log()` — emits `workspace.selected` with reason `context_bar` to satisfy FR-005 (every workspace selection must be audited). + +**Controller**: `SwitchWorkspaceController` + +**Request body**: `workspace_id` (required, integer) + +**Middleware**: `web`, `auth`, `ensure-correct-guard:web` + +--- + +## Middleware Contract: `ensure-workspace-selected` + +### Algorithm (v1 — 7-step) + +``` +Step 1: If path is workspace-optional → ALLOW (no redirect) +Step 2: If query has `choose=1` → REDIRECT to /admin/choose-workspace?choose=1 +Step 3: If session.current_workspace_id is set: + - If membership valid + not archived → ALLOW + - Else: clear session + flash warning → REDIRECT to chooser +Step 4: Load user's selectable workspace memberships (not archived) +Step 5: If exactly 1 → auto-select, audit log (single_membership) → REDIRECT via tenant branching +Step 6: If last_workspace_id set: + - If valid membership + selectable → auto-select, audit log (last_used) → REDIRECT via tenant branching + - Else: clear last_workspace_id + flash warning → REDIRECT to chooser +Step 7: Else → REDIRECT to chooser +``` + +### Exempt Paths (workspace-optional) + +- `/admin/workspaces*` +- `/admin/choose-workspace` +- `/admin/no-access` +- `/admin/onboarding` +- `/admin/settings/workspace` +- `/admin/operations/{id}` (existing exemption) +- `/admin/t/*` (tenant-scoped routes) +- Routes with `.auth.` in name + +--- + +## User Menu Contract + +### "Switch workspace" menu item + +**Location**: Admin panel user menu (registered via `AdminPanelProvider::panel()` → `->userMenuItems()`) + +**Visibility**: Only when current user has > 1 workspace membership. + +**URL**: `/admin/choose-workspace?choose=1` + +**Icon**: `heroicon-o-arrows-right-left` + +--- + +## Audit Event Contracts + +### `workspace.auto_selected` + +**Trigger**: Middleware auto-resume (steps 5 or 6). + +**Payload** (in `audit_logs.metadata`): + +```json +{ + "method": "auto", + "reason": "single_membership" | "last_used", + "prev_workspace_id": null +} +``` + +### `workspace.selected` + +**Trigger**: Manual selection from chooser (via `selectWorkspace()`). + +**Payload** (in `audit_logs.metadata`): + +```json +{ + "method": "manual", + "reason": "chooser", + "prev_workspace_id": 42 +} +``` + +Both events use `WorkspaceAuditLogger::log()` with: +- `action`: `AuditActionId::WorkspaceAutoSelected->value` or `AuditActionId::WorkspaceSelected->value` +- `resource_type`: `'workspace'` +- `resource_id`: `(string) $workspace->getKey()` diff --git a/specs/107-workspace-chooser/data-model.md b/specs/107-workspace-chooser/data-model.md new file mode 100644 index 0000000..9d1713d --- /dev/null +++ b/specs/107-workspace-chooser/data-model.md @@ -0,0 +1,142 @@ +# Data Model: Workspace Chooser v1 + +**Feature**: 107-workspace-chooser | **Date**: 2026-02-22 + +## Existing Entities (No Changes) + +### workspaces + +| Column | Type | Nullable | Notes | +|--------|------|----------|-------| +| id | bigint (PK) | NO | Auto-increment | +| name | varchar(255) | NO | Display name | +| slug | varchar(255) | YES | URL-safe identifier | +| archived_at | timestamp | YES | Soft-archive marker; non-null = archived | +| created_at | timestamp | NO | | +| updated_at | timestamp | NO | | + +### workspace_memberships + +| Column | Type | Nullable | Notes | +|--------|------|----------|-------| +| id | bigint (PK) | NO | Auto-increment | +| workspace_id | bigint (FK) | NO | → workspaces.id | +| user_id | bigint (FK) | NO | → users.id | +| role | varchar(255) | NO | 'owner', 'admin', 'member' | +| created_at | timestamp | NO | | +| updated_at | timestamp | NO | | + +### users (relevant columns only) + +| Column | Type | Nullable | Notes | +|--------|------|----------|-------| +| last_workspace_id | bigint (FK) | YES | → workspaces.id; auto-resume preference | + +### audit_logs (relevant columns only) + +| Column | Type | Nullable | Notes | +|--------|------|----------|-------| +| id | bigint (PK) | NO | | +| workspace_id | bigint | YES | → workspaces.id | +| tenant_id | bigint | YES | NULL for workspace-scoped events | +| actor_id | bigint | YES | → users.id | +| actor_email | varchar | YES | | +| actor_name | varchar | YES | | +| action | varchar | NO | stable action ID string | +| resource_type | varchar | YES | | +| resource_id | varchar | YES | | +| status | varchar | NO | 'success' / 'failure' | +| metadata | jsonb | YES | additional context (sanitized) | +| recorded_at | timestamp | NO | | + +### Session (in-memory / store-backed) + +| Key | Type | Notes | +|-----|------|-------| +| `current_workspace_id` | int | Set by `WorkspaceContext::setCurrentWorkspace()` | + +## New Data (Enum Values Only) + +### AuditActionId Enum — New Cases + +```php +case WorkspaceAutoSelected = 'workspace.auto_selected'; +case WorkspaceSelected = 'workspace.selected'; +``` + +### Audit Log Metadata Schema (for workspace selection events) + +```jsonc +{ + "method": "auto" | "manual", + "reason": "single_membership" | "last_used" | "chooser" | "context_bar", + "prev_workspace_id": 123 | null // previous workspace if switching +} +``` + +## Entity Relationships (relevant to this feature) + +```text +User ──< WorkspaceMembership >── Workspace + │ │ + └── last_workspace_id ────────────┘ + +User ──< AuditLog >── Workspace +``` + +## Validation Rules + +| Field | Rule | Source | +|-------|------|--------| +| Workspace selectability | `archived_at IS NULL` | `WorkspaceContext::isWorkspaceSelectable()` | +| Membership check | `workspace_memberships WHERE user_id AND workspace_id` | `WorkspaceContext::isMember()` | +| `choose` param | `?choose=1` (truthy string) | Middleware step 2 | +| Non-member selection attempt | abort(404) | FR deny-as-not-found | + +## State Transitions + +The workspace selection flow is a session-context transition, not a data state machine. + +```text +[No Session] ──auto-resume──> [Active Workspace Session] +[No Session] ──chooser──────> [Active Workspace Session] +[Active Session] ──switch───> [Active Workspace Session (different)] +[Active Session] ──revoked──> [No Session] + warning flash +[Active Session] ──archived─> [No Session] + warning flash +``` + +## Query Patterns + +### Chooser Page Query (FR-003, FR-011) + +```php +Workspace::query() + ->whereIn('id', function ($query) use ($user) { + $query->from('workspace_memberships') + ->select('workspace_id') + ->where('user_id', $user->getKey()); + }) + ->whereNull('archived_at') + ->withCount('tenants') + ->orderBy('name') + ->get(); +``` + +Joined via subquery (no N+1). `withCount('tenants')` adds a single correlated subquery. Result includes `tenants_count` attribute. + +### Role Retrieval for Display + +```php +// Eager-load membership pivot to get role per workspace +// Option A: Join workspace_memberships in the query +// Option B: Use $workspace->pivot->role when loaded via user relationship + +// Preferred: load memberships separately keyed by workspace_id +$memberships = WorkspaceMembership::query() + ->where('user_id', $user->getKey()) + ->pluck('role', 'workspace_id'); + +// Then in view: $memberships[$workspace->id] ?? 'member' +``` + +Single query, keyed by workspace_id, accessed in O(1) per card. diff --git a/specs/107-workspace-chooser/plan.md b/specs/107-workspace-chooser/plan.md new file mode 100644 index 0000000..7b114fb --- /dev/null +++ b/specs/107-workspace-chooser/plan.md @@ -0,0 +1,113 @@ +# Implementation Plan: Workspace Chooser v1 (Enterprise) + In-App Switch Entry Point + +**Branch**: `107-workspace-chooser` | **Date**: 2026-02-22 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/107-workspace-chooser/spec.md` + +## Summary + +Refactor the workspace resolution flow to provide an enterprise-grade auto-resume, explicit switch/manage separation, enhanced metadata in the chooser, and audit events for all workspace selection transitions. The primary changes are: + +1. **Refactor `EnsureWorkspaceSelected` middleware** to implement the spec's 7-step auto-resume algorithm with stale-membership detection and flash warnings. +2. **Upgrade the `ChooseWorkspace` page** with role badges, tenant counts, "Manage workspaces" link (capability-gated), and cleaned-up empty state (no "Create workspace" header action). +3. **Add audit events** for workspace auto-selection and manual selection via new `AuditActionId` enum cases + `WorkspaceAuditLogger` calls. +4. **Add "Switch workspace" user menu entry** visible only when user has >1 workspace membership. +5. **Support `?choose=1` forced chooser** bypass parameter in middleware. + +No new tables, no new columns, no Microsoft Graph calls. All changes are DB-only, session-based, and synchronous. + +## Technical Context + +**Language/Version**: PHP 8.4 / Laravel 12 +**Primary Dependencies**: Filament v5, Livewire v4, Tailwind CSS v4 +**Storage**: PostgreSQL (existing tables: `workspaces`, `workspace_memberships`, `users`, `audit_logs`) +**Testing**: Pest v4 (feature tests as Livewire component tests + HTTP tests) +**Target Platform**: Web (Sail/Docker locally, Dokploy for staging/production) +**Project Type**: Web application (Laravel monolith) +**Performance Goals**: Chooser page < 200ms DB time with 50 workspace memberships; no N+1 queries +**Constraints**: Session-based workspace context (all tabs share); no new tables/columns +**Scale/Scope**: Single Filament page refactor + 1 middleware refactor + 2 enum values + user menu entry + ~17 tests + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- [x] **Inventory-first**: N/A — this feature does not interact with Inventory. Workspace selection is a session context operation. +- [x] **Read/write separation**: The only write is updating `users.last_workspace_id` (convenience preference) and creating audit log entries. No destructive mutations — no preview/confirmation needed for preference persistence. Audit events fire on every selection. +- [x] **Graph contract path**: N/A — no Microsoft Graph calls in this feature. All data is local (workspaces, memberships, session). +- [x] **Deterministic capabilities**: `Capabilities::WORKSPACE_MANAGE` is referenced via the canonical registry constant. No new capabilities introduced. +- [x] **RBAC-UX**: Feature operates in the `/admin` plane only. Non-member workspace selection returns 404 (deny-as-not-found) via `WorkspaceContext::isMember()`. "Manage workspaces" link gated by `workspace.manage` capability. No cross-plane access introduced. +- [x] **Workspace isolation**: Middleware ensures workspace membership on every `/admin/*` request. Stale sessions are cleared and redirected. Non-members get 404. +- [x] **Destructive actions**: No destructive actions in this feature. The re-selection is a non-destructive context switch. +- [x] **Global search**: No changes to global search behavior. +- [x] **Tenant isolation**: Not directly affected. After workspace selection, the existing tenant-count branching routes to tenant-scoped flows. +- [x] **Run observability**: N/A — workspace selection is a synchronous, DB-only, < 2s session operation. No `OperationRun` needed. Selection events are audit-logged. +- [x] **Automation**: N/A — no queued/scheduled operations. +- [x] **Data minimization**: Audit log stores only `actor_id`, `workspace_id`, `method`, `reason`, `prev_workspace_id` — no secrets/tokens/PII. +- [x] **Badge semantics (BADGE-001)**: Role badge in chooser renders the workspace membership role. Simple color-mapped Filament badge (no status-like semantics, just a label). The workspace membership role is a tag/category, not a status — exempt from `BadgeCatalog`. Verified: no `BadgeDomain` exists for workspace roles. +- [x] **Filament UI Action Surface Contract**: ChooseWorkspace is a custom context-selector page, not a CRUD Resource. Spec includes UI Action Matrix with explicit exemption documented. No header actions (v1), "Open" per workspace, empty state with specific title + CTA. +- [x] **Filament UI UX-001**: This is a context-selector page, not a Create/Edit/View resource page. UX-001 Main/Aside layout does not apply. Exemption documented in spec. + +## Project Structure + +### Documentation (this feature) + +```text +specs/107-workspace-chooser/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ # Phase 1 output +│ └── routes.md +└── tasks.md # Phase 2 output (created by /speckit.tasks) +``` + +### Source Code (repository root) + +```text +app/ +├── Http/ +│ ├── Controllers/ +│ │ └── SwitchWorkspaceController.php # MODIFY — WorkspaceRedirectResolver + audit (context_bar) +│ └── Middleware/ +│ └── EnsureWorkspaceSelected.php # MODIFY — refactor to spec algorithm +├── Filament/ +│ └── Pages/ +│ └── ChooseWorkspace.php # MODIFY — metadata, remove Create action, audit +├── Providers/ +│ └── Filament/ +│ └── AdminPanelProvider.php # MODIFY — add user menu item +├── Support/ +│ ├── Audit/ +│ │ └── AuditActionId.php # MODIFY — add 2 enum cases +│ └── Workspaces/ +│ └── WorkspaceRedirectResolver.php # NEW — tenant-count branching helper (R4) + +resources/ +└── views/ + └── filament/ + └── pages/ + └── choose-workspace.blade.php # MODIFY — metadata cards, empty state, manage link + +routes/ +└── web.php # MODIFY — WorkspaceRedirectResolver integration + +tests/ +└── Feature/ + └── Workspaces/ + ├── EnsureWorkspaceSelectedMiddlewareTest.php # NEW + ├── ChooseWorkspacePageTest.php # NEW + ├── WorkspaceSwitchUserMenuTest.php # NEW + ├── WorkspaceRedirectResolverTest.php # NEW + └── WorkspaceAuditTrailTest.php # NEW +``` + +**Structure Decision**: Standard Laravel monolith structure. All changes are in existing directories. No new folders needed. + +## Complexity Tracking + +> No Constitution Check violations. No justifications needed. + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| — | — | — | diff --git a/specs/107-workspace-chooser/quickstart.md b/specs/107-workspace-chooser/quickstart.md new file mode 100644 index 0000000..4c0d4e8 --- /dev/null +++ b/specs/107-workspace-chooser/quickstart.md @@ -0,0 +1,87 @@ +# Quickstart: Workspace Chooser v1 + +**Feature**: 107-workspace-chooser | **Date**: 2026-02-22 + +## Prerequisites + +- Branch: `107-workspace-chooser` checked out +- Sail running: `vendor/bin/sail up -d` +- Existing workspace + user fixtures (factory-based) + +## Implementation Order + +### Phase A: Foundation (no visible changes) + +1. **Add `AuditActionId` enum cases** — `WorkspaceAutoSelected`, `WorkspaceSelected` +2. **Extract `WorkspaceRedirectResolver`** — shared tenant-count branching helper (DRY the 4 current copies) +3. **Tests for redirect resolver** — verify 0/1/>1 tenant branching + +### Phase B: Middleware Refactor (core behavior change) + +4. **Refactor `EnsureWorkspaceSelected`** — implement 7-step algorithm from spec + - Step 1: workspace-optional path bypass (keep existing `isWorkspaceOptionalPath()`) + - Step 2: `?choose=1` handling (new) + - Step 3: stale session detection + flash warning (enhanced) + - Step 4-5: single membership auto-resume + audit (new) + - Step 6: `last_workspace_id` auto-resume + audit (new) + - Step 7: fallback to chooser (existing) +5. **Middleware tests** — all 7 steps covered + +### Phase C: Chooser Page Upgrade (UI changes) + +6. **Refactor `ChooseWorkspace` page**: + - Remove "Create workspace" header action + - Add `withCount('tenants')` to query + - Load membership roles keyed by workspace_id + - Expose `getWorkspaceRole()` and `getWorkspaceMemberships()` for Blade +7. **Update `choose-workspace.blade.php`**: + - Add role badge per card + - Add tenant count per card + - Add "Manage workspaces" link (capability-gated) + - Update empty state (spec copy) + - Replace form POST with `wire:click="selectWorkspace({{ $workspace->id }})"` +8. **Add audit logging in `selectWorkspace()`** — emit `workspace.selected` with metadata +9. **Chooser page tests** — metadata display, empty state, manage link visibility, audit events + +### Phase D: User Menu Integration + +10. **Register "Switch workspace" in `AdminPanelProvider`** — `userMenuItems()` with visibility condition +11. **User menu tests** — visible when >1 workspace, hidden when 1 + +### Phase E: Cleanup & Verification + +12. **Replace inline tenant-branching** in `SwitchWorkspaceController` and `routes/web.php` with `WorkspaceRedirectResolver`; add `WorkspaceAuditLogger::log()` for `context_bar` switch path in `SwitchWorkspaceController` +13. **Run full test suite** — verify no regressions +14. **Pint formatting** — `vendor/bin/sail bin pint --dirty` +15. **Commit + push** + +## Key Files to Understand First + +| File | Why | +|------|-----| +| `app/Http/Middleware/EnsureWorkspaceSelected.php` | The middleware being refactored | +| `app/Filament/Pages/ChooseWorkspace.php` | The page being upgraded | +| `app/Support/Workspaces/WorkspaceContext.php` | The workspace session manager | +| `app/Services/Audit/WorkspaceAuditLogger.php` | Where audit events are emitted | +| `app/Support/Audit/AuditActionId.php` | Where enum cases are added | +| `app/Http/Controllers/SwitchWorkspaceController.php` | POST switch (redirect resolver integration) | +| `routes/web.php` (lines 36-82) | `/admin` route with duplicated branching | + +## Verification Commands + +```bash +# Run workspace-related tests +vendor/bin/sail artisan test --compact tests/Feature/Workspaces/ + +# Run specific middleware test +vendor/bin/sail artisan test --compact --filter=EnsureWorkspaceSelected + +# Run chooser page test +vendor/bin/sail artisan test --compact --filter=ChooseWorkspacePage + +# Format +vendor/bin/sail bin pint --dirty + +# Full suite +vendor/bin/sail artisan test --compact +``` diff --git a/specs/107-workspace-chooser/research.md b/specs/107-workspace-chooser/research.md new file mode 100644 index 0000000..68e4a56 --- /dev/null +++ b/specs/107-workspace-chooser/research.md @@ -0,0 +1,106 @@ +# Research: Workspace Chooser v1 + +**Feature**: 107-workspace-chooser | **Date**: 2026-02-22 + +## R1: Middleware Refactor Strategy + +**Question**: Should we create a new `EnsureActiveWorkspace` middleware or refactor the existing `EnsureWorkspaceSelected`? + +- **Decision**: Refactor the existing `EnsureWorkspaceSelected` middleware in-place. +- **Rationale**: The existing middleware is already registered in both `AdminPanelProvider` and `TenantPanelProvider` middleware stacks as `'ensure-workspace-selected'`, and referenced by alias in `bootstrap/app.php`. Creating a new class would require changing all registration points and updating existing tests. The current class already handles the same responsibilities — it just doesn't implement them according to the spec. +- **Alternatives considered**: + - New `EnsureActiveWorkspace` class: rejected because it would require renaming the middleware alias everywhere, with no functional benefit beyond a name change. The alias can remain `ensure-workspace-selected` for backward compatibility. + +## R2: Audit Event Integration Pattern + +**Question**: How should workspace selection audit events be emitted? + +- **Decision**: Call `WorkspaceAuditLogger::log()` directly from the middleware (for auto-selections) and from `ChooseWorkspace::selectWorkspace()` (for manual selections). No events/listeners needed. +- **Rationale**: `WorkspaceAuditLogger` is a simple synchronous service — no queue, no listener. The codebase pattern (used in workspace membership, settings, alert destinations, baselines, etc.) is direct `$logger->log(...)` calls at the mutation point. Workspace selection audit is similarly < 1ms DB insert. +- **Alternatives considered**: + - Laravel Events + Listeners: rejected — overkill for a synchronous log write. No other systems need to react to workspace selection events. + - Observer on `User` model (`last_workspace_id` change): rejected — would miss cases where only the session changes (auto-resume from session), and would conflate preference persistence with audit semantics. + +## R3: `AuditActionId` Enum Values + +**Question**: What enum values and string representations to use? + +- **Decision**: Add two cases: + - `WorkspaceAutoSelected = 'workspace.auto_selected'` — for auto-resume (single membership or last-used). + - `WorkspaceSelected = 'workspace.selected'` — for manual selection from chooser. +- **Rationale**: Follows the existing naming pattern (`case CamelName = 'snake.dotted_value'`). The `method` (auto/manual) and `reason` (single_membership/last_used/chooser) are stored in the audit log's `metadata` JSONB, not in separate enum values. +- **Alternatives considered**: + - Three separate enum values (one per reason): rejected — metadata provides sufficient granularity; enum values should represent the action type, not the trigger. + +## R4: Redirect After Selection (Tenant-Count Branching) + +**Question**: Where does redirect logic live? Should it be deduplicated? + +- **Decision**: Extract the tenant-count branching logic into a shared helper method on `WorkspaceContext` or a dedicated `WorkspaceRedirectResolver` to avoid duplicating it across: + 1. `EnsureWorkspaceSelected` middleware (auto-resume redirects) + 2. `ChooseWorkspace::selectWorkspace()` (manual selection redirect) + 3. `SwitchWorkspaceController::__invoke()` (POST switch redirect) + 4. `routes/web.php` `/admin` route handler + + Currently, the same branching logic (0 tenants → managed-tenants, 1 → tenant dashboard, >1 → choose-tenant) is copy-pasted in all four locations. +- **Rationale**: DRY — the branching is identical in all cases and is the single authority for "where to go after workspace is set." A single method eliminates the risk of divergence as new conditions are added. +- **Alternatives considered**: + - Leave duplicated: rejected — 4 copies of the same logic is a maintenance hazard. + - Put on `ChooseWorkspace` page: rejected — the middleware and controller both need it but don't have access to the page class. + +## R5: `?choose=1` Handling Location + +**Question**: Should the `?choose=1` forced-chooser parameter be handled in the middleware or in the page? + +- **Decision**: Handle in the middleware — step 2 of the algorithm. If `choose=1` is present, redirect to `/admin/choose-workspace?choose=1` and skip auto-resume logic. +- **Rationale**: The middleware is the single entry point for all `/admin/*` requests. Handling it there prevents auto-resume from overriding the explicit user intent to see the chooser. +- **Alternatives considered**: + - Handle in `ChooseWorkspace` page: rejected — the middleware would auto-resume BEFORE the page loads, so the user would never see the chooser. + +## R6: User Menu Integration + +**Question**: How to add "Switch workspace" to the Filament user menu? + +- **Decision**: Register via `->userMenuItems()` in `AdminPanelProvider::panel()`. Use `Filament\Navigation\MenuItem::make()` with `->url('/admin/choose-workspace?choose=1')` and a `->visible()` callback that checks workspace membership count > 1. +- **Rationale**: This is the documented Filament v5 pattern from the constitution + blueprint. The menu item is a navigation-only action (URL link), not a destructive action, so no confirmation needed. +- **Alternatives considered**: + - Context bar link only: rejected — specification explicitly requires a user menu entry (FR-008). + - Adding to both user menu and context bar: the context bar already has "Switch workspace" — the user menu entry provides an additional discovery point per spec. + +## R7: Badge Rendering for Workspace Role + +**Question**: Should workspace membership role badges use `BadgeCatalog`/`BadgeDomain`? + +- **Decision**: No. Workspace membership role is a **tag/category** (owner, admin, member), not a status-like value. Per constitution (BADGE-001), tag/category chips are not governed by `BadgeCatalog`. Use a simple Filament `` with a color mapping (e.g., owner → primary, admin → warning, member → gray). +- **Rationale**: The role is static metadata, not a state transition. No existing `BadgeDomain` for workspace roles. Adding one would be over-engineering for 3 static values. +- **Alternatives considered**: + - Create a `WorkspaceRoleBadgeDomain`: rejected — violates the "tag, not status" exemption in BADGE-001. + +## R8: Blade Template vs. Livewire Component for Chooser + +**Question**: Should the chooser cards remain as a Blade template or be converted to a Livewire component? + +- **Decision**: Keep as Blade template rendered by the existing Livewire-backed `ChooseWorkspace` Filament Page. The page class already extends `Filament\Pages\Page` (which is Livewire). The `wire:click` for "Open" calls the existing `selectWorkspace()` method. No separate Livewire component needed. +- **Rationale**: The existing pattern works. The page is already a Livewire component (all Filament Pages are). Converting to a separate Livewire component adds complexity with no benefit — the chooser has no real-time reactive needs. +- **Alternatives considered**: + - Separate `WorkspaceChooserCard` Livewire component: rejected — unnecessary abstraction for a simple card grid. + +## R9: Existing `SwitchWorkspaceController` Coexistence + +**Question**: The chooser currently uses POST to `SwitchWorkspaceController`. Should we switch to Livewire `wire:click` or keep the POST? + +- **Decision**: Migrate the chooser page to use Livewire `wire:click` calling `selectWorkspace($workspaceId)` (which already exists). The `SwitchWorkspaceController` is still needed for the `workspace-switcher.blade.php` partial (context-bar dropdown) which uses a form POST. Both paths converge on `WorkspaceContext::setCurrentWorkspace()`. +- **Rationale**: The Livewire path already exists in `ChooseWorkspace::selectWorkspace()` — the blade template just needs to call it via `wire:click` instead of a form POST. This simplifies the chooser page and makes audit integration easier (audit logging happens in the PHP method, not in a controller). +- **Alternatives considered**: + - Keep form POST from chooser: rejected — the `selectWorkspace()` method is where we add audit logging. Using `wire:click` means a single code path. + - Remove `SwitchWorkspaceController` entirely: deferred — the context-bar dropdown still uses it. Can be unified in a future PR. + +## R10: Flash Warning Implementation + +**Question**: How to show "Your access to {workspace_name} was removed" when stale membership detected? + +- **Decision**: Use Filament database notifications or session flash + `Filament\Notifications\Notification::make()->danger()`. Since the middleware redirects to the chooser, and the chooser is a Filament page, Filament's notification system renders flash/database notifications automatically. +- **Rationale**: The chooser is a Filament page — Filament's notification toast system is already wired. Session-based `Notification::make()` works for redirect→page scenarios. +- **Alternatives considered**: + - Custom Blade banner: rejected — Filament notifications already solve this and are consistent with the rest of the app. + - Session flash only (no Filament notification): rejected — the Filament notification system provides better UX (auto-dismiss, consistent styling). diff --git a/specs/107-workspace-chooser/spec.md b/specs/107-workspace-chooser/spec.md new file mode 100644 index 0000000..ee28e17 --- /dev/null +++ b/specs/107-workspace-chooser/spec.md @@ -0,0 +1,311 @@ +# Feature Specification: Workspace Chooser v1 (Enterprise) + In-App Switch Entry Point + +**Feature Branch**: `107-workspace-chooser` +**Created**: 2026-02-22 +**Status**: Draft +**Input**: User description: "Workspace Chooser v1 (Enterprise) + In-App Switch Entry Point: Auto-Resume, Switch vs Manage separation, Enterprise metadata, Audit events" + +--- + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace +- **Primary Routes**: `/admin/choose-workspace`, `/admin/*` (middleware), user menu switch entry point +- **Data Ownership**: workspace-owned (`workspaces`, `workspace_memberships`); user-owned (`users.last_workspace_id`) +- **RBAC**: Any workspace member may switch/select; `workspace.manage` capability required for "Manage workspaces" link visibility in chooser + +--- + +## Context + +TenantPilot is workspace-first: a Workspace groups one or more Microsoft Tenants (customer environments). After login, an active workspace must be set so that RBAC, scoping, operations, findings, and all tenant-level features function correctly. + +### Current State (What Exists) + +The codebase already has foundational infrastructure: + +- **`WorkspaceContext`** (`app/Support/Workspaces/WorkspaceContext.php`) — manages `session.current_workspace_id`, `users.last_workspace_id`, and provides `resolveInitialWorkspaceFor()` with partial auto-resume logic (session → last-used → single membership). +- **`ChooseWorkspace`** page (`app/Filament/Pages/ChooseWorkspace.php`) — card grid with "Create workspace" header action, Livewire select, and POST-based form submit. +- **`WorkspaceMembership`** pivot model with `role` column. +- **Audit system** via `WorkspaceAuditLogger` + `AuditActionId` enum (workspace membership events already audited). +- **`Capabilities::WORKSPACE_MANAGE`** already defined in the capability registry. + +### What's Missing (Motivation) + +1. **Switch vs. Manage conflation**: "Create workspace" is prominently placed in the chooser alongside selection. +2. **No explicit auto-resume in middleware**: `resolveInitialWorkspaceFor()` exists but is not systematically called as middleware before every `/admin/*` request. +3. **No race-condition handling with user feedback**: stale `last_workspace_id` or revoked membership causes silent fallback, no warning notification. +4. **Minimal metadata**: chooser cards show name + "Last used" badge only — no role, no tenant count. +5. **No audit events for workspace selection/switch** (only membership changes are audited). + +--- + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Auto-Resume: Single Workspace (Priority: P1) + +A user with exactly one workspace membership logs in and is taken directly to their workspace dashboard without seeing the chooser screen. + +**Why this priority**: Eliminates an unnecessary click for the majority of users (single-workspace scenario is the most common). + +**Independent Test**: Create a user with one workspace membership, hit `/admin`, verify redirect to workspace dashboard without chooser. + +**Acceptance Scenarios**: + +1. **Given** a user with exactly 1 workspace membership and no session, **When** they visit `/admin`, **Then** they are redirected directly to the workspace dashboard and `session.current_workspace_id` is set. +2. **Given** a user with exactly 1 workspace membership, **When** auto-resume fires, **Then** an audit event `workspace.auto_selected` with reason `single_membership` is emitted. + +--- + +### User Story 2 — Auto-Resume: Last Used Workspace (Priority: P1) + +A user with multiple workspaces who has a valid `last_workspace_id` is taken directly to that workspace without the chooser. + +**Why this priority**: Reduces friction for multi-workspace users (MSP/consulting scenario) on repeat visits. + +**Independent Test**: Create a user with 2+ workspaces and a valid `last_workspace_id`, hit `/admin`, verify direct entry. + +**Acceptance Scenarios**: + +1. **Given** a user with 3 workspace memberships and `last_workspace_id` pointing to a valid membership, **When** they visit `/admin`, **Then** they land on that workspace's dashboard. +2. **Given** the auto-resume via last-used fires, **Then** an audit event `workspace.auto_selected` with reason `last_used` is emitted. + +--- + +### User Story 3 — Chooser Fallback: Multiple Workspaces, No Default (Priority: P1) + +A user with multiple workspaces and no valid `last_workspace_id` sees the chooser with enterprise metadata. + +**Why this priority**: Core path — the chooser must show meaningful data to support quick selection. + +**Independent Test**: Create a user with 3 workspaces (varying roles, tenant counts), clear `last_workspace_id`, visit `/admin`, verify chooser renders with metadata. + +**Acceptance Scenarios**: + +1. **Given** a user with 3 workspace memberships and no `last_workspace_id`, **When** they visit `/admin`, **Then** the chooser is displayed. +2. **Given** the chooser renders, **Then** each workspace row shows: Name, Role badge, Tenants count, and an "Open" action. +3. **Given** the chooser renders, **Then** "Create workspace" is not prominently shown. A "Manage workspaces" link appears only if the user has `workspace.manage` capability. + +--- + +### User Story 4 — Stale Session / Revoked Membership (Priority: P2) + +A user whose workspace membership was revoked between sessions sees a clear warning and is redirected to the chooser. + +**Why this priority**: Race condition handling is essential for multi-tenant governance and prevents silent errors. + +**Independent Test**: Set session to a workspace, delete the membership, visit `/admin`, verify warning + chooser. + +**Acceptance Scenarios**: + +1. **Given** a user with `session.current_workspace_id` pointing to a workspace where membership was revoked, **When** they visit `/admin`, **Then** the session is cleared and they are redirected to the chooser with a warning notification: "Your access to {workspace_name} was removed." +2. **Given** a user with `last_workspace_id` pointing to a revoked membership and no session, **When** they visit `/admin`, **Then** `last_workspace_id` is cleared, and the chooser is shown with a warning. + +--- + +### User Story 5 — Manual Workspace Switch (Priority: P2) + +A user can switch workspaces from within the app via the user menu, which takes them to the chooser. + +**Why this priority**: Users managing multiple tenants need an explicit switch path. This is the foundation for the in-app switcher. + +**Independent Test**: As a logged-in user with active workspace, click "Switch workspace" in user menu, verify chooser loads with `?choose=1`. + +**Acceptance Scenarios**: + +1. **Given** a user with an active workspace, **When** they click "Switch workspace" in the user menu, **Then** they are taken to `/admin/choose-workspace?choose=1`. +2. **Given** a user selects a different workspace from the chooser, **Then** `session.current_workspace_id` is updated, `users.last_workspace_id` is updated, and an audit event `workspace.selected` with reason `chooser` is emitted. +3. **Given** a user visits `/admin/choose-workspace?choose=1`, **Then** the chooser is shown regardless of auto-resume eligibility. + +--- + +### User Story 6 — Audit Trail for Workspace Context Changes (Priority: P2) + +Every workspace selection (auto or manual) produces an audit log entry for compliance. + +**Why this priority**: MSP/compliance requirement — workspace context changes must be traceable. + +**Independent Test**: Trigger auto-resume and manual selection, verify audit log entries with correct payloads. + +**Acceptance Scenarios**: + +1. **Given** any workspace selection occurs, **Then** an audit log entry is created with: `actor_id`, `workspace_id`, `method` (auto|manual), `reason`, and optional `prev_workspace_id`. +2. **Given** an auto-resume via single membership, **Then** the audit event reason is `single_membership`. +3. **Given** a manual selection from the chooser, **Then** the audit event reason is `chooser`. + +--- + +### Edge Cases + +- What happens when a user has **zero workspace memberships**? → Empty state: "You don't have access to any workspace yet." with optional "Manage workspaces" link (permission-gated). +- What happens when the workspace referenced by `last_workspace_id` is **archived**? → Treated as invalid, cleared, chooser shown. +- What happens when `?choose=1` is used by a user with **only 1 workspace**? → Chooser is shown anyway (forced mode). +- What happens when `session.current_workspace_id` is set but the workspace was **archived** between requests? → Session cleared, warning shown, chooser displayed. +- What happens when a user has **multiple browser tabs** open and switches workspace in one tab? → Session is the single source of truth. Other tabs reflect the new workspace on their next server request. No per-tab isolation in v1. + +--- + +## Requirements *(mandatory)* + +**Constitution alignment (RBAC-UX):** + +- **Authorization plane**: workspace `/admin` scope. +- **Membership = switch-right**: no separate `workspace.switch` capability in v1. Any workspace member may select/switch to that workspace. +- **`workspace.manage`** gates: visibility of "Manage workspaces" link in chooser; access to Workspace CRUD (existing separate resource). +- **404 vs 403**: non-member attempting to select a workspace they're not in → 404 (deny-as-not-found); results in no selection change. +- **Server-side enforcement**: `EnsureWorkspaceSelected` middleware validates membership on every request; chooser only lists workspaces with valid membership. + +**Constitution alignment (audit):** + +- Workspace selection events (`workspace.auto_selected`, `workspace.selected`) are logged via `WorkspaceAuditLogger` using new `AuditActionId` enum values. +- No `OperationRun` needed — these are session-context changes, not long-running operations. + +**Constitution alignment (UX-001):** + +- The chooser page is a **context selector**, not a CRUD screen. UX-001 layout rules (Main/Aside, Sections) do not directly apply. Exemption: this is a custom selection page, not a Create/Edit/View resource page. +- Empty state follows UX-001: specific title + explanation + 1 CTA (permission-gated). + +### Functional Requirements + +- **FR-001**: System MUST auto-resume to an active workspace without showing the chooser when: (a) session is valid, (b) user has exactly 1 membership, or (c) `last_workspace_id` points to a valid membership. +- **FR-002**: System MUST show the chooser only when auto-resume cannot determine a valid workspace. +- **FR-003**: Chooser MUST display each workspace with: Name, Role badge, Tenants count (`withCount`), and a primary "Open" action. +- **FR-004**: System MUST clear stale session/preference values and show a warning notification when membership has been revoked for the referenced workspace. +- **FR-005**: System MUST emit an audit event for every workspace selection (auto or manual) with payload: `actor_id`, `workspace_id`, `method`, `reason`, optional `prev_workspace_id`. +- **FR-006**: Chooser MUST NOT prominently display "Create workspace". A "Manage workspaces" link MAY appear, gated by `workspace.manage` capability. +- **FR-007**: Route parameter `?choose=1` MUST force the chooser to display regardless of auto-resume eligibility. +- **FR-008**: User menu MUST contain a "Switch workspace" entry that links to `/admin/choose-workspace?choose=1`. Entry is visible only when the user has >1 workspace membership. +- **FR-009**: After workspace selection (auto or manual), the system MUST apply existing tenant-count branching: 0 tenants → Managed Tenants index, 1 tenant → Tenant Dashboard directly, >1 tenants → Choose Tenant page. No "smart redirect" to the last-visited page in v1. +- **FR-010**: The `EnsureWorkspaceSelected` middleware MUST run on all `/admin/*` routes except the chooser page itself, login/logout routes, and OAuth callbacks. +- **FR-011**: Chooser queries MUST NOT produce N+1 problems (eager load memberships + `withCount('tenants')`). + +--- + +## UI Action Matrix *(mandatory when Filament is changed)* + +| Surface | Location | Header Actions | Inspect Affordance | Row Actions | Bulk Actions | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | +|---|---|---|---|---|---|---|---|---|---|---| +| ChooseWorkspace (Custom Page) | `app/Filament/Pages/ChooseWorkspace.php` | None (v1) | N/A — cards/rows | "Open" (primary) per workspace | N/A | "You don't have access to any workspace yet." + "Manage workspaces" (gated by `workspace.manage`) | N/A | N/A | Yes — `workspace.selected` / `workspace.auto_selected` | Context selector page, not CRUD. "Create workspace" removed from header actions; accessible only via "Manage workspaces" link. | + +--- + +### Key Entities + +- **Workspace**: The organizational context (portfolio/MSP account) that groups Microsoft Tenants. Key attributes: name, slug, archived_at. +- **WorkspaceMembership**: Pivot linking User to Workspace with a role. Determines selection eligibility. +- **User**: The authenticated actor. Stores `last_workspace_id` as a convenience preference for auto-resume. +- **AuditLog**: Existing audit infrastructure. New action IDs for workspace selection events. + +--- + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Users with a single workspace reach their dashboard in **zero extra clicks** after login (no chooser screen). +- **SC-002**: Users with a valid last-used workspace reach their dashboard in **zero extra clicks** after login. +- **SC-003**: 100% of workspace selection events (auto and manual) produce an audit log entry within the same request. +- **SC-004**: Chooser page loads in **under 200ms database time** with up to 50 workspace memberships (no N+1 queries). +- **SC-005**: Users whose membership was revoked see a **clear warning message** within 1 page load, never a broken/empty state. +- **SC-006**: "Create workspace" is **never visible** on the chooser page as a primary action; only "Manage workspaces" appears for authorized users. + +--- + +## Terminology & Copy + +- **"Workspace"** remains the product term (matches architecture: Workspace → contains Tenants). +- Chooser page title: **"Select workspace"** +- Chooser description: **"A workspace groups one or more Microsoft tenants (customer environments)."** +- Warning banner (revoked access): **"Your access to {workspace_name} was removed."** +- User menu entry: **"Switch workspace"** +- Button label: **"Open"** (not "Continue") + +--- + +## Middleware: EnsureWorkspaceSelected (v1 Algorithm) + +The middleware runs on all `/admin/*` routes (except chooser, login/logout, OAuth callbacks). + +**Algorithm (strict order):** + +1. If request path is `/admin/choose-workspace` → **allow** (prevent redirect loop). +2. If query has `choose=1` → **redirect to chooser**. +3. If `session.current_workspace_id` is set: + - If membership valid + workspace not archived → **allow**. + - Else: clear session, set flash warning ("Your access to {name} was removed."), redirect to chooser. +4. Load user's workspace memberships (selectable only: not archived). +5. If exactly 1 → set active, emit audit (`auto_selected`, reason: `single_membership`), redirect via tenant-count branching (0→managed tenants, 1→tenant dashboard, >1→choose tenant). +6. If `users.last_workspace_id` set: + - If membership valid + workspace selectable → set active, emit audit (`auto_selected`, reason: `last_used`), redirect via tenant-count branching. + - Else: clear `last_workspace_id`, set flash warning, redirect to chooser. +7. Else → redirect to chooser. + +--- + +## Data Model (v1) + +### Existing (no changes needed) + +- `workspaces` table (name, slug, archived_at) +- `workspace_memberships` pivot (workspace_id, user_id, role) +- `users.last_workspace_id` (nullable FK) — already exists, used by `WorkspaceContext::setCurrentWorkspace()` +- `session.current_workspace_id` — `WorkspaceContext::SESSION_KEY` + +### New + +- **`AuditActionId` enum values**: `WorkspaceAutoSelected`, `WorkspaceSelected` — to be added to existing enum. +- No new tables or columns required. + +--- + +## Audit Events (v1) + +| Event | AuditActionId | Method | Reason | Payload | +|---|---|---|---|---| +| Auto-resume: single membership | `workspace.auto_selected` | `auto` | `single_membership` | `actor_id`, `workspace_id`, `prev_workspace_id` (if any) | +| Auto-resume: last used | `workspace.auto_selected` | `auto` | `last_used` | `actor_id`, `workspace_id`, `prev_workspace_id` (if any) | +| Manual selection from chooser | `workspace.selected` | `manual` | `chooser` | `actor_id`, `workspace_id`, `prev_workspace_id` (if any) | +| Context-bar switch (dropdown) | `workspace.selected` | `manual` | `context_bar` | `actor_id`, `workspace_id`, `prev_workspace_id` (if any) | + +--- + +## Test Plan (Feature/Integration) + +- `it_skips_chooser_when_single_workspace_membership` +- `it_auto_resumes_to_last_used_workspace_when_membership_valid` +- `it_redirects_to_chooser_when_last_workspace_membership_revoked_and_shows_warning` +- `it_clears_session_when_active_workspace_membership_revoked` +- `it_only_lists_workspaces_user_is_member_of` +- `it_shows_name_role_and_tenants_count_per_workspace` +- `it_persists_last_used_workspace_on_manual_selection` +- `it_emits_audit_event_on_auto_selection_single_membership` +- `it_emits_audit_event_on_auto_selection_last_used` +- `it_emits_audit_event_on_manual_selection` +- `it_hides_manage_link_without_workspace_manage_capability` +- `it_shows_manage_link_with_workspace_manage_capability` +- `it_forces_chooser_with_choose_param` +- `it_shows_empty_state_when_no_memberships` +- `it_hides_switch_workspace_menu_when_single_workspace` +- `it_shows_switch_workspace_menu_when_multiple_workspaces` +- `it_has_no_n_plus_1_queries_in_chooser` (query count assertion) + +--- + +## Clarifications + +### Session 2026-02-22 + +- Q: Should v1 redirect always to a fixed dashboard, or preserve existing tenant-count branching (0→managed tenants, 1→tenant dashboard, >1→choose tenant)? → A: Preserve existing tenant-count branching — avoids UX regression for current users. +- Q: Should the middleware treat the session as single source of truth for all tabs, or add per-tab workspace isolation? → A: Session is single source of truth — all tabs share the workspace context. Switching in one tab is reflected in all others on next request. Matches existing `WorkspaceContext` design. + +--- + +## v2 Backlog (Explicitly Deferred) + +- Search/Sort/Favorites/Pins in chooser +- Environment Badges (Prod/Test/Staging) — requires data source +- Last Activity per workspace (max OperationRun timestamp) +- Smart Redirect after switch (return to last page if authorized in new workspace) +- Stateless API workspace scoping (header/token-based) +- Dropdown switcher in header (v1 = link to chooser page) +- `user_preferences` JSONB table (only if more preferences accumulate; v1 stays on `users.last_workspace_id`) \ No newline at end of file diff --git a/specs/107-workspace-chooser/tasks.md b/specs/107-workspace-chooser/tasks.md new file mode 100644 index 0000000..920aeea --- /dev/null +++ b/specs/107-workspace-chooser/tasks.md @@ -0,0 +1,287 @@ +# Tasks: Workspace Chooser v1 (Enterprise) + In-App Switch Entry Point + +**Input**: Design documents from `/specs/107-workspace-chooser/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/routes.md, quickstart.md + +**Tests**: REQUIRED (Pest) — all changes involve runtime behavior. 17 test cases from spec + additional integration tests. +**Operations**: No `OperationRun` needed — workspace selection is synchronous, DB-only, < 2s. Audit entries via `WorkspaceAuditLogger`. +**RBAC**: +- Authorization plane: admin `/admin` +- Membership = switch-right (any workspace member may select/switch) +- Non-member selection attempt → 404 (deny-as-not-found) via `WorkspaceContext::isMember()` +- `Capabilities::WORKSPACE_MANAGE` gates "Manage workspaces" link visibility (canonical registry constant) +- Positive test: member selects workspace → success +- Negative test: non-member attempt → 404 +**Filament UI Action Surfaces**: ChooseWorkspace is a custom context-selector page (not CRUD Resource). UI Action Matrix in spec — no header actions (v1), "Open" per workspace, empty state with specific title + CTA. Exemption from UX-001 documented. +**Badges**: Workspace membership role badge is a tag/category (owner/admin/member), exempt from BADGE-001 per R7 decision. Simple color-mapped ``, no `BadgeCatalog`. + +**Organization**: Tasks grouped by user story. Stories map to quickstart phases: +- Foundation → Phase A (enum + redirect resolver) +- US1+US2 → Phase B (middleware refactor, incremental) +- US3 → Phase C (chooser page upgrade) +- US4 → Phase B enhancement (stale detection) +- US5 → Phase C+D (chooser audit + user menu) +- US6 → Verification (audit payloads) + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (US1–US6) +- Exact file paths included in all descriptions + +--- + +## Phase 1: Setup + +**Purpose**: No project initialization needed — existing Laravel monolith with Filament v5. + +_(No tasks — project structure, dependencies, and all target directories already exist.)_ + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete. + +- [X] T001 [P] Add `WorkspaceAutoSelected` and `WorkspaceSelected` enum cases to `app/Support/Audit/AuditActionId.php` +- [X] T002 [P] Create `WorkspaceRedirectResolver` service with tenant-count branching logic (0→managed tenants, 1→tenant dashboard, >1→choose tenant) in `app/Support/Workspaces/WorkspaceRedirectResolver.php` +- [X] T003 Write tests for `WorkspaceRedirectResolver` covering 0/1/>1 tenant branching in `tests/Feature/Workspaces/WorkspaceRedirectResolverTest.php` + +**Checkpoint**: Foundation ready — AuditActionId enum extended, tenant-count branching deduplicated into resolver. User story implementation can now begin. + +--- + +## Phase 3: User Story 1 — Auto-Resume: Single Workspace (Priority: P1) 🎯 MVP + +**Goal**: A user with exactly one workspace membership is taken directly to their workspace dashboard without seeing the chooser. + +**Independent Test**: Create a user with one workspace membership, hit `/admin`, verify redirect to workspace dashboard without chooser. + +### Implementation for User Story 1 + +- [X] T004 [US1] Refactor `EnsureWorkspaceSelected` middleware: implement step 1 (workspace-optional bypass), step 2 (`?choose=1` redirect), step 3 (basic session validation — allow if valid membership), step 4 (load selectable memberships), step 5 (single membership auto-resume with audit via `WorkspaceAuditLogger`), step 7 (fallback redirect to chooser) in `app/Http/Middleware/EnsureWorkspaceSelected.php` +- [X] T005 [US1] Write test `it_skips_chooser_when_single_workspace_membership` — verify direct redirect to workspace dashboard in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` +- [X] T006 [US1] Write test `it_emits_audit_event_on_auto_selection_single_membership` — verify `workspace.auto_selected` audit log with reason `single_membership` in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` +- [X] T007 [US1] Write test `it_redirects_via_tenant_count_branching_after_single_auto_resume` — verify 0/1/>1 tenant routing after auto-resume in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` +- [X] T008 [US1] Write test `it_allows_request_when_session_workspace_is_valid` — verify middleware passes through when session has valid membership in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` + +**Checkpoint**: Single-workspace users bypass the chooser entirely. Audit event emitted. Middleware skeleton (7 steps) in place with steps 1–5, 7 active. + +--- + +## Phase 4: User Story 2 — Auto-Resume: Last Used Workspace (Priority: P1) + +**Goal**: A user with multiple workspaces who has a valid `last_workspace_id` is taken directly to that workspace without the chooser. + +**Independent Test**: Create a user with 2+ workspaces and a valid `last_workspace_id`, hit `/admin`, verify direct entry. + +### Implementation for User Story 2 + +- [X] T009 [US2] Add step 6 to `EnsureWorkspaceSelected` middleware: `last_workspace_id` auto-resume with membership validation and audit logging in `app/Http/Middleware/EnsureWorkspaceSelected.php` +- [X] T010 [US2] Write test `it_auto_resumes_to_last_used_workspace_when_membership_valid` — verify direct redirect via last_workspace_id in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` +- [X] T011 [US2] Write test `it_emits_audit_event_on_auto_selection_last_used` — verify `workspace.auto_selected` audit log with reason `last_used` in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` +- [X] T012 [US2] Write test `it_falls_back_to_chooser_when_multiple_workspaces_and_no_last_used` — verify redirect to chooser when no default in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` + +**Checkpoint**: Multi-workspace users with a valid last-used preference bypass the chooser. Both P1 auto-resume paths (single + last-used) are functional. + +--- + +## Phase 5: User Story 3 — Chooser Fallback: Multiple Workspaces, No Default (Priority: P1) + +**Goal**: A user with multiple workspaces and no valid `last_workspace_id` sees the chooser with enterprise metadata (name, role badge, tenant count). + +**Independent Test**: Create a user with 3 workspaces (varying roles, tenant counts), clear `last_workspace_id`, visit `/admin`, verify chooser renders with metadata. + +### Implementation for User Story 3 + +- [X] T013 [US3] Refactor `ChooseWorkspace::getWorkspaces()` to add `withCount('tenants')` and load membership roles keyed by workspace_id; expose `$this->workspaceRoles` for Blade in `app/Filament/Pages/ChooseWorkspace.php` +- [X] T014 [US3] Remove "Create workspace" header action from `ChooseWorkspace` page (FR-006) in `app/Filament/Pages/ChooseWorkspace.php` +- [X] T015 [US3] Update Blade template: add role badge (`` with color mapping for owner/admin/member), tenant count display, "Manage workspaces" link (gated by `Capabilities::WORKSPACE_MANAGE`), updated empty state copy per spec terminology in `resources/views/filament/pages/choose-workspace.blade.php` +- [X] T016 [US3] Write test `it_only_lists_workspaces_user_is_member_of` — create workspaces user is and isn't a member of, verify only member workspaces shown in `tests/Feature/Workspaces/ChooseWorkspacePageTest.php` +- [X] T017 [US3] Write test `it_shows_name_role_and_tenants_count_per_workspace` — verify metadata rendered in chooser cards in `tests/Feature/Workspaces/ChooseWorkspacePageTest.php` +- [X] T018 [US3] Write test `it_shows_empty_state_when_no_memberships` — verify "You don't have access to any workspace yet." message in `tests/Feature/Workspaces/ChooseWorkspacePageTest.php` +- [X] T019 [US3] Write test `it_hides_manage_link_without_workspace_manage_capability` and `it_shows_manage_link_with_workspace_manage_capability` — positive + negative authorization in `tests/Feature/Workspaces/ChooseWorkspacePageTest.php` +- [X] T020 [US3] Write test `it_has_no_n_plus_1_queries_in_chooser` — assert query count with 5+ workspaces in `tests/Feature/Workspaces/ChooseWorkspacePageTest.php` + +**Checkpoint**: Chooser page displays enterprise metadata. All three P1 stories are functional — auto-resume (single + last-used) and chooser fallback with metadata. + +--- + +## Phase 6: User Story 4 — Stale Session / Revoked Membership (Priority: P2) + +**Goal**: A user whose workspace membership was revoked between sessions sees a clear warning and is redirected to the chooser. + +**Independent Test**: Set session to a workspace, delete the membership, visit `/admin`, verify warning + chooser. + +### Implementation for User Story 4 + +- [X] T021 [US4] Enhance middleware step 3: detect stale session (revoked membership or archived workspace), clear session, emit Filament `Notification::make()->danger()` with "Your access to {workspace_name} was removed." flash, redirect to chooser in `app/Http/Middleware/EnsureWorkspaceSelected.php` +- [X] T022 [US4] Enhance middleware step 6 error path: detect stale `last_workspace_id` (revoked or archived), clear `last_workspace_id` on user record, emit flash warning, redirect to chooser in `app/Http/Middleware/EnsureWorkspaceSelected.php` +- [X] T023 [US4] Write test `it_clears_session_when_active_workspace_membership_revoked` — verify session cleared + warning notification + chooser redirect in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` +- [X] T024 [US4] Write test `it_redirects_to_chooser_when_last_workspace_membership_revoked_and_shows_warning` — verify `last_workspace_id` cleared + warning + chooser, including archived workspace scenario (edge case EC2) in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` +- [X] T025 [US4] Write test `it_handles_archived_workspace_in_session` — verify archived workspace treated as stale in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` + +**Checkpoint**: Stale/revoked membership detection is active. Users see clear warning notifications instead of broken states. + +--- + +## Phase 7: User Story 5 — Manual Workspace Switch (Priority: P2) + +**Goal**: A user can switch workspaces from within the app via the user menu, which takes them to the chooser. + +**Independent Test**: As a logged-in user with active workspace, click "Switch workspace" in user menu, verify chooser loads with `?choose=1`. + +### Implementation for User Story 5 + +- [X] T026 [US5] Add audit logging in `ChooseWorkspace::selectWorkspace()` — emit `workspace.selected` via `WorkspaceAuditLogger` with metadata `{method: "manual", reason: "chooser", prev_workspace_id}` in `app/Filament/Pages/ChooseWorkspace.php` +- [X] T027 [US5] Replace form POST with `wire:click="selectWorkspace({{ $workspace->id }})"` in chooser Blade template in `resources/views/filament/pages/choose-workspace.blade.php` +- [X] T028 [US5] Use `WorkspaceRedirectResolver` in `ChooseWorkspace::redirectAfterWorkspaceSelected()` for tenant-count branching in `app/Filament/Pages/ChooseWorkspace.php` +- [X] T029 [US5] Register "Switch workspace" user menu item via `->userMenuItems()` with `MenuItem::make()->url('/admin/choose-workspace?choose=1')->icon('heroicon-o-arrows-right-left')` and `->visible()` callback (>1 workspace membership) in `app/Providers/Filament/AdminPanelProvider.php` +- [X] T030 [US5] Write test `it_forces_chooser_with_choose_param` — verify `?choose=1` bypasses auto-resume, including the single-workspace sub-case (edge case EC3: forced chooser shown even with 1 membership) in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php` +- [X] T031 [US5] Write test `it_persists_last_used_workspace_on_manual_selection` and `it_emits_audit_event_on_manual_selection` — verify `last_workspace_id` update + `workspace.selected` audit log in `tests/Feature/Workspaces/ChooseWorkspacePageTest.php` +- [X] T032 [US5] Write test `it_shows_switch_workspace_menu_when_multiple_workspaces` and `it_hides_switch_workspace_menu_when_single_workspace` in `tests/Feature/Workspaces/WorkspaceSwitchUserMenuTest.php` +- [X] T033 [US5] Write test `it_rejects_non_member_workspace_selection_with_404` — verify deny-as-not-found for non-member attempt in `tests/Feature/Workspaces/ChooseWorkspacePageTest.php` + +**Checkpoint**: Manual switch flow complete — user menu entry, Livewire selection, audit logging, and 404 for non-members. + +--- + +## Phase 8: User Story 6 — Audit Trail for Workspace Context Changes (Priority: P2) + +**Goal**: Every workspace selection (auto or manual) produces an audit log entry with correct payloads for compliance. + +**Independent Test**: Trigger auto-resume and manual selection, verify audit log entries with correct payloads. + +### Implementation for User Story 6 + +- [X] T034 [US6] Write comprehensive audit payload verification test covering all four audit scenarios (auto/single_membership, auto/last_used, manual/chooser, manual/context_bar) with full metadata assertion (`method`, `reason`, `prev_workspace_id`, `resource_type`, `resource_id`) in `tests/Feature/Workspaces/WorkspaceAuditTrailTest.php` +- [X] T035 [US6] Write test `it_includes_prev_workspace_id_when_switching_from_active_workspace` — verify previous workspace context is captured in audit metadata in `tests/Feature/Workspaces/WorkspaceAuditTrailTest.php` + +**Checkpoint**: All six user stories are implemented and tested. Audit trail is verified for compliance. + +--- + +## Phase 9: Polish & Cross-Cutting Concerns + +**Purpose**: Deduplicate remaining tenant-branching copies, full suite validation, formatting. + +- [X] T036 [US6] Replace inline tenant-count branching in `SwitchWorkspaceController::__invoke()` with `WorkspaceRedirectResolver` AND add `WorkspaceAuditLogger::log()` for `workspace.selected` (method: `manual`, reason: `context_bar`) to satisfy FR-005 audit coverage for the context-bar switch path, in `app/Http/Controllers/SwitchWorkspaceController.php` +- [X] T037 Replace inline tenant-count branching in `/admin` route handler with `WorkspaceRedirectResolver` in `routes/web.php` +- [X] T038 Run full test suite via `vendor/bin/sail artisan test --compact` and verify no regressions +- [X] T039 Run Pint formatting via `vendor/bin/sail bin pint --dirty --format agent` +- [X] T040 Final commit and push to branch `107-workspace-chooser` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: N/A — project already initialized +- **Foundational (Phase 2)**: No dependencies — can start immediately. BLOCKS all user stories +- **US1 (Phase 3)**: Depends on Phase 2 (AuditActionId enum + WorkspaceRedirectResolver) +- **US2 (Phase 4)**: Depends on Phase 3 (middleware skeleton from US1) +- **US3 (Phase 5)**: Depends on Phase 2 only — can run in parallel with US1/US2 +- **US4 (Phase 6)**: Depends on Phase 3 (middleware skeleton from US1) +- **US5 (Phase 7)**: Depends on Phase 5 (chooser page from US3) + Phase 3 (middleware `?choose=1`) +- **US6 (Phase 8)**: Depends on Phases 3–7 (all audit-emitting code must exist) +- **Polish (Phase 9)**: Depends on all user stories being complete + +### User Story Dependencies + +- **US1 (P1)**: After Foundational → provides middleware skeleton for US2 + US4 +- **US2 (P1)**: After US1 → extends middleware with step 6 +- **US3 (P1)**: After Foundational → independent from US1/US2 (different files) +- **US4 (P2)**: After US1 → enhances middleware error paths +- **US5 (P2)**: After US3 (chooser page) + US1 (middleware ?choose=1) +- **US6 (P2)**: After US1–US5 → verifies audit payloads across all paths + +### Within Each User Story + +- Implementation before tests (refactoring existing code — not greenfield TDD) +- Core changes before edge-case handling +- Story complete before moving to next priority + +### Parallel Opportunities + +- **Phase 2**: T001 and T002 can run in parallel (different files) +- **After Phase 2**: US3 (chooser page) can start in parallel with US1 (middleware) +- **After Phase 3**: US2 and US4 can start in parallel (US4 enhances middleware error paths, US2 adds step 6) +- **Tests within same file**: Sequential (same file), but different test files can run in parallel + +--- + +## Parallel Example: After Foundational + +``` +# Developer A: Middleware track (US1 → US2 → US4) +T004 Refactor EnsureWorkspaceSelected (US1) +T009 Add step 6 to middleware (US2) +T021–T022 Enhance error paths (US4) + +# Developer B: Chooser page track (US3 → US5) +T013–T015 Upgrade ChooseWorkspace page + Blade (US3) +T026–T029 Add audit + wire:click + user menu (US5) + +# Both tracks converge at: +T034–T035 Audit trail verification (US6) +T036–T040 Polish & cross-cutting +``` + +--- + +## Implementation Strategy + +### MVP First (US1 Only) + +1. Complete Phase 2: Foundational (T001–T003) +2. Complete Phase 3: US1 — Single workspace auto-resume (T004–T008) +3. **STOP and VALIDATE**: Single-workspace users bypass chooser, audit logged +4. Deploy/demo if ready → immediate UX improvement for majority of users + +### Incremental Delivery + +1. Foundation (T001–T003) → Core infrastructure ready +2. US1 (T004–T008) → Single workspace auto-resume → **MVP!** +3. US2 (T009–T012) → Last-used auto-resume → Multi-workspace friction reduced +4. US3 (T013–T020) → Enterprise metadata in chooser → Better selection UX +5. US4 (T021–T025) → Stale session handling → Governance safety net +6. US5 (T026–T033) → Manual switch via user menu → Full switch flow +7. US6 (T034–T035) → Audit verification → Compliance confidence +8. Polish (T036–T040) → DRY codebase, full suite green + +Each story adds value without breaking previous stories. + +--- + +## Summary + +| Metric | Value | +|--------|-------| +| **Total tasks** | 40 | +| **Phase 2 (Foundational)** | 3 tasks | +| **US1 (Auto-resume single)** | 5 tasks | +| **US2 (Auto-resume last-used)** | 4 tasks | +| **US3 (Chooser metadata)** | 8 tasks | +| **US4 (Stale session)** | 5 tasks | +| **US5 (Manual switch)** | 8 tasks | +| **US6 (Audit verification)** | 2 tasks | +| **Polish** | 5 tasks | +| **Parallel opportunities** | 2 independent tracks (middleware + chooser page) after foundation | +| **MVP scope** | Foundation + US1 (8 tasks) | +| **New files** | 6 (1 service + 5 test files) | +| **Modified files** | 6 (middleware, page, blade, enum, provider, controller + routes) | + +--- + +## Notes + +- All tasks reference exact file paths from plan.md project structure +- Audit logging uses direct `WorkspaceAuditLogger::log()` calls (decision R2) +- Middleware is refactored in-place (decision R1) — alias `ensure-workspace-selected` unchanged +- Chooser migrates to `wire:click` (decision R9) — `SwitchWorkspaceController` retained for context-bar +- Flash warnings use Filament `Notification::make()->danger()` (decision R10) +- Role badge uses simple color mapping, exempt from BadgeCatalog (decision R7) +- `WorkspaceRedirectResolver` deduplicates 4 copies of tenant-count branching (decision R4) diff --git a/tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php b/tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php index 6e20af6..ec65bd9 100644 --- a/tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php +++ b/tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php @@ -13,7 +13,7 @@ uses(RefreshDatabase::class); -it('redirects /admin to onboarding when a workspace is selected and has no tenants', function (): void { +it('redirects /admin to managed tenants index when a workspace is selected and has no tenants', function (): void { $user = User::factory()->create(); $workspace = Workspace::factory()->create(); @@ -24,11 +24,14 @@ 'role' => 'owner', ]); - $this + $response = $this ->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) - ->get('/admin') - ->assertRedirect('/admin/onboarding'); + ->get('/admin'); + + $response->assertRedirect(); + $location = $response->headers->get('Location'); + expect($location)->toContain('managed-tenants'); }); it('redirects /admin to choose-tenant when a workspace is selected and has multiple tenants', function (): void { diff --git a/tests/Feature/Filament/ChooseTenantEmptyStateRegisterTenantCtaVisibilityTest.php b/tests/Feature/Filament/ChooseTenantEmptyStateRegisterTenantCtaVisibilityTest.php index e259f06..fed24c8 100644 --- a/tests/Feature/Filament/ChooseTenantEmptyStateRegisterTenantCtaVisibilityTest.php +++ b/tests/Feature/Filament/ChooseTenantEmptyStateRegisterTenantCtaVisibilityTest.php @@ -25,7 +25,7 @@ ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) ->get('/admin/choose-tenant') ->assertSuccessful() - ->assertSee('No tenants are available') + ->assertSee('No tenants available') ->assertDontSee('Register tenant'); }); @@ -44,7 +44,7 @@ ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) ->get('/admin/choose-tenant') ->assertSuccessful() - ->assertSee('No tenants are available') - ->assertSee('Change workspace') + ->assertSee('No tenants available') + ->assertSee('Switch workspace') ->assertDontSee('Register tenant'); }); diff --git a/tests/Feature/Filament/LoginRedirectsToChooseWorkspaceWhenMultipleWorkspacesTest.php b/tests/Feature/Filament/LoginRedirectsToChooseWorkspaceWhenMultipleWorkspacesTest.php index 4bd094d..b8b1fd6 100644 --- a/tests/Feature/Filament/LoginRedirectsToChooseWorkspaceWhenMultipleWorkspacesTest.php +++ b/tests/Feature/Filament/LoginRedirectsToChooseWorkspaceWhenMultipleWorkspacesTest.php @@ -14,7 +14,7 @@ Http::preventStrayRequests(); }); -it('redirects to choose-workspace after login when user has multiple workspaces and no workspace is selected', function (): void { +it('auto-resumes to last used workspace when user has multiple workspaces and last_workspace_id is set', function (): void { $user = User::factory()->create(); $workspaceA = Workspace::factory()->create(['name' => 'Workspace A']); @@ -34,6 +34,35 @@ $user->forceFill(['last_workspace_id' => (int) $workspaceA->getKey()])->save(); + $response = $this->actingAs($user) + ->get('/admin'); + + // Middleware step 6: auto-resumes to last used workspace and redirects + // via tenant branching (workspaceA has 0 tenants → managed-tenants). + $response->assertRedirect(); + $location = $response->headers->get('Location'); + expect($location)->toContain('managed-tenants'); +}); + +it('redirects to choose-workspace when user has multiple workspaces and no last_workspace_id', function (): void { + $user = User::factory()->create(); + + $workspaceA = Workspace::factory()->create(['name' => 'Workspace A']); + $workspaceB = Workspace::factory()->create(['name' => 'Workspace B']); + + 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' => 'owner', + ]); + + // No last_workspace_id → falls through to chooser. $this->actingAs($user) ->get('/admin') ->assertRedirect(route('filament.admin.pages.choose-workspace')); diff --git a/tests/Feature/Filament/TenantSetupTest.php b/tests/Feature/Filament/TenantSetupTest.php index fc38a21..5126d3b 100644 --- a/tests/Feature/Filament/TenantSetupTest.php +++ b/tests/Feature/Filament/TenantSetupTest.php @@ -1,6 +1,5 @@ create(); $this->actingAs($user); - $contextTenant = Tenant::create([ - 'tenant_id' => 'tenant-context', - 'name' => 'Context Tenant', + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-guid', + 'name' => 'Contoso', + 'environment' => 'other', + 'domain' => 'contoso.com', ]); - [$user, $contextTenant] = createUserWithTenant($contextTenant, $user, role: 'owner'); + [$user, $tenant] = createUserWithTenant($tenant, $user, role: 'owner', ensureDefaultMicrosoftProviderConnection: false); $this->actingAs($user); - Filament::setTenant($contextTenant, true); - - Livewire::test(CreateTenant::class) - ->fillForm([ - 'name' => 'Contoso', - 'environment' => 'other', - 'tenant_id' => 'tenant-guid', - 'domain' => 'contoso.com', - ]) - ->call('create') - ->assertHasNoFormErrors(); - - $tenant = Tenant::query()->where('tenant_id', 'tenant-guid')->first(); - expect($tenant)->not->toBeNull(); + Filament::setTenant($tenant, true); $connection = ProviderConnection::factory()->create([ 'tenant_id' => (int) $tenant->getKey(), diff --git a/tests/Feature/OpsUx/OperateHubShellTest.php b/tests/Feature/OpsUx/OperateHubShellTest.php index 806511a..e89bbd8 100644 --- a/tests/Feature/OpsUx/OperateHubShellTest.php +++ b/tests/Feature/OpsUx/OperateHubShellTest.php @@ -168,14 +168,15 @@ ->assertDontSee('Show all operations'); })->group('ops-ux'); -it('returns 404 for non-member workspace access to /admin/operations', function (): void { +it('redirects non-member workspace access to chooser on /admin/operations', function (): void { $user = User::factory()->create(); $workspace = Workspace::factory()->create(); + // User is NOT a member — middleware detects stale session and redirects. $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) ->get(route('admin.operations.index')) - ->assertNotFound(); + ->assertRedirect(); })->group('ops-ux'); it('returns 404 for non-entitled tenant dashboard direct access', function (): void { diff --git a/tests/Feature/ProviderConnections/AuthorizationSemanticsTest.php b/tests/Feature/ProviderConnections/AuthorizationSemanticsTest.php index 9e4498f..450af02 100644 --- a/tests/Feature/ProviderConnections/AuthorizationSemanticsTest.php +++ b/tests/Feature/ProviderConnections/AuthorizationSemanticsTest.php @@ -15,11 +15,12 @@ 'provider' => 'microsoft', ]); + // Non-member outsider is redirected by workspace middleware (no workspace membership). $outsider = User::factory()->create(); $this->actingAs($outsider) ->get('/admin/provider-connections/'.$connection->getKey().'/edit') - ->assertNotFound(); + ->assertRedirect(); [$readonly] = createUserWithTenant(tenant: $tenant, role: 'readonly'); diff --git a/tests/Feature/ProviderConnections/LegacyRedirectTest.php b/tests/Feature/ProviderConnections/LegacyRedirectTest.php index 7d93e42..008d9a8 100644 --- a/tests/Feature/ProviderConnections/LegacyRedirectTest.php +++ b/tests/Feature/ProviderConnections/LegacyRedirectTest.php @@ -5,6 +5,7 @@ use App\Models\ProviderConnection; use App\Models\Tenant; use App\Models\User; +use App\Support\Workspaces\WorkspaceContext; it('redirects legacy tenant-scoped provider connection routes for entitled members', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); @@ -31,14 +32,13 @@ ->assertRedirect('/admin/provider-connections/'.$connection->getKey().'/edit?tenant_id='.$tenant->external_id); }); -it('returns 404 without location header for non-workspace members on legacy routes', function (): void { +it('redirects non-workspace-members on legacy routes', function (): void { $user = User::factory()->create(); $tenant = Tenant::factory()->create(); $this->actingAs($user) ->get('/admin/tenants/'.$tenant->external_id.'/provider-connections') - ->assertNotFound() - ->assertHeaderMissing('Location'); + ->assertRedirect(); }); it('returns 404 without location header for non-tenant members on legacy routes', function (): void { @@ -50,6 +50,7 @@ [$user] = createUserWithTenant(tenant: $tenantA, role: 'owner'); $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id]) ->get('/admin/tenants/'.$tenantB->external_id.'/provider-connections') ->assertNotFound() ->assertHeaderMissing('Location'); diff --git a/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationSpec081Test.php b/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationSpec081Test.php index af6aa3e..6373a77 100644 --- a/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationSpec081Test.php +++ b/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationSpec081Test.php @@ -8,7 +8,7 @@ use App\Models\User; use App\Support\Workspaces\WorkspaceContext; -it('Spec081 returns 404 for non-members on provider connection management routes', function (): void { +it('Spec081 redirects non-members on provider connection management routes', function (): void { $tenant = Tenant::factory()->create([ 'status' => 'active', ]); @@ -25,7 +25,7 @@ WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id, ]) ->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant)) - ->assertNotFound(); + ->assertRedirect(); }); it('Spec081 returns 403 for members without provider manage capability', function (): void { diff --git a/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php b/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php index b1ababd..80bfad6 100644 --- a/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php +++ b/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php @@ -4,6 +4,8 @@ use App\Models\ProviderConnection; use App\Models\Tenant; use App\Models\User; +use App\Models\WorkspaceMembership; +use App\Support\Workspaces\WorkspaceContext; test('owners can manage provider connections in their tenant', function () { [$user, $tenant] = createUserWithTenant(role: 'owner'); @@ -98,7 +100,14 @@ $tenantB->getKey() => ['role' => 'owner'], ]); + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $tenantA->workspace_id, + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id]) ->get(ProviderConnectionResource::getUrl('edit', ['record' => $connectionB], tenant: $tenantA)) ->assertNotFound(); }); diff --git a/tests/Feature/ProviderConnections/TenantlessListRouteTest.php b/tests/Feature/ProviderConnections/TenantlessListRouteTest.php index ce4ce20..8294f08 100644 --- a/tests/Feature/ProviderConnections/TenantlessListRouteTest.php +++ b/tests/Feature/ProviderConnections/TenantlessListRouteTest.php @@ -16,10 +16,10 @@ ->toContain('/admin/provider-connections'); }); -it('returns 404 on the canonical tenantless route for non-workspace members', function (): void { +it('redirects non-workspace-members on the canonical tenantless route', function (): void { $user = User::factory()->create(); $this->actingAs($user) ->get('/admin/provider-connections') - ->assertNotFound(); + ->assertRedirect(); }); diff --git a/tests/Feature/Rbac/TenantAdminAuthorizationTest.php b/tests/Feature/Rbac/TenantAdminAuthorizationTest.php index 94bb9f1..d8b7db0 100644 --- a/tests/Feature/Rbac/TenantAdminAuthorizationTest.php +++ b/tests/Feature/Rbac/TenantAdminAuthorizationTest.php @@ -1,7 +1,7 @@ actingAs($user); - Livewire::actingAs($user) - ->test(CreateTenant::class) - ->assertStatus(403); + expect(TenantResource::canCreate())->toBeFalse(); }); diff --git a/tests/Feature/Rbac/TenantResourceAuthorizationTest.php b/tests/Feature/Rbac/TenantResourceAuthorizationTest.php index 2a46054..023ffb9 100644 --- a/tests/Feature/Rbac/TenantResourceAuthorizationTest.php +++ b/tests/Feature/Rbac/TenantResourceAuthorizationTest.php @@ -15,12 +15,12 @@ expect(TenantResource::canCreate())->toBeFalse(); }); - it('can be created by managers (TENANT_MANAGE)', function () { + it('cannot be created via CRUD (onboarding wizard is the only path)', function () { [$user] = createUserWithTenant(role: 'manager'); $this->actingAs($user); - expect(TenantResource::canCreate())->toBeTrue(); + expect(TenantResource::canCreate())->toBeFalse(); }); it('can be edited by managers (TENANT_MANAGE)', function () { diff --git a/tests/Feature/RequiredPermissions/RequiredPermissionsAccessTest.php b/tests/Feature/RequiredPermissions/RequiredPermissionsAccessTest.php index c11861c..48635f2 100644 --- a/tests/Feature/RequiredPermissions/RequiredPermissionsAccessTest.php +++ b/tests/Feature/RequiredPermissions/RequiredPermissionsAccessTest.php @@ -37,7 +37,7 @@ ->assertNotFound(); }); -it('returns 404 for users who are not workspace members', function (): void { +it('redirects non-workspace-members with stale session', function (): void { $user = User::factory()->create(); $workspace = Workspace::factory()->create(); $tenant = Tenant::factory()->create([ @@ -49,7 +49,7 @@ WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(), ]) ->get("/admin/tenants/{$tenant->external_id}/required-permissions") - ->assertNotFound(); + ->assertRedirect(); }); it('returns 404 when the route tenant is invalid instead of falling back to the current tenant context', function (): void { diff --git a/tests/Feature/RequiredPermissions/RequiredPermissionsSidebarTest.php b/tests/Feature/RequiredPermissions/RequiredPermissionsSidebarTest.php index 673e9ee..f337aa2 100644 --- a/tests/Feature/RequiredPermissions/RequiredPermissionsSidebarTest.php +++ b/tests/Feature/RequiredPermissions/RequiredPermissionsSidebarTest.php @@ -63,20 +63,20 @@ $response->assertDontSee('>Governance', false); }); -it('returns 404 for non-workspace-members after middleware change (FR-002 regression guard)', function (): void { +it('redirects non-workspace-members with stale session (FR-002 regression guard)', function (): void { $user = User::factory()->create(); $workspace = Workspace::factory()->create(); $tenant = Tenant::factory()->create([ 'workspace_id' => (int) $workspace->getKey(), ]); - // User is NOT a workspace member — no WorkspaceMembership created + // User is NOT a workspace member — middleware clears stale session and redirects $this->actingAs($user) ->withSession([ WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(), ]) ->get("/admin/tenants/{$tenant->external_id}/required-permissions") - ->assertNotFound(); + ->assertRedirect(); }); it('returns 404 for workspace members without tenant entitlement after middleware change (FR-002 regression guard)', function (): void { diff --git a/tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php b/tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php index 64e2014..a2c5e69 100644 --- a/tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php +++ b/tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php @@ -32,14 +32,14 @@ ->assertOk(); }); -it('returns 404 for non-members on the workspace-managed tenants index', function (): void { +it('redirects non-members on the workspace-managed tenants index', function (): void { $tenant = Tenant::factory()->create(); $user = User::factory()->create(); $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->get('/admin/tenants') - ->assertNotFound(); + ->assertRedirect(); }); it('allows workspace members to open the workspace-managed tenant view route', function (): void { @@ -61,14 +61,14 @@ ->assertSee('/admin/provider-connections?tenant_id='.$tenant->external_id, false); }); -it('returns 404 for non-members on the workspace-managed tenant view route', function (): void { +it('redirects non-members on the workspace-managed tenant view route', function (): void { $tenant = Tenant::factory()->create(); $user = User::factory()->create(); $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->get("/admin/tenants/{$tenant->external_id}") - ->assertNotFound(); + ->assertRedirect(); }); it('exposes memberships management under workspace scope', function (): void { diff --git a/tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php b/tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php index b8e2b61..35a3f80 100644 --- a/tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php +++ b/tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php @@ -19,14 +19,12 @@ Http::preventStrayRequests(); }); -it('returns 404 for non-workspace-members on central operations index', function (): void { +it('redirects non-workspace-members on central operations index', function (): void { $user = User::factory()->create(); - session()->forget(WorkspaceContext::SESSION_KEY); - $this->actingAs($user) ->get('/admin/operations') - ->assertNotFound(); + ->assertRedirect(); }); it('returns 404 for non-workspace-members on central operation run detail', function (): void { diff --git a/tests/Feature/Workspaces/ChooseWorkspacePageTest.php b/tests/Feature/Workspaces/ChooseWorkspacePageTest.php new file mode 100644 index 0000000..4cd44a7 --- /dev/null +++ b/tests/Feature/Workspaces/ChooseWorkspacePageTest.php @@ -0,0 +1,297 @@ +create(); + + $workspaceA = Workspace::factory()->create(['name' => 'My Workspace']); + $workspaceB = Workspace::factory()->create(['name' => 'Other Workspace']); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceA->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + // User is NOT a member of workspaceB. + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceB->getKey(), + 'user_id' => User::factory()->create()->getKey(), + 'role' => 'owner', + ]); + + $user->forceFill(['last_workspace_id' => (int) $workspaceA->getKey()])->save(); + + $this->actingAs($user) + ->get(route('filament.admin.pages.choose-workspace')) + ->assertOk() + ->assertSee('My Workspace') + ->assertDontSee('Other Workspace'); +}); + +it('excludes archived workspaces from the list', function (): void { + $user = User::factory()->create(); + + $activeWorkspace = Workspace::factory()->create(['name' => 'Active WS']); + $archivedWorkspace = Workspace::factory()->create(['name' => 'Archived WS', 'archived_at' => now()]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $activeWorkspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $archivedWorkspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $user->forceFill(['last_workspace_id' => (int) $activeWorkspace->getKey()])->save(); + + $this->actingAs($user) + ->get(route('filament.admin.pages.choose-workspace')) + ->assertOk() + ->assertSee('Active WS') + ->assertDontSee('Archived WS'); +}); + +// --- T017: it_shows_name_role_and_tenants_count_per_workspace --- + +it('shows name role and tenants count per workspace', function (): void { + $user = User::factory()->create(); + + $workspace = Workspace::factory()->create(['name' => 'Test Corp']); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'manager', + ]); + + // Create 2 active tenants. + Tenant::factory()->count(2)->create([ + 'workspace_id' => $workspace->getKey(), + 'status' => 'active', + ]); + + // Create 1 inactive tenant (should not count). + Tenant::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'status' => 'pending_validation', + ]); + + $user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save(); + + $this->actingAs($user) + ->get(route('filament.admin.pages.choose-workspace')) + ->assertOk() + ->assertSee('Test Corp') + ->assertSee('Manager') + ->assertSee('2 tenants'); +}); + +it('shows singular tenant label when count is one', function (): void { + $user = User::factory()->create(); + + $workspace = Workspace::factory()->create(['name' => 'Solo Corp']); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + Tenant::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'status' => 'active', + ]); + + $user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save(); + + $this->actingAs($user) + ->get(route('filament.admin.pages.choose-workspace')) + ->assertOk() + ->assertSee('1 tenant'); +}); + +// --- T018: it_shows_empty_state_when_no_memberships --- + +it('shows empty state when no memberships', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('filament.admin.pages.choose-workspace')) + ->assertOk() + ->assertSee("You don't have access to any workspace yet.", false); +}); + +// --- T019: manage link visibility --- + +it('shows manage link for owner role', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save(); + + $this->actingAs($user) + ->get(route('filament.admin.pages.choose-workspace')) + ->assertOk() + ->assertSee('Manage workspaces'); +}); + +it('hides manage link for non-owner roles', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'operator', + ]); + + $user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save(); + + $this->actingAs($user) + ->get(route('filament.admin.pages.choose-workspace')) + ->assertOk() + ->assertDontSee('Manage workspaces'); +}); + +// --- T020: it_has_no_n_plus_1_queries_in_chooser --- + +it('has no n plus 1 queries in chooser', function (): void { + $user = User::factory()->create(); + + // Create 5 workspaces with memberships. + $workspaces = Workspace::factory()->count(5)->create(); + + foreach ($workspaces as $workspace) { + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + Tenant::factory()->count(2)->create([ + 'workspace_id' => $workspace->getKey(), + 'status' => 'active', + ]); + } + + $user->forceFill(['last_workspace_id' => (int) $workspaces->first()->getKey()])->save(); + + DB::enableQueryLog(); + + $this->actingAs($user) + ->get(route('filament.admin.pages.choose-workspace')) + ->assertOk(); + + $queryCount = count(DB::getQueryLog()); + DB::disableQueryLog(); + + // Should be bounded: auth query + workspaces (with count) + memberships for roles + minimal Filament overhead. + // Not proportional to workspace count. + expect($queryCount)->toBeLessThan(20); +}); + +// --- T031: it_persists_last_used_workspace_on_manual_selection --- + +it('persists last used workspace on manual selection', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + Livewire::actingAs($user) + ->test(\App\Filament\Pages\ChooseWorkspace::class) + ->call('selectWorkspace', (int) $workspace->getKey()); + + $user->refresh(); + expect($user->last_workspace_id)->toBe((int) $workspace->getKey()); +}); + +it('emits audit event on manual selection', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + Livewire::actingAs($user) + ->test(\App\Filament\Pages\ChooseWorkspace::class) + ->call('selectWorkspace', (int) $workspace->getKey()); + + $auditLog = AuditLog::query() + ->where('action', AuditActionId::WorkspaceSelected->value) + ->where('workspace_id', $workspace->getKey()) + ->first(); + + expect($auditLog)->not->toBeNull(); + expect($auditLog->metadata)->toMatchArray([ + 'method' => 'manual', + 'reason' => 'chooser', + ]); +}); + +// --- T033: it_rejects_non_member_workspace_selection_with_404 --- + +it('rejects non member workspace selection with 404', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + // User is NOT a member. + Livewire::actingAs($user) + ->test(\App\Filament\Pages\ChooseWorkspace::class) + ->call('selectWorkspace', (int) $workspace->getKey()) + ->assertStatus(404); +}); + +it('rejects archived workspace selection with 404', 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', + ]); + + Livewire::actingAs($user) + ->test(\App\Filament\Pages\ChooseWorkspace::class) + ->call('selectWorkspace', (int) $workspace->getKey()) + ->assertStatus(404); +}); diff --git a/tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php b/tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php new file mode 100644 index 0000000..80adedf --- /dev/null +++ b/tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php @@ -0,0 +1,358 @@ +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'); +}); diff --git a/tests/Feature/Workspaces/ManagedTenantsLivewireUpdateTest.php b/tests/Feature/Workspaces/ManagedTenantsLivewireUpdateTest.php new file mode 100644 index 0000000..4edcca5 --- /dev/null +++ b/tests/Feature/Workspaces/ManagedTenantsLivewireUpdateTest.php @@ -0,0 +1,81 @@ +create(); + + $workspace = Workspace::factory()->create(['slug' => 'test-ws']); + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + // 1. Load the page + $response = $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get('/admin/w/'.$workspace->slug.'/managed-tenants'); + + $response->assertSuccessful(); + + $html = $response->getContent(); + + // This landing page must not include Livewire-driven panel widgets that + // trigger background updates (lazy-loaded database notifications, progress poller). + expect($html)->not->toContain('Filament\\Livewire\\DatabaseNotifications'); + expect($html)->not->toContain('__lazyLoad'); + expect($html)->not->toContain('opsUxProgressWidgetPoller'); + + // 2. Extract the first Livewire component snapshot + preg_match('/wire:snapshot="([^"]+)"/', $html, $snapshotMatch); + expect($snapshotMatch)->not->toBeEmpty('No Livewire snapshot found in page HTML'); + + $snapshotJson = htmlspecialchars_decode($snapshotMatch[1]); + $snapshot = json_decode($snapshotJson, true); + + expect($snapshot)->toBeArray(); + expect($snapshot['memo']['path'] ?? null)->toBe('admin/w/test-ws/managed-tenants'); + + // 3. POST a Livewire update request + $updatePayload = [ + 'components' => [[ + 'snapshot' => $snapshotJson, + 'updates' => new \stdClass, + 'calls' => [], + ]], + ]; + + // Get the Livewire update URI path (includes hash prefix) + $routes = app('router')->getRoutes(); + $updateRoute = null; + foreach ($routes as $route) { + if (str_contains($route->getName() ?? '', 'livewire.update')) { + $updateRoute = $route; + break; + } + } + expect($updateRoute)->not->toBeNull('Livewire update route must exist'); + + $updateResponse = $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->withHeaders([ + 'X-Livewire' => 'true', + ]) + ->postJson('/'.$updateRoute->uri(), $updatePayload); + + $updateResponse->assertSuccessful(); +}); diff --git a/tests/Feature/Workspaces/SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php b/tests/Feature/Workspaces/SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php index ad86f4b..34380ad 100644 --- a/tests/Feature/Workspaces/SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php +++ b/tests/Feature/Workspaces/SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php @@ -10,7 +10,7 @@ uses(RefreshDatabase::class); -it('redirects to onboarding after switching to a workspace with no tenants', function (): void { +it('redirects to managed tenants after switching to a workspace with no tenants', function (): void { $user = User::factory()->create(); $workspace = Workspace::factory()->create(); @@ -21,10 +21,14 @@ 'role' => 'owner', ]); - $this + $response = $this ->actingAs($user) - ->post(route('admin.switch-workspace'), ['workspace_id' => (int) $workspace->getKey()]) - ->assertRedirect(route('admin.onboarding')); + ->post(route('admin.switch-workspace'), ['workspace_id' => (int) $workspace->getKey()]); + + $response->assertRedirect(); + + $location = $response->headers->get('Location'); + expect($location)->toContain('managed-tenants'); expect(session(WorkspaceContext::SESSION_KEY))->toBe((int) $workspace->getKey()); }); diff --git a/tests/Feature/Workspaces/WorkspaceAuditTrailTest.php b/tests/Feature/Workspaces/WorkspaceAuditTrailTest.php new file mode 100644 index 0000000..4a3a58d --- /dev/null +++ b/tests/Feature/Workspaces/WorkspaceAuditTrailTest.php @@ -0,0 +1,211 @@ +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'); + + $log = AuditLog::query() + ->where('action', AuditActionId::WorkspaceAutoSelected->value) + ->where('workspace_id', $workspace->getKey()) + ->first(); + + expect($log)->not->toBeNull(); + expect($log->metadata)->toMatchArray([ + 'method' => 'auto', + 'reason' => 'single_membership', + 'prev_workspace_id' => null, + ]); + expect($log->resource_type)->toBe('workspace'); + expect($log->resource_id)->toBe((string) $workspace->getKey()); + expect($log->actor_id)->toBe((int) $user->getKey()); +}); + +it('records workspace.auto_selected audit with last_used reason', 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 workspaceA. + $user->forceFill(['last_workspace_id' => (int) $workspaceA->getKey()])->save(); + + $this->actingAs($user)->get('/admin/_test/workspace-context'); + + $log = AuditLog::query() + ->where('action', AuditActionId::WorkspaceAutoSelected->value) + ->where('workspace_id', $workspaceA->getKey()) + ->first(); + + expect($log)->not->toBeNull(); + expect($log->metadata)->toMatchArray([ + 'method' => 'auto', + 'reason' => 'last_used', + 'prev_workspace_id' => null, + ]); + expect($log->resource_type)->toBe('workspace'); + expect($log->resource_id)->toBe((string) $workspaceA->getKey()); + expect($log->actor_id)->toBe((int) $user->getKey()); +}); + +it('records workspace.selected audit with chooser reason on manual selection', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + Livewire::actingAs($user) + ->test(\App\Filament\Pages\ChooseWorkspace::class) + ->call('selectWorkspace', (int) $workspace->getKey()); + + $log = AuditLog::query() + ->where('action', AuditActionId::WorkspaceSelected->value) + ->where('workspace_id', $workspace->getKey()) + ->first(); + + expect($log)->not->toBeNull(); + expect($log->metadata)->toMatchArray([ + 'method' => 'manual', + 'reason' => 'chooser', + ]); + expect($log->resource_type)->toBe('workspace'); + expect($log->resource_id)->toBe((string) $workspace->getKey()); + expect($log->actor_id)->toBe((int) $user->getKey()); +}); + +it('records workspace.selected audit with context_bar reason on switch-workspace POST', 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) + ->post(route('admin.switch-workspace'), ['workspace_id' => (int) $workspace->getKey()]); + + $log = AuditLog::query() + ->where('action', AuditActionId::WorkspaceSelected->value) + ->where('workspace_id', $workspace->getKey()) + ->first(); + + expect($log)->not->toBeNull(); + expect($log->metadata)->toMatchArray([ + 'method' => 'manual', + 'reason' => 'context_bar', + ]); + expect($log->resource_type)->toBe('workspace'); + expect($log->resource_id)->toBe((string) $workspace->getKey()); + expect($log->actor_id)->toBe((int) $user->getKey()); +}); + +// --- T035: it_includes_prev_workspace_id_when_switching_from_active_workspace --- + +it('includes prev_workspace_id when switching via chooser from active workspace', 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', + ]); + + // Simulate having workspaceA as the current workspace. + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspaceA->getKey()); + + Livewire::actingAs($user) + ->test(\App\Filament\Pages\ChooseWorkspace::class) + ->call('selectWorkspace', (int) $workspaceB->getKey()); + + $log = AuditLog::query() + ->where('action', AuditActionId::WorkspaceSelected->value) + ->where('workspace_id', $workspaceB->getKey()) + ->first(); + + expect($log)->not->toBeNull(); + expect($log->metadata['prev_workspace_id'])->toBe((int) $workspaceA->getKey()); + expect($log->metadata['method'])->toBe('manual'); + expect($log->metadata['reason'])->toBe('chooser'); +}); + +it('includes prev_workspace_id when switching via context bar from active workspace', 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', + ]); + + $this->actingAs($user) + ->withSession([ + WorkspaceContext::SESSION_KEY => (int) $workspaceA->getKey(), + ]) + ->post(route('admin.switch-workspace'), ['workspace_id' => (int) $workspaceB->getKey()]); + + $log = AuditLog::query() + ->where('action', AuditActionId::WorkspaceSelected->value) + ->where('workspace_id', $workspaceB->getKey()) + ->first(); + + expect($log)->not->toBeNull(); + expect($log->metadata['prev_workspace_id'])->toBe((int) $workspaceA->getKey()); + expect($log->metadata['method'])->toBe('manual'); + expect($log->metadata['reason'])->toBe('context_bar'); +}); diff --git a/tests/Feature/Workspaces/WorkspaceRedirectResolverTest.php b/tests/Feature/Workspaces/WorkspaceRedirectResolverTest.php new file mode 100644 index 0000000..56896b0 --- /dev/null +++ b/tests/Feature/Workspaces/WorkspaceRedirectResolverTest.php @@ -0,0 +1,121 @@ +resolver = new WorkspaceRedirectResolver; +}); + +it('redirects to managed tenants index when 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', + ]); + + $url = $this->resolver->resolve($workspace, $user); + + $expectedRoute = route('admin.workspace.managed-tenants.index', [ + 'workspace' => $workspace->slug ?? $workspace->getKey(), + ]); + + expect($url)->toBe($expectedRoute); +}); + +it('redirects to tenant dashboard when workspace has exactly 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'], + ]); + + $url = $this->resolver->resolve($workspace, $user); + + $expectedUrl = TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant); + + expect($url)->toBe($expectedUrl); +}); + +it('redirects to choose tenant page when workspace has multiple active tenants', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $tenantA = Tenant::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'status' => 'active', + ]); + + $tenantB = Tenant::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'status' => 'active', + ]); + + $user->tenants()->syncWithoutDetaching([ + $tenantA->getKey() => ['role' => 'owner'], + $tenantB->getKey() => ['role' => 'owner'], + ]); + + $url = $this->resolver->resolve($workspace, $user); + + expect($url)->toBe(ChooseTenant::getUrl()); +}); + +it('falls back to chooser page when workspace ID is invalid', function (): void { + $user = User::factory()->create(); + + $url = $this->resolver->resolveFromId(999999, $user); + + expect($url)->toBe(ChooseWorkspace::getUrl()); +}); + +it('resolves correctly from workspace ID', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $url = $this->resolver->resolveFromId((int) $workspace->getKey(), $user); + + $expectedRoute = route('admin.workspace.managed-tenants.index', [ + 'workspace' => $workspace->slug ?? $workspace->getKey(), + ]); + + expect($url)->toBe($expectedRoute); +}); diff --git a/tests/Feature/Workspaces/WorkspaceSwitchUserMenuTest.php b/tests/Feature/Workspaces/WorkspaceSwitchUserMenuTest.php new file mode 100644 index 0000000..32ac538 --- /dev/null +++ b/tests/Feature/Workspaces/WorkspaceSwitchUserMenuTest.php @@ -0,0 +1,73 @@ +create(); + + $workspaceA = Workspace::factory()->create(['name' => 'Workspace Alpha']); + $workspaceB = Workspace::factory()->create(['name' => 'Workspace Beta']); + + 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) $workspaceA->getKey()])->save(); + + $response = $this->actingAs($user) + ->withSession([ + WorkspaceContext::SESSION_KEY => (int) $workspaceA->getKey(), + ]) + ->get('/admin/workspaces'); + + $response->assertOk(); + $response->assertSee('choose-workspace?choose=1', false); +}); + +// --- T032: it_hides_switch_workspace_menu_when_single_workspace --- + +it('hides switch workspace in user menu when single workspace', function (): void { + $user = User::factory()->create(); + + $workspace = Workspace::factory()->create(['name' => 'Solo Workspace']); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save(); + + $response = $this->actingAs($user) + ->withSession([ + WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(), + ]) + ->get('/admin/workspaces'); + + $response->assertOk(); + $response->assertDontSee('choose-workspace?choose=1', false); +});