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 <ahmed.darrazi@live.de>
Reviewed-on: #131
This commit is contained in:
ahmido 2026-02-22 19:54:24 +00:00
parent 8bee824966
commit e15eee8f26
50 changed files with 3244 additions and 620 deletions

View File

@ -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
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -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<int, Tenant>
*/

View File

@ -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<Action>
* Workspace roles keyed by workspace_id.
*
* @var array<int, string>
*/
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));
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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),
];
}
}

View File

@ -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'),

View File

@ -1,41 +0,0 @@
<?php
namespace App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\TenantResource;
use App\Models\User;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Resources\Pages\CreateRecord;
class CreateTenant extends CreateRecord
{
protected static string $resource = TenantResource::class;
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
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'],
]);
}
}

View File

@ -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')),
];
}
}

View File

@ -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));
}
}

View File

@ -1,17 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
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 App\Support\Workspaces\WorkspaceRedirectResolver;
use Closure;
use Filament\Notifications\Notification;
use Illuminate\Http\Request;
use Illuminate\Http\Response as HttpResponse;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\HttpFoundation\Response;
class EnsureWorkspaceSelected
@ -19,10 +22,20 @@ class EnsureWorkspaceSelected
/**
* Handle an incoming request.
*
* Spec 107 7-step algorithm:
* 1. If workspace-optional path allow
* 2. If ?choose=1 redirect to chooser
* 3. If session set validate membership; stale clear + warn + chooser
* 4. Load selectable memberships
* 5. If exactly 1 auto-select + audit + redirect via tenant branching
* 6. If last_workspace_id valid auto-select + audit + redirect
* 7. Else redirect to chooser
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
// Auth-related routes are always allowed.
$routeName = $request->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(),
);
}
}

View File

@ -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,

View File

@ -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';
}

View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Support\Workspaces;
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\ChooseWorkspace;
use App\Filament\Pages\TenantDashboard;
use App\Models\User;
use App\Models\Workspace;
/**
* Resolves the redirect URL after a workspace is set.
*
* Tenant-count branching (FR-009):
* - 0 tenants Managed Tenants index
* - 1 tenant Tenant Dashboard directly
* - >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);
}
}

View File

@ -1,60 +1,133 @@
<x-filament-panels::page>
<x-filament::section>
<div class="flex flex-col gap-4">
<div class="text-sm text-gray-600 dark:text-gray-300">
Select a tenant to continue.
@php
$tenants = $this->getTenants();
$workspace = app(\App\Support\Workspaces\WorkspaceContext::class)->currentWorkspace();
@endphp
@if ($tenants->isEmpty())
{{-- Empty state --}}
<div class="mx-auto max-w-md">
<div class="rounded-xl border border-gray-200 bg-white p-8 text-center shadow-sm dark:border-white/10 dark:bg-white/5">
@if ($workspace)
<div class="mb-5 inline-flex items-center gap-1.5 rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs font-medium text-gray-600 dark:border-white/10 dark:bg-white/5 dark:text-gray-400">
<x-filament::icon icon="heroicon-m-building-office-2" class="h-3.5 w-3.5" />
{{ $workspace->name }}
</div>
@endif
<div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 dark:bg-primary-950/30">
<x-filament::icon
icon="heroicon-o-server-stack"
class="h-7 w-7 text-primary-500 dark:text-primary-400"
/>
</div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">No tenants available</h3>
<p class="mx-auto mt-2 max-w-xs text-sm text-gray-500 dark:text-gray-400">
There are no active tenants in this workspace yet. Add one via onboarding, or switch to a different workspace.
</p>
<div class="mt-6 flex flex-col items-center gap-3">
<x-filament::button
tag="a"
href="{{ route('admin.onboarding') }}"
icon="heroicon-m-plus"
size="lg"
>
Add tenant
</x-filament::button>
<a href="{{ route('filament.admin.pages.choose-workspace') }}"
class="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<x-filament::icon icon="heroicon-m-arrows-right-left" class="h-4 w-4" />
Switch workspace
</a>
</div>
</div>
</div>
@else
{{-- Tenant list --}}
<div class="mx-auto max-w-3xl">
{{-- Header row --}}
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-2">
@if ($workspace)
<div class="inline-flex items-center gap-1.5 rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs font-medium text-gray-600 dark:border-white/10 dark:bg-white/5 dark:text-gray-400">
<x-filament::icon icon="heroicon-m-building-office-2" class="h-3.5 w-3.5" />
{{ $workspace->name }}
</div>
@endif
<span class="text-sm text-gray-500 dark:text-gray-400">
&middot; {{ $tenants->count() }} {{ \Illuminate\Support\Str::plural('tenant', $tenants->count()) }}
</span>
</div>
</div>
@php
$tenants = $this->getTenants();
@endphp
<p class="mb-4 text-sm text-gray-500 dark:text-gray-400">Select a tenant to continue.</p>
@if ($tenants->isEmpty())
<div class="rounded-md border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
<div class="font-medium text-gray-900 dark:text-gray-100">No tenants are available for your account.</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
Switch workspaces, or contact an administrator.
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<x-filament::button
type="button"
color="gray"
tag="a"
href="{{ route('filament.admin.pages.choose-workspace') }}"
>
Change workspace
</x-filament::button>
</div>
</div>
@else
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($tenants as $tenant)
<div
wire:key="tenant-{{ $tenant->id }}"
x-data
@click="if ($event.target.closest('button,a,input,select,textarea')) return; $refs.form.submit();"
class="cursor-pointer rounded-lg border border-gray-200 p-4 dark:border-gray-800"
>
<form x-ref="form" method="POST" action="{{ route('admin.select-tenant') }}" class="flex flex-col gap-3">
@csrf
<input type="hidden" name="tenant_id" value="{{ (int) $tenant->id }}" />
<div class="font-medium text-gray-900 dark:text-gray-100">
{{ $tenant->name }}
</div>
<x-filament::button
type="submit"
color="primary"
class="w-full"
>
Continue
</x-filament::button>
</form>
{{-- Tenant cards --}}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-{{ min($tenants->count(), 3) }}">
@foreach ($tenants as $tenant)
<button
type="button"
wire:key="tenant-{{ $tenant->id }}"
wire:click="selectTenant({{ (int) $tenant->id }})"
class="group relative flex flex-col rounded-xl border border-gray-200 bg-white p-5 text-left shadow-sm transition-all duration-150 hover:border-gray-300 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:border-white/10 dark:bg-white/5 dark:hover:border-white/20 dark:focus:ring-offset-gray-900"
>
{{-- Loading overlay --}}
<div wire:loading wire:target="selectTenant({{ (int) $tenant->id }})"
class="absolute inset-0 z-10 flex items-center justify-center rounded-xl bg-white/80 dark:bg-gray-900/80">
<x-filament::loading-indicator class="h-5 w-5 text-primary-500" />
</div>
@endforeach
</div>
@endif
<div class="flex items-start gap-3">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-gray-100 group-hover:bg-gray-200 dark:bg-white/10 dark:group-hover:bg-white/15">
<x-filament::icon
icon="heroicon-o-server-stack"
class="h-5 w-5 text-gray-500 group-hover:text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-300"
/>
</div>
<div class="min-w-0 flex-1">
<h3 class="truncate text-sm font-semibold text-gray-900 dark:text-white">
{{ $tenant->name }}
</h3>
@if ($tenant->domain)
<p class="mt-0.5 truncate text-xs text-gray-500 dark:text-gray-400">
{{ $tenant->domain }}
</p>
@endif
@if ($tenant->environment)
<span class="mt-1.5 inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-white/10 dark:text-gray-400">
{{ strtoupper($tenant->environment) }}
</span>
@endif
</div>
</div>
{{-- Hover arrow --}}
<div class="absolute right-4 top-5 opacity-0 transition-opacity group-hover:opacity-100">
<x-filament::icon
icon="heroicon-m-arrow-right"
class="h-4 w-4 text-gray-400 dark:text-gray-500"
/>
</div>
</button>
@endforeach
</div>
{{-- Footer links --}}
<div class="mt-6 flex items-center justify-center gap-6">
<a href="{{ route('admin.onboarding') }}"
class="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<x-filament::icon icon="heroicon-m-plus" class="h-4 w-4" />
Add tenant
</a>
<a href="{{ route('filament.admin.pages.choose-workspace') }}"
class="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<x-filament::icon icon="heroicon-m-arrows-right-left" class="h-4 w-4" />
Switch workspace
</a>
</div>
</div>
</x-filament::section>
@endif
</x-filament-panels::page>

View File

@ -1,70 +1,155 @@
<x-filament-panels::page>
<x-filament::section>
<div class="flex flex-col gap-4">
<div class="text-sm text-gray-600 dark:text-gray-300">
@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())
<div class="mx-auto max-w-md">
<div class="rounded-xl border border-gray-200 bg-white p-8 text-center shadow-sm dark:border-white/10 dark:bg-white/5">
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-white/10">
<x-filament::icon
icon="heroicon-o-building-office-2"
class="h-6 w-6 text-gray-400 dark:text-gray-500"
/>
</div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">No workspaces available</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
You don't have access to any workspace yet. Contact your administrator to get started.
</p>
</div>
</div>
@else
<div class="mx-auto max-w-3xl">
<p class="mb-6 text-center text-sm text-gray-500 dark:text-gray-400">
Select a workspace to continue.
</p>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-{{ min($workspaces->count(), 3) }}">
@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
<button
type="button"
wire:key="workspace-{{ $workspace->id }}"
wire:click="selectWorkspace({{ (int) $workspace->id }})"
@class([
'group relative flex flex-col rounded-xl border p-5 text-left transition-all duration-150',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900',
// Recommended (last used)
'border-primary-300 bg-primary-50/50 shadow-sm ring-1 ring-primary-200 hover:border-primary-400 hover:shadow-md dark:border-primary-500/40 dark:bg-primary-950/20 dark:ring-primary-500/20 dark:hover:border-primary-400/60' => $isRecommended,
// Default
'border-gray-200 bg-white shadow-sm hover:border-gray-300 hover:shadow-md dark:border-white/10 dark:bg-white/5 dark:hover:border-white/20' => ! $isRecommended,
])
>
{{-- Loading overlay --}}
<div wire:loading wire:target="selectWorkspace({{ (int) $workspace->id }})"
class="absolute inset-0 z-10 flex items-center justify-center rounded-xl bg-white/80 dark:bg-gray-900/80">
<x-filament::loading-indicator class="h-5 w-5 text-primary-500" />
</div>
{{-- Header: icon + name --}}
<div class="flex items-start gap-3">
<div @class([
'flex h-10 w-10 shrink-0 items-center justify-center rounded-lg',
'bg-primary-100 dark:bg-primary-900/50' => $isRecommended,
'bg-gray-100 group-hover:bg-gray-200 dark:bg-white/10 dark:group-hover:bg-white/15' => ! $isRecommended,
])>
<x-filament::icon
icon="heroicon-o-building-office-2"
@class([
'h-5 w-5',
'text-primary-600 dark:text-primary-400' => $isRecommended,
'text-gray-500 group-hover:text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-300' => ! $isRecommended,
])
/>
</div>
<div class="min-w-0 flex-1">
<h3 class="truncate text-sm font-semibold text-gray-900 dark:text-white">
{{ $workspace->name }}
</h3>
@if ($role)
<div class="mt-0.5 flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
<x-filament::icon
:icon="$roleIconMap[$role] ?? 'heroicon-m-user'"
class="h-3.5 w-3.5"
/>
{{ ucfirst($role) }}
</div>
@endif
</div>
</div>
{{-- Metadata --}}
<div class="mt-4 flex items-center justify-between border-t border-gray-100 pt-3 dark:border-white/5">
<div class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<x-filament::icon icon="heroicon-m-server-stack" class="h-3.5 w-3.5" />
{{ $tenantCount }} {{ \Illuminate\Support\Str::plural('tenant', $tenantCount) }}
</div>
@if ($isRecommended)
<x-filament::badge color="primary" size="sm">
Last used
</x-filament::badge>
@endif
</div>
{{-- Hover arrow indicator --}}
<div class="absolute right-4 top-5 opacity-0 transition-opacity group-hover:opacity-100">
<x-filament::icon
icon="heroicon-m-arrow-right"
class="h-4 w-4 text-gray-400 dark:text-gray-500"
/>
</div>
</button>
@endforeach
</div>
@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())
<div class="rounded-md border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
No active workspaces are available for your account.
You can create one using the button above.
</div>
@else
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($workspaces as $workspace)
@php
$isRecommended = $recommendedWorkspaceId > 0 && (int) $workspace->id === $recommendedWorkspaceId;
@endphp
<div
wire:key="workspace-{{ $workspace->id }}"
x-data
@click="if ($event.target.closest('button,a,input,select,textarea')) return; $refs.form.submit();"
class="cursor-pointer rounded-lg border p-4 dark:border-gray-800 {{ $isRecommended ? 'border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/30' : 'border-gray-200' }}"
>
<form x-ref="form" method="POST" action="{{ route('admin.switch-workspace') }}" class="flex flex-col gap-3">
@csrf
<input type="hidden" name="workspace_id" value="{{ (int) $workspace->id }}" />
<div class="flex flex-col gap-2">
<div class="font-medium text-gray-900 dark:text-gray-100">
{{ $workspace->name }}
</div>
@if ($isRecommended)
<div>
<x-filament::badge color="warning" size="sm">
Last used
</x-filament::badge>
</div>
@endif
</div>
<x-filament::button
type="submit"
color="primary"
class="w-full"
>
Continue
</x-filament::button>
</form>
</div>
@endforeach
@if ($canManageWorkspaces)
<div class="mt-6 flex justify-center">
<a href="/admin/workspaces"
class="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<x-filament::icon icon="heroicon-m-cog-6-tooth" class="h-4 w-4" />
Manage workspaces
</a>
</div>
@endif
</div>
</x-filament::section>
@endif
</x-filament-panels::page>

View File

@ -1,170 +1,3 @@
<x-filament-panels::page>
<x-filament::section>
<div class="flex flex-col gap-4">
<div class="text-sm text-gray-600 dark:text-gray-300">
Workspace: <span class="font-medium text-gray-900 dark:text-gray-100">{{ $this->workspace->name }}</span>
</div>
<div class="rounded-md border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
<div class="font-medium text-gray-900 dark:text-gray-100">
Managed tenant onboarding
</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
This wizard will guide you through identifying a managed tenant and verifying access.
</div>
</div>
@if ($this->managedTenant)
<div class="rounded-md border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-200">
<div class="font-medium text-gray-900 dark:text-gray-100">Identified tenant</div>
<dl class="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Name</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $this->managedTenant->name }}</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Tenant ID</dt>
<dd class="mt-1 font-mono text-sm text-gray-900 dark:text-gray-100">{{ $this->managedTenant->tenant_id }}</dd>
</div>
</dl>
</div>
@endif
@php
$verificationSucceeded = $this->verificationSucceeded();
$hasTenant = (bool) $this->managedTenant;
$hasConnection = $hasTenant && is_int($this->selectedProviderConnectionId) && $this->selectedProviderConnectionId > 0;
@endphp
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 1 Identify managed tenant</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Provide tenant ID + display name to start or resume the flow.</div>
</div>
<div class="text-xs font-medium {{ $hasTenant ? 'text-emerald-700 dark:text-emerald-400' : 'text-gray-500 dark:text-gray-400' }}">
{{ $hasTenant ? 'Done' : 'Pending' }}
</div>
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<x-filament::button
type="button"
color="primary"
wire:click="mountAction('identifyManagedTenant')"
>
{{ $hasTenant ? 'Change tenant' : 'Identify tenant' }}
</x-filament::button>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 2 Provider connection</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Create or pick the connection used to verify access.</div>
</div>
<div class="text-xs font-medium {{ $hasConnection ? 'text-emerald-700 dark:text-emerald-400' : 'text-gray-500 dark:text-gray-400' }}">
{{ $hasConnection ? 'Selected' : ($hasTenant ? 'Pending' : 'Locked') }}
</div>
</div>
@if ($hasTenant)
<div class="mt-3 text-sm text-gray-700 dark:text-gray-200">
<span class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Selected connection ID</span>
<div class="mt-1 font-mono">{{ $this->selectedProviderConnectionId ?? '—' }}</div>
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<x-filament::button
type="button"
color="gray"
wire:click="mountAction('createProviderConnection')"
>
Create connection
</x-filament::button>
<x-filament::button
type="button"
color="gray"
wire:click="mountAction('selectProviderConnection')"
>
Select connection
</x-filament::button>
</div>
@endif
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 3 Verify access</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Runs a verification operation and records the result.</div>
</div>
<div class="text-xs font-medium {{ $verificationSucceeded ? 'text-emerald-700 dark:text-emerald-400' : 'text-gray-500 dark:text-gray-400' }}">
{{ $verificationSucceeded ? 'Succeeded' : ($hasConnection ? 'Pending' : 'Locked') }}
</div>
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<x-filament::button
type="button"
color="primary"
:disabled="! $hasConnection"
wire:click="mountAction('startVerification')"
>
Run verification
</x-filament::button>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 4 Bootstrap (optional)</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Start inventory/compliance sync after verification.</div>
</div>
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ $verificationSucceeded ? 'Available' : 'Locked' }}
</div>
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<x-filament::button
type="button"
color="gray"
:disabled="! $verificationSucceeded"
wire:click="mountAction('startBootstrap')"
>
Start bootstrap
</x-filament::button>
</div>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 5 Complete onboarding</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Marks the tenant as active after successful verification.</div>
</div>
<div class="text-xs font-medium {{ $verificationSucceeded ? 'text-emerald-700 dark:text-emerald-400' : 'text-gray-500 dark:text-gray-400' }}">
{{ $verificationSucceeded ? 'Ready' : 'Locked' }}
</div>
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<x-filament::button
type="button"
color="success"
:disabled="! $verificationSucceeded"
wire:click="mountAction('completeOnboarding')"
>
Complete onboarding
</x-filament::button>
</div>
</div>
</div>
</x-filament::section>
</x-filament-panels::page>

View File

@ -1,76 +1,131 @@
<x-filament-panels::page>
<x-filament::section>
<div class="flex flex-col gap-4">
<div class="text-sm text-gray-600 dark:text-gray-300">
Workspace: <span class="font-medium text-gray-900 dark:text-gray-100">{{ $this->workspace->name }}</span>
@php
$tenants = $this->getTenants();
@endphp
@if ($tenants->isEmpty())
{{-- Empty state enterprise-grade --}}
<div class="mx-auto max-w-md">
<div class="rounded-xl border border-gray-200 bg-white p-8 text-center shadow-sm dark:border-white/10 dark:bg-white/5">
{{-- Workspace context badge --}}
<div class="mb-5 inline-flex items-center gap-1.5 rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs font-medium text-gray-600 dark:border-white/10 dark:bg-white/5 dark:text-gray-400">
<x-filament::icon icon="heroicon-m-building-office-2" class="h-3.5 w-3.5" />
{{ $this->workspace->name }}
</div>
{{-- Icon --}}
<div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 dark:bg-primary-950/30">
<x-filament::icon
icon="heroicon-o-server-stack"
class="h-7 w-7 text-primary-500 dark:text-primary-400"
/>
</div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">No managed tenants yet</h3>
<p class="mx-auto mt-2 max-w-xs text-sm text-gray-500 dark:text-gray-400">
Connect your first Microsoft Entra tenant to start managing inventory, backups, drift detection, and policies.
</p>
<div class="mt-6 flex flex-col items-center gap-3">
<x-filament::button
tag="a"
href="{{ route('admin.onboarding') }}"
icon="heroicon-m-plus"
size="lg"
>
Add tenant
</x-filament::button>
<a href="{{ route('filament.admin.pages.choose-workspace') }}"
class="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<x-filament::icon icon="heroicon-m-arrows-right-left" class="h-4 w-4" />
Switch workspace
</a>
</div>
</div>
</div>
@else
{{-- Tenant list --}}
<div class="mx-auto max-w-3xl">
{{-- Header row --}}
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="inline-flex items-center gap-1.5 rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs font-medium text-gray-600 dark:border-white/10 dark:bg-white/5 dark:text-gray-400">
<x-filament::icon icon="heroicon-m-building-office-2" class="h-3.5 w-3.5" />
{{ $this->workspace->name }}
</div>
<span class="text-sm text-gray-500 dark:text-gray-400">
&middot; {{ $tenants->count() }} {{ \Illuminate\Support\Str::plural('tenant', $tenants->count()) }}
</span>
</div>
<x-filament::button
color="gray"
size="sm"
wire:click="goToChooseTenant"
icon="heroicon-m-arrow-right"
icon-position="after"
>
Choose tenant
</x-filament::button>
</div>
@php
$tenants = $this->getTenants();
@endphp
@if ($tenants->isEmpty())
<div class="rounded-md border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
<div class="font-medium text-gray-900 dark:text-gray-100">No managed tenants yet.</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
Add a managed tenant to start inventory, drift, backups, and policy management.
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<x-filament::button
type="button"
color="primary"
tag="a"
href="{{ route('admin.onboarding') }}"
>
Start onboarding
</x-filament::button>
<x-filament::button
type="button"
color="gray"
tag="a"
href="{{ route('filament.admin.pages.choose-workspace') }}"
>
Change workspace
</x-filament::button>
</div>
</div>
@else
<div class="flex items-center justify-between gap-3">
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ $tenants->count() }} managed tenant{{ $tenants->count() === 1 ? '' : 's' }}
</div>
<x-filament::button
{{-- Tenant cards --}}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-{{ min($tenants->count(), 3) }}">
@foreach ($tenants as $tenant)
<button
type="button"
color="gray"
wire:click="goToChooseTenant"
wire:key="tenant-{{ $tenant->id }}"
wire:click="openTenant({{ (int) $tenant->id }})"
class="group relative flex flex-col rounded-xl border border-gray-200 bg-white p-5 text-left shadow-sm transition-all duration-150 hover:border-gray-300 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:border-white/10 dark:bg-white/5 dark:hover:border-white/20 dark:focus:ring-offset-gray-900"
>
Choose tenant
</x-filament::button>
</div>
{{-- Loading overlay --}}
<div wire:loading wire:target="openTenant({{ (int) $tenant->id }})"
class="absolute inset-0 z-10 flex items-center justify-center rounded-xl bg-white/80 dark:bg-gray-900/80">
<x-filament::loading-indicator class="h-5 w-5 text-primary-500" />
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($tenants as $tenant)
<div wire:key="tenant-{{ $tenant->id }}" class="rounded-lg border border-gray-200 p-4 dark:border-gray-800">
<div class="flex flex-col gap-3">
<div class="font-medium text-gray-900 dark:text-gray-100">
<div class="flex items-start gap-3">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-gray-100 group-hover:bg-gray-200 dark:bg-white/10 dark:group-hover:bg-white/15">
<x-filament::icon
icon="heroicon-o-server-stack"
class="h-5 w-5 text-gray-500 group-hover:text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-300"
/>
</div>
<div class="min-w-0 flex-1">
<h3 class="truncate text-sm font-semibold text-gray-900 dark:text-white">
{{ $tenant->name }}
</div>
<x-filament::button
type="button"
color="primary"
wire:click="openTenant({{ (int) $tenant->id }})"
>
Open
</x-filament::button>
</h3>
<p class="mt-0.5 truncate text-xs text-gray-500 dark:text-gray-400">
{{ $tenant->external_id ?? 'No external ID' }}
</p>
</div>
</div>
@endforeach
</div>
@endif
{{-- Hover arrow --}}
<div class="absolute right-4 top-5 opacity-0 transition-opacity group-hover:opacity-100">
<x-filament::icon
icon="heroicon-m-arrow-right"
class="h-4 w-4 text-gray-400 dark:text-gray-500"
/>
</div>
</button>
@endforeach
</div>
{{-- Footer links --}}
<div class="mt-6 flex items-center justify-center gap-6">
<a href="{{ route('admin.onboarding') }}"
class="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<x-filament::icon icon="heroicon-m-plus" class="h-4 w-4" />
Add tenant
</a>
<a href="{{ route('filament.admin.pages.choose-workspace') }}"
class="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<x-filament::icon icon="heroicon-m-arrows-right-left" class="h-4 w-4" />
Switch workspace
</a>
</div>
</div>
</x-filament::section>
@endif
</x-filament-panels::page>

View File

@ -1,6 +1,5 @@
<?php
use App\Filament\Pages\TenantDashboard;
use App\Http\Controllers\AdminConsentCallbackController;
use App\Http\Controllers\Auth\EntraController;
use App\Http\Controllers\ClearTenantContextController;
@ -13,6 +12,7 @@
use App\Models\User;
use App\Models\Workspace;
use App\Support\Workspaces\WorkspaceContext;
use App\Support\Workspaces\WorkspaceRedirectResolver;
use App\Support\Workspaces\WorkspaceResolver;
use Filament\Http\Middleware\Authenticate as FilamentAuthenticate;
use Filament\Http\Middleware\DisableBladeIconComponents;
@ -60,25 +60,10 @@
return redirect()->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');

View File

@ -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`.

View File

@ -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()`

View File

@ -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.

View File

@ -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 |
|-----------|------------|-------------------------------------|
| — | — | — |

View File

@ -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
```

View File

@ -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 `<x-filament::badge>` 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).

View File

@ -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`)

View File

@ -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 `<x-filament::badge>`, 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 (US1US6)
- 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 15, 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 (`<x-filament::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 37 (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 US1US5 → 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)
T021T022 Enhance error paths (US4)
# Developer B: Chooser page track (US3 → US5)
T013T015 Upgrade ChooseWorkspace page + Blade (US3)
T026T029 Add audit + wire:click + user menu (US5)
# Both tracks converge at:
T034T035 Audit trail verification (US6)
T036T040 Polish & cross-cutting
```
---
## Implementation Strategy
### MVP First (US1 Only)
1. Complete Phase 2: Foundational (T001T003)
2. Complete Phase 3: US1 — Single workspace auto-resume (T004T008)
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 (T001T003) → Core infrastructure ready
2. US1 (T004T008) → Single workspace auto-resume → **MVP!**
3. US2 (T009T012) → Last-used auto-resume → Multi-workspace friction reduced
4. US3 (T013T020) → Enterprise metadata in chooser → Better selection UX
5. US4 (T021T025) → Stale session handling → Governance safety net
6. US5 (T026T033) → Manual switch via user menu → Full switch flow
7. US6 (T034T035) → Audit verification → Compliance confidence
8. Polish (T036T040) → 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)

View File

@ -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 {

View File

@ -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');
});

View File

@ -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'));

View File

@ -1,6 +1,5 @@
<?php
use App\Filament\Resources\TenantResource\Pages\CreateTenant;
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
@ -18,33 +17,22 @@
uses(RefreshDatabase::class);
test('tenant can be created via filament and verification start enqueues an operation run', function () {
test('verification start enqueues an operation run for a tenant', function () {
Queue::fake();
bindFailHardGraphClient();
$user = User::factory()->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(),

View File

@ -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 {

View File

@ -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');

View File

@ -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');

View File

@ -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 {

View File

@ -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();
});

View File

@ -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();
});

View File

@ -1,7 +1,7 @@
<?php
use App\Filament\Pages\Tenancy\RegisterTenant;
use App\Filament\Resources\TenantResource\Pages\CreateTenant;
use App\Filament\Resources\TenantResource;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
@ -24,12 +24,10 @@
Filament::setCurrentPanel(null);
});
test('readonly users cannot create tenants', function () {
test('readonly users cannot create tenants via CRUD', function () {
[$user] = createUserWithTenant(role: 'readonly');
$this->actingAs($user);
Livewire::actingAs($user)
->test(CreateTenant::class)
->assertStatus(403);
expect(TenantResource::canCreate())->toBeFalse();
});

View File

@ -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 () {

View File

@ -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 {

View File

@ -63,20 +63,20 @@
$response->assertDontSee('>Governance</span>', 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 {

View File

@ -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 {

View File

@ -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 {

View File

@ -0,0 +1,297 @@
<?php
declare(strict_types=1);
use App\Models\AuditLog;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Audit\AuditActionId;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Http::preventStrayRequests();
});
// --- T016: it_only_lists_workspaces_user_is_member_of ---
it('only lists workspaces user is member of', function (): void {
$user = User::factory()->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);
});

View File

@ -0,0 +1,358 @@
<?php
declare(strict_types=1);
use App\Models\AuditLog;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Audit\AuditActionId;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
// --- T005: it_skips_chooser_when_single_workspace_membership ---
it('skips chooser when single workspace membership', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$response = $this->actingAs($user)->get('/admin/_test/workspace-context');
// Should redirect via tenant branching (not to chooser).
$response->assertRedirect();
$location = $response->headers->get('Location');
expect($location)->not->toContain('choose-workspace');
});
// --- T006: it_emits_audit_event_on_auto_selection_single_membership ---
it('emits audit event on auto selection single membership', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user)->get('/admin/_test/workspace-context');
$auditLog = AuditLog::query()
->where('action', AuditActionId::WorkspaceAutoSelected->value)
->where('workspace_id', $workspace->getKey())
->first();
expect($auditLog)->not->toBeNull();
expect($auditLog->metadata)->toMatchArray([
'method' => 'auto',
'reason' => 'single_membership',
]);
expect($auditLog->resource_type)->toBe('workspace');
expect($auditLog->resource_id)->toBe((string) $workspace->getKey());
});
// --- T007: it_redirects_via_tenant_count_branching_after_single_auto_resume ---
it('redirects to managed tenants index when single workspace has zero tenants', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$response = $this->actingAs($user)->get('/admin/_test/workspace-context');
$expectedRoute = route('admin.workspace.managed-tenants.index', [
'workspace' => $workspace->slug ?? $workspace->getKey(),
]);
$response->assertRedirect($expectedRoute);
});
it('redirects to tenant dashboard when single workspace has one active tenant', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$tenant = Tenant::factory()->create([
'workspace_id' => $workspace->getKey(),
'status' => 'active',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$response = $this->actingAs($user)->get('/admin/_test/workspace-context');
$response->assertRedirect();
$location = $response->headers->get('Location');
expect($location)->toContain('/admin/t/');
});
// --- T008: it_allows_request_when_session_workspace_is_valid ---
it('allows request when session workspace is valid', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$response = $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin/_test/workspace-context');
// Should pass through (200) since session is valid.
$response->assertOk();
$response->assertJson(['workspace_id' => (int) $workspace->getKey()]);
});
// --- T010: it_auto_resumes_to_last_used_workspace_when_membership_valid ---
it('auto resumes to last used workspace when membership valid', function (): void {
$user = User::factory()->create();
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspaceA->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
WorkspaceMembership::factory()->create([
'workspace_id' => $workspaceB->getKey(),
'user_id' => $user->getKey(),
'role' => 'operator',
]);
// Set last_workspace_id to workspaceB.
$user->forceFill(['last_workspace_id' => (int) $workspaceB->getKey()])->save();
$response = $this->actingAs($user)->get('/admin/_test/workspace-context');
// Should redirect via tenant branching (not to chooser).
$response->assertRedirect();
$location = $response->headers->get('Location');
expect($location)->not->toContain('choose-workspace');
});
// --- T011: it_emits_audit_event_on_auto_selection_last_used ---
it('emits audit event on auto selection last used', function (): void {
$user = User::factory()->create();
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspaceA->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
WorkspaceMembership::factory()->create([
'workspace_id' => $workspaceB->getKey(),
'user_id' => $user->getKey(),
'role' => 'operator',
]);
$user->forceFill(['last_workspace_id' => (int) $workspaceB->getKey()])->save();
$this->actingAs($user)->get('/admin/_test/workspace-context');
$auditLog = AuditLog::query()
->where('action', AuditActionId::WorkspaceAutoSelected->value)
->where('workspace_id', $workspaceB->getKey())
->first();
expect($auditLog)->not->toBeNull();
expect($auditLog->metadata)->toMatchArray([
'method' => 'auto',
'reason' => 'last_used',
]);
});
// --- T012: it_falls_back_to_chooser_when_multiple_workspaces_and_no_last_used ---
it('falls back to chooser when multiple workspaces and no last used', function (): void {
$user = User::factory()->create();
$user->forceFill(['last_workspace_id' => null])->save();
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspaceA->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
WorkspaceMembership::factory()->create([
'workspace_id' => $workspaceB->getKey(),
'user_id' => $user->getKey(),
'role' => 'operator',
]);
$response = $this->actingAs($user)->get('/admin/_test/workspace-context');
$response->assertRedirect('/admin/choose-workspace');
});
// --- T023: it_clears_session_when_active_workspace_membership_revoked ---
it('clears session when active workspace membership revoked', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
// Set session but don't create membership — simulates revoked access.
$response = $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin/_test/workspace-context');
// Should redirect to no-access or chooser since user has no memberships.
$response->assertRedirect();
});
// --- T024: it_redirects_to_chooser_when_last_workspace_membership_revoked_and_shows_warning ---
it('redirects to chooser when last workspace membership revoked', function (): void {
$user = User::factory()->create();
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create();
$workspaceC = Workspace::factory()->create();
// User is member of A and C but NOT B. last_workspace_id points to B.
WorkspaceMembership::factory()->create([
'workspace_id' => $workspaceA->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
WorkspaceMembership::factory()->create([
'workspace_id' => $workspaceC->getKey(),
'user_id' => $user->getKey(),
'role' => 'operator',
]);
$user->forceFill(['last_workspace_id' => (int) $workspaceB->getKey()])->save();
$response = $this->actingAs($user)->get('/admin/_test/workspace-context');
// last_workspace_id should be cleared.
$user->refresh();
expect($user->last_workspace_id)->toBeNull();
// Should redirect to chooser since user has 2 valid workspaces and last_workspace was invalid.
$response->assertRedirect('/admin/choose-workspace');
});
it('redirects to chooser when last workspace is archived', function (): void {
$user = User::factory()->create();
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create(['archived_at' => now()]);
WorkspaceMembership::factory()->create([
'workspace_id' => $workspaceA->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
WorkspaceMembership::factory()->create([
'workspace_id' => $workspaceB->getKey(),
'user_id' => $user->getKey(),
'role' => 'operator',
]);
$user->forceFill(['last_workspace_id' => (int) $workspaceB->getKey()])->save();
$response = $this->actingAs($user)->get('/admin/_test/workspace-context');
// Step 5 auto-resumes to workspaceA (only selectable). setCurrentWorkspace updates last_workspace_id.
$user->refresh();
expect($user->last_workspace_id)->toBe((int) $workspaceA->getKey());
// Only workspaceA is selectable → single membership auto-resume.
$response->assertRedirect();
$location = $response->headers->get('Location');
expect($location)->not->toContain('choose-workspace');
});
// --- T025: it_handles_archived_workspace_in_session ---
it('handles archived workspace in session', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['archived_at' => now()]);
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$response = $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin/_test/workspace-context');
// Session with archived workspace should be treated as stale.
$response->assertRedirect();
$location = $response->headers->get('Location');
// Should redirect to chooser or no-access.
expect($location)->toMatch('/choose-workspace|no-access/');
});
// --- T030: it_forces_chooser_with_choose_param ---
it('forces chooser with choose param', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$response = $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin/_test/workspace-context?choose=1');
$response->assertRedirect('/admin/choose-workspace');
});
it('forces chooser with choose param even when single workspace', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
// No session set — normally would auto-resume, but ?choose=1 forces chooser.
$response = $this->actingAs($user)->get('/admin/_test/workspace-context?choose=1');
$response->assertRedirect('/admin/choose-workspace');
});

View File

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Http::preventStrayRequests();
});
it('serves the managed-tenants page without Livewire update failures', function (): void {
$user = User::factory()->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();
});

View File

@ -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());
});

View File

@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
use App\Models\AuditLog;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Audit\AuditActionId;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
// --- T034: Comprehensive audit payload verification across all four scenarios ---
it('records workspace.auto_selected audit with single_membership reason', 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');
$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');
});

View File

@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\ChooseWorkspace;
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceRedirectResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->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);
});

View File

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Http::preventStrayRequests();
});
// --- T032: it_shows_switch_workspace_menu_when_multiple_workspaces ---
it('shows switch workspace in user menu when multiple workspaces', function (): void {
$user = User::factory()->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);
});