TenantAtlas/app/Filament/Pages/ChooseWorkspace.php
Ahmed Darrazi 051db1842d feat(107): implement Workspace Chooser v1 — all 40 tasks complete
Spec 107: Workspace Chooser v1 (Enterprise) + In-App Switch Entry Point

## Core changes
- Refactor EnsureWorkspaceSelected middleware: 7-step algorithm with
  auto-resume (single membership + last_workspace_id), stale session
  detection, ?choose=1 forced chooser, workspace-optional path bypass
- Create WorkspaceRedirectResolver for DRY tenant-count branching
  (0→managed-tenants, 1→tenant-dashboard, >1→choose-tenant)
- Add WorkspaceAutoSelected + WorkspaceSelected audit enum cases
- Rewrite ChooseWorkspace page: role badges, tenant counts,
  wire:click selection, audit logging, WorkspaceRedirectResolver
- Add 'Switch workspace' user menu item in AdminPanelProvider
- Rewrite SwitchWorkspaceController with audit + resolver
- Replace inline tenant branching in routes/web.php with resolver

## New test files (6)
- WorkspaceRedirectResolverTest (5 tests
Spec 107: Workspace Chooser v1 (Enterprise) + In-App Switch Entry Point

## Core changes
- Refactor EnsureWorkspaceSelected middleware: 7-step algorithmst
## Core changes
- Refactor EnsureWorkspaceSelected middleware: 7-stepes - Refactor Ensng  auto-resume (single membership + last_workspace_id), stale sessioid  detection, ?choose=1 forced chooser, w (security invariant preserve- Create WorkspaceRedirectResolver for DRY tenant-count branching

  (0→managed-tenants, 1→tenant-dashboapped (8163 assertions)
2026-02-22 17:19:19 +01:00

179 lines
5.0 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Pages;
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 Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Database\Eloquent\Collection;
class ChooseWorkspace extends Page
{
protected static string $layout = 'filament-panels::components.layout.simple';
protected static bool $shouldRegisterNavigation = false;
protected static bool $isDiscovered = false;
protected static ?string $slug = 'choose-workspace';
protected static ?string $title = 'Choose workspace';
protected string $view = 'filament.pages.choose-workspace';
/**
* Workspace roles keyed by workspace_id.
*
* @var array<int, string>
*/
public array $workspaceRoles = [];
/**
* @return array<\Filament\Actions\Action>
*/
protected function getHeaderActions(): array
{
return [];
}
/**
* @return Collection<int, Workspace>
*/
public function getWorkspaces(): Collection
{
$user = auth()->user();
if (! $user instanceof User) {
return Workspace::query()->whereRaw('1 = 0')->get();
}
$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
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
abort(404);
}
if (! empty($workspace->archived_at)) {
abort(404);
}
$context = app(WorkspaceContext::class);
if (! $context->isMember($user, $workspace)) {
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());
/** @var WorkspaceRedirectResolver $resolver */
$resolver = app(WorkspaceRedirectResolver::class);
$this->redirect($intendedUrl ?: $resolver->resolve($workspace, $user));
}
/**
* @param array{name: string, slug?: string|null} $data
*/
public function createWorkspace(array $data): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $user->can('create', Workspace::class)) {
abort(403);
}
$workspace = Workspace::query()->create([
'name' => $data['name'],
'slug' => $data['slug'] ?? null,
]);
WorkspaceMembership::query()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
app(WorkspaceContext::class)->setCurrentWorkspace($workspace, $user, request());
Notification::make()
->title('Workspace created')
->success()
->send();
$intendedUrl = WorkspaceIntendedUrl::consume(request());
/** @var WorkspaceRedirectResolver $resolver */
$resolver = app(WorkspaceRedirectResolver::class);
$this->redirect($intendedUrl ?: $resolver->resolve($workspace, $user));
}
}