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:
parent
8bee824966
commit
e15eee8f26
5
.github/agents/copilot-instructions.md
vendored
5
.github/agents/copilot-instructions.md
vendored
@ -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 -->
|
||||
|
||||
@ -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>
|
||||
*/
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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')),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
68
app/Support/Workspaces/WorkspaceRedirectResolver.php
Normal file
68
app/Support/Workspaces/WorkspaceRedirectResolver.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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">
|
||||
· {{ $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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
· {{ $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>
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
47
specs/107-workspace-chooser/checklists/requirements.md
Normal file
47
specs/107-workspace-chooser/checklists/requirements.md
Normal 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`.
|
||||
126
specs/107-workspace-chooser/contracts/routes.md
Normal file
126
specs/107-workspace-chooser/contracts/routes.md
Normal 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()`
|
||||
142
specs/107-workspace-chooser/data-model.md
Normal file
142
specs/107-workspace-chooser/data-model.md
Normal 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.
|
||||
113
specs/107-workspace-chooser/plan.md
Normal file
113
specs/107-workspace-chooser/plan.md
Normal 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 |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| — | — | — |
|
||||
87
specs/107-workspace-chooser/quickstart.md
Normal file
87
specs/107-workspace-chooser/quickstart.md
Normal 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
|
||||
```
|
||||
106
specs/107-workspace-chooser/research.md
Normal file
106
specs/107-workspace-chooser/research.md
Normal 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).
|
||||
311
specs/107-workspace-chooser/spec.md
Normal file
311
specs/107-workspace-chooser/spec.md
Normal 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`)
|
||||
287
specs/107-workspace-chooser/tasks.md
Normal file
287
specs/107-workspace-chooser/tasks.md
Normal 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 (US1–US6)
|
||||
- Exact file paths included in all descriptions
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
**Purpose**: No project initialization needed — existing Laravel monolith with Filament v5.
|
||||
|
||||
_(No tasks — project structure, dependencies, and all target directories already exist.)_
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
|
||||
|
||||
- [X] T001 [P] Add `WorkspaceAutoSelected` and `WorkspaceSelected` enum cases to `app/Support/Audit/AuditActionId.php`
|
||||
- [X] T002 [P] Create `WorkspaceRedirectResolver` service with tenant-count branching logic (0→managed tenants, 1→tenant dashboard, >1→choose tenant) in `app/Support/Workspaces/WorkspaceRedirectResolver.php`
|
||||
- [X] T003 Write tests for `WorkspaceRedirectResolver` covering 0/1/>1 tenant branching in `tests/Feature/Workspaces/WorkspaceRedirectResolverTest.php`
|
||||
|
||||
**Checkpoint**: Foundation ready — AuditActionId enum extended, tenant-count branching deduplicated into resolver. User story implementation can now begin.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Auto-Resume: Single Workspace (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: A user with exactly one workspace membership is taken directly to their workspace dashboard without seeing the chooser.
|
||||
|
||||
**Independent Test**: Create a user with one workspace membership, hit `/admin`, verify redirect to workspace dashboard without chooser.
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T004 [US1] Refactor `EnsureWorkspaceSelected` middleware: implement step 1 (workspace-optional bypass), step 2 (`?choose=1` redirect), step 3 (basic session validation — allow if valid membership), step 4 (load selectable memberships), step 5 (single membership auto-resume with audit via `WorkspaceAuditLogger`), step 7 (fallback redirect to chooser) in `app/Http/Middleware/EnsureWorkspaceSelected.php`
|
||||
- [X] T005 [US1] Write test `it_skips_chooser_when_single_workspace_membership` — verify direct redirect to workspace dashboard in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php`
|
||||
- [X] T006 [US1] Write test `it_emits_audit_event_on_auto_selection_single_membership` — verify `workspace.auto_selected` audit log with reason `single_membership` in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php`
|
||||
- [X] T007 [US1] Write test `it_redirects_via_tenant_count_branching_after_single_auto_resume` — verify 0/1/>1 tenant routing after auto-resume in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php`
|
||||
- [X] T008 [US1] Write test `it_allows_request_when_session_workspace_is_valid` — verify middleware passes through when session has valid membership in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php`
|
||||
|
||||
**Checkpoint**: Single-workspace users bypass the chooser entirely. Audit event emitted. Middleware skeleton (7 steps) in place with steps 1–5, 7 active.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Auto-Resume: Last Used Workspace (Priority: P1)
|
||||
|
||||
**Goal**: A user with multiple workspaces who has a valid `last_workspace_id` is taken directly to that workspace without the chooser.
|
||||
|
||||
**Independent Test**: Create a user with 2+ workspaces and a valid `last_workspace_id`, hit `/admin`, verify direct entry.
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T009 [US2] Add step 6 to `EnsureWorkspaceSelected` middleware: `last_workspace_id` auto-resume with membership validation and audit logging in `app/Http/Middleware/EnsureWorkspaceSelected.php`
|
||||
- [X] T010 [US2] Write test `it_auto_resumes_to_last_used_workspace_when_membership_valid` — verify direct redirect via last_workspace_id in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php`
|
||||
- [X] T011 [US2] Write test `it_emits_audit_event_on_auto_selection_last_used` — verify `workspace.auto_selected` audit log with reason `last_used` in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php`
|
||||
- [X] T012 [US2] Write test `it_falls_back_to_chooser_when_multiple_workspaces_and_no_last_used` — verify redirect to chooser when no default in `tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php`
|
||||
|
||||
**Checkpoint**: Multi-workspace users with a valid last-used preference bypass the chooser. Both P1 auto-resume paths (single + last-used) are functional.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Chooser Fallback: Multiple Workspaces, No Default (Priority: P1)
|
||||
|
||||
**Goal**: A user with multiple workspaces and no valid `last_workspace_id` sees the chooser with enterprise metadata (name, role badge, tenant count).
|
||||
|
||||
**Independent Test**: Create a user with 3 workspaces (varying roles, tenant counts), clear `last_workspace_id`, visit `/admin`, verify chooser renders with metadata.
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T013 [US3] Refactor `ChooseWorkspace::getWorkspaces()` to add `withCount('tenants')` and load membership roles keyed by workspace_id; expose `$this->workspaceRoles` for Blade in `app/Filament/Pages/ChooseWorkspace.php`
|
||||
- [X] T014 [US3] Remove "Create workspace" header action from `ChooseWorkspace` page (FR-006) in `app/Filament/Pages/ChooseWorkspace.php`
|
||||
- [X] T015 [US3] Update Blade template: add role badge (`<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 3–7 (all audit-emitting code must exist)
|
||||
- **Polish (Phase 9)**: Depends on all user stories being complete
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)**: After Foundational → provides middleware skeleton for US2 + US4
|
||||
- **US2 (P1)**: After US1 → extends middleware with step 6
|
||||
- **US3 (P1)**: After Foundational → independent from US1/US2 (different files)
|
||||
- **US4 (P2)**: After US1 → enhances middleware error paths
|
||||
- **US5 (P2)**: After US3 (chooser page) + US1 (middleware ?choose=1)
|
||||
- **US6 (P2)**: After US1–US5 → verifies audit payloads across all paths
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Implementation before tests (refactoring existing code — not greenfield TDD)
|
||||
- Core changes before edge-case handling
|
||||
- Story complete before moving to next priority
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- **Phase 2**: T001 and T002 can run in parallel (different files)
|
||||
- **After Phase 2**: US3 (chooser page) can start in parallel with US1 (middleware)
|
||||
- **After Phase 3**: US2 and US4 can start in parallel (US4 enhances middleware error paths, US2 adds step 6)
|
||||
- **Tests within same file**: Sequential (same file), but different test files can run in parallel
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: After Foundational
|
||||
|
||||
```
|
||||
# Developer A: Middleware track (US1 → US2 → US4)
|
||||
T004 Refactor EnsureWorkspaceSelected (US1)
|
||||
T009 Add step 6 to middleware (US2)
|
||||
T021–T022 Enhance error paths (US4)
|
||||
|
||||
# Developer B: Chooser page track (US3 → US5)
|
||||
T013–T015 Upgrade ChooseWorkspace page + Blade (US3)
|
||||
T026–T029 Add audit + wire:click + user menu (US5)
|
||||
|
||||
# Both tracks converge at:
|
||||
T034–T035 Audit trail verification (US6)
|
||||
T036–T040 Polish & cross-cutting
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (US1 Only)
|
||||
|
||||
1. Complete Phase 2: Foundational (T001–T003)
|
||||
2. Complete Phase 3: US1 — Single workspace auto-resume (T004–T008)
|
||||
3. **STOP and VALIDATE**: Single-workspace users bypass chooser, audit logged
|
||||
4. Deploy/demo if ready → immediate UX improvement for majority of users
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Foundation (T001–T003) → Core infrastructure ready
|
||||
2. US1 (T004–T008) → Single workspace auto-resume → **MVP!**
|
||||
3. US2 (T009–T012) → Last-used auto-resume → Multi-workspace friction reduced
|
||||
4. US3 (T013–T020) → Enterprise metadata in chooser → Better selection UX
|
||||
5. US4 (T021–T025) → Stale session handling → Governance safety net
|
||||
6. US5 (T026–T033) → Manual switch via user menu → Full switch flow
|
||||
7. US6 (T034–T035) → Audit verification → Compliance confidence
|
||||
8. Polish (T036–T040) → DRY codebase, full suite green
|
||||
|
||||
Each story adds value without breaking previous stories.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| **Total tasks** | 40 |
|
||||
| **Phase 2 (Foundational)** | 3 tasks |
|
||||
| **US1 (Auto-resume single)** | 5 tasks |
|
||||
| **US2 (Auto-resume last-used)** | 4 tasks |
|
||||
| **US3 (Chooser metadata)** | 8 tasks |
|
||||
| **US4 (Stale session)** | 5 tasks |
|
||||
| **US5 (Manual switch)** | 8 tasks |
|
||||
| **US6 (Audit verification)** | 2 tasks |
|
||||
| **Polish** | 5 tasks |
|
||||
| **Parallel opportunities** | 2 independent tracks (middleware + chooser page) after foundation |
|
||||
| **MVP scope** | Foundation + US1 (8 tasks) |
|
||||
| **New files** | 6 (1 service + 5 test files) |
|
||||
| **Modified files** | 6 (middleware, page, blade, enum, provider, controller + routes) |
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- All tasks reference exact file paths from plan.md project structure
|
||||
- Audit logging uses direct `WorkspaceAuditLogger::log()` calls (decision R2)
|
||||
- Middleware is refactored in-place (decision R1) — alias `ensure-workspace-selected` unchanged
|
||||
- Chooser migrates to `wire:click` (decision R9) — `SwitchWorkspaceController` retained for context-bar
|
||||
- Flash warnings use Filament `Notification::make()->danger()` (decision R10)
|
||||
- Role badge uses simple color mapping, exempt from BadgeCatalog (decision R7)
|
||||
- `WorkspaceRedirectResolver` deduplicates 4 copies of tenant-count branching (decision R4)
|
||||
@ -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 {
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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'));
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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 () {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
297
tests/Feature/Workspaces/ChooseWorkspacePageTest.php
Normal file
297
tests/Feature/Workspaces/ChooseWorkspacePageTest.php
Normal 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);
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
@ -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());
|
||||
});
|
||||
|
||||
211
tests/Feature/Workspaces/WorkspaceAuditTrailTest.php
Normal file
211
tests/Feature/Workspaces/WorkspaceAuditTrailTest.php
Normal 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');
|
||||
});
|
||||
121
tests/Feature/Workspaces/WorkspaceRedirectResolverTest.php
Normal file
121
tests/Feature/Workspaces/WorkspaceRedirectResolverTest.php
Normal 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);
|
||||
});
|
||||
73
tests/Feature/Workspaces/WorkspaceSwitchUserMenuTest.php
Normal file
73
tests/Feature/Workspaces/WorkspaceSwitchUserMenuTest.php
Normal 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);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user