From 35e14c10753b2f64d6d53d01b3930d1b2c88e981 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 2 Feb 2026 16:52:32 +0100 Subject: [PATCH] feat: enforce workspace context + last-owner safeguards --- app/Console/Commands/SyncPolicies.php | 2 +- .../TenantpilotPurgeNonPersistentData.php | 2 +- app/Filament/Pages/ChooseTenant.php | 6 + app/Filament/Pages/ChooseWorkspace.php | 31 ++- app/Filament/Pages/Tenancy/RegisterTenant.php | 22 ++ .../Resources/BackupScheduleResource.php | 4 +- .../BackupScheduleRunsRelationManager.php | 2 +- app/Filament/Resources/PolicyResource.php | 2 +- .../Resources/PolicyVersionResource.php | 2 +- app/Filament/Resources/RestoreRunResource.php | 4 +- app/Filament/Resources/TenantResource.php | 25 ++- .../TenantResource/Pages/CreateTenant.php | 16 ++ .../Workspaces/Pages/ViewWorkspace.php | 19 ++ .../WorkspaceMembershipsRelationManager.php | 204 ++++++++++++++++++ .../Workspaces/WorkspaceResource.php | 17 +- .../System/Pages/RepairWorkspaceOwners.php | 169 +++++++++++++++ .../Controllers/SelectTenantController.php | 72 +++++++ .../Controllers/SwitchWorkspaceController.php | 61 ++++++ .../Middleware/EnsureWorkspaceSelected.php | 4 + app/Livewire/BackupSetPolicyPickerTable.php | 2 +- app/Models/Tenant.php | 9 +- app/Providers/AuthServiceProvider.php | 6 +- app/Providers/Filament/AdminPanelProvider.php | 14 ++ app/Services/Audit/WorkspaceAuditLogger.php | 9 +- .../Auth/WorkspaceMembershipManager.php | 181 +++++++++------- app/Support/Audit/AuditActionId.php | 6 + .../EnsureFilamentTenantSelected.php | 83 +++++-- app/Support/Workspaces/WorkspaceContext.php | 4 - .../filament/pages/choose-tenant.blade.php | 49 ++++- .../filament/pages/choose-workspace.blade.php | 44 +++- .../partials/workspace-switcher.blade.php | 47 ++++ .../pages/repair-workspace-owners.blade.php | 13 ++ routes/web.php | 34 +++ .../plan.md | 2 + .../spec.md | 1 + .../tasks.md | 16 ++ .../BreakGlassWorkspaceOwnerRecoveryTest.php | 89 ++++++++ .../Auth/WorkspaceLastOwnerGuardTest.php | 96 +++++++++ ...oChooseTenantWhenWorkspaceSelectedTest.php | 29 +++ ...tyStateRegisterTenantCtaVisibilityTest.php | 48 +++++ ...rkspaceShowsLastUsedRecommendationTest.php | 45 ++++ ...oseWorkspaceWhenMultipleWorkspacesTest.php | 40 ++++ .../SelectTenantPostPersistsLastUsedTest.php | 65 ++++++ ...nantResourceIndexIsWorkspaceScopedTest.php | 73 +++++++ ...aceContextTopbarAndTenantSelectionTest.php | 72 +++++++ ...seWorkspaceRedirectsToChooseTenantTest.php | 34 ++- ...sToTenantRegistrationWhenNoTenantsTest.php | 30 +++ .../WorkspacesResourceIsTenantlessTest.php | 75 +++++++ 48 files changed, 1753 insertions(+), 127 deletions(-) create mode 100644 app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php create mode 100644 app/Filament/Resources/Workspaces/RelationManagers/WorkspaceMembershipsRelationManager.php create mode 100644 app/Filament/System/Pages/RepairWorkspaceOwners.php create mode 100644 app/Http/Controllers/SelectTenantController.php create mode 100644 app/Http/Controllers/SwitchWorkspaceController.php create mode 100644 resources/views/filament/partials/workspace-switcher.blade.php create mode 100644 resources/views/filament/system/pages/repair-workspace-owners.blade.php create mode 100644 tests/Feature/Auth/BreakGlassWorkspaceOwnerRecoveryTest.php create mode 100644 tests/Feature/Auth/WorkspaceLastOwnerGuardTest.php create mode 100644 tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php create mode 100644 tests/Feature/Filament/ChooseTenantEmptyStateRegisterTenantCtaVisibilityTest.php create mode 100644 tests/Feature/Filament/ChooseWorkspaceShowsLastUsedRecommendationTest.php create mode 100644 tests/Feature/Filament/LoginRedirectsToChooseWorkspaceWhenMultipleWorkspacesTest.php create mode 100644 tests/Feature/Filament/SelectTenantPostPersistsLastUsedTest.php create mode 100644 tests/Feature/Filament/TenantResourceIndexIsWorkspaceScopedTest.php create mode 100644 tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php create mode 100644 tests/Feature/Workspaces/SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php create mode 100644 tests/Feature/Workspaces/WorkspacesResourceIsTenantlessTest.php diff --git a/app/Console/Commands/SyncPolicies.php b/app/Console/Commands/SyncPolicies.php index 8546b78..9a34d81 100644 --- a/app/Console/Commands/SyncPolicies.php +++ b/app/Console/Commands/SyncPolicies.php @@ -34,6 +34,6 @@ private function resolveTenant(): Tenant ->firstOrFail(); } - return Tenant::current(); + return Tenant::currentOrFail(); } } diff --git a/app/Console/Commands/TenantpilotPurgeNonPersistentData.php b/app/Console/Commands/TenantpilotPurgeNonPersistentData.php index ba153a1..21c9ff2 100644 --- a/app/Console/Commands/TenantpilotPurgeNonPersistentData.php +++ b/app/Console/Commands/TenantpilotPurgeNonPersistentData.php @@ -138,7 +138,7 @@ private function resolveTenants() } try { - return collect([Tenant::current()]); + return collect([Tenant::currentOrFail()]); } catch (RuntimeException) { return collect(); } diff --git a/app/Filament/Pages/ChooseTenant.php b/app/Filament/Pages/ChooseTenant.php index 31e8181..bead16d 100644 --- a/app/Filament/Pages/ChooseTenant.php +++ b/app/Filament/Pages/ChooseTenant.php @@ -4,6 +4,7 @@ namespace App\Filament\Pages; +use App\Filament\Pages\Tenancy\RegisterTenant as RegisterTenantPage; use App\Models\Tenant; use App\Models\User; use App\Models\UserTenantPreference; @@ -72,6 +73,11 @@ public function selectTenant(int $tenantId): void $this->redirect(TenantDashboard::getUrl(tenant: $tenant)); } + public function canRegisterTenant(): bool + { + return RegisterTenantPage::canView(); + } + private function persistLastTenant(User $user, Tenant $tenant): void { if (Schema::hasColumn('users', 'last_tenant_id')) { diff --git a/app/Filament/Pages/ChooseWorkspace.php b/app/Filament/Pages/ChooseWorkspace.php index 10bd3d0..8de5ab3 100644 --- a/app/Filament/Pages/ChooseWorkspace.php +++ b/app/Filament/Pages/ChooseWorkspace.php @@ -9,6 +9,7 @@ use App\Models\WorkspaceMembership; use App\Support\Workspaces\WorkspaceContext; use Filament\Actions\Action; +use Filament\Facades\Filament; use Filament\Forms\Components\TextInput; use Filament\Notifications\Notification; use Filament\Pages\Page; @@ -100,7 +101,7 @@ public function selectWorkspace(int $workspaceId): void $context->setCurrentWorkspace($workspace, $user, request()); - $this->redirect(ChooseTenant::getUrl()); + $this->redirect($this->redirectAfterWorkspaceSelected($user)); } /** @@ -132,6 +133,32 @@ public function createWorkspace(array $data): void ->success() ->send(); - $this->redirect(ChooseTenant::getUrl()); + $this->redirect($this->redirectAfterWorkspaceSelected($user)); + } + + private function redirectAfterWorkspaceSelected(User $user): string + { + $tenants = $user->getTenants(Filament::getCurrentOrDefaultPanel()); + + $tenants = $tenants instanceof Collection ? $tenants : collect($tenants); + + if ($tenants->isEmpty()) { + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(); + + if ($workspaceId !== null) { + $role = WorkspaceMembership::query() + ->where('workspace_id', $workspaceId) + ->where('user_id', $user->getKey()) + ->value('role'); + + if (in_array($role, ['owner', 'manager'], true)) { + return route('filament.admin.tenant.registration'); + } + } + + return ChooseTenant::getUrl(); + } + + return ChooseTenant::getUrl(); } } diff --git a/app/Filament/Pages/Tenancy/RegisterTenant.php b/app/Filament/Pages/Tenancy/RegisterTenant.php index 5940f23..4396496 100644 --- a/app/Filament/Pages/Tenancy/RegisterTenant.php +++ b/app/Filament/Pages/Tenancy/RegisterTenant.php @@ -4,9 +4,11 @@ use App\Models\Tenant; use App\Models\User; +use App\Models\WorkspaceMembership; use App\Services\Auth\CapabilityResolver; use App\Services\Intune\AuditLogger; use App\Support\Auth\Capabilities; +use App\Support\Workspaces\WorkspaceContext; use Filament\Forms; use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant; use Filament\Schemas\Schema; @@ -27,6 +29,20 @@ public static function canView(): bool return false; } + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(); + + if ($workspaceId !== null) { + $canRegisterInWorkspace = WorkspaceMembership::query() + ->where('workspace_id', $workspaceId) + ->where('user_id', $user->getKey()) + ->whereIn('role', ['owner', 'manager']) + ->exists(); + + if ($canRegisterInWorkspace) { + return true; + } + } + $tenantIds = $user->tenants()->withTrashed()->pluck('tenants.id'); if ($tenantIds->isEmpty()) { @@ -95,6 +111,12 @@ protected function handleRegistration(array $data): Model abort(403); } + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(); + + if ($workspaceId !== null) { + $data['workspace_id'] = $workspaceId; + } + $tenant = Tenant::create($data); $user = auth()->user(); diff --git a/app/Filament/Resources/BackupScheduleResource.php b/app/Filament/Resources/BackupScheduleResource.php index 6ce9c03..cd05f7e 100644 --- a/app/Filament/Resources/BackupScheduleResource.php +++ b/app/Filament/Resources/BackupScheduleResource.php @@ -938,7 +938,7 @@ public static function table(Table $table): Table public static function getEloquentQuery(): Builder { - $tenantId = Tenant::current()->getKey(); + $tenantId = Tenant::currentOrFail()->getKey(); return parent::getEloquentQuery() ->where('tenant_id', $tenantId) @@ -1054,7 +1054,7 @@ public static function ensurePolicyTypes(array $data): array public static function assignTenant(array $data): array { - $data['tenant_id'] = Tenant::current()->getKey(); + $data['tenant_id'] = Tenant::currentOrFail()->getKey(); return $data; } diff --git a/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php b/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php index d0b1e99..c615989 100644 --- a/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php +++ b/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php @@ -21,7 +21,7 @@ class BackupScheduleRunsRelationManager extends RelationManager public function table(Table $table): Table { return $table - ->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::current()->getKey())->with('backupSet')) + ->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey())->with('backupSet')) ->defaultSort('scheduled_for', 'desc') ->columns([ Tables\Columns\TextColumn::make('scheduled_for') diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index f2260eb..24b5480 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -894,7 +894,7 @@ public static function table(Table $table): Table public static function getEloquentQuery(): Builder { - $tenantId = Tenant::current()->getKey(); + $tenantId = Tenant::currentOrFail()->getKey(); return parent::getEloquentQuery() ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)) diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php index bb703c6..399d647 100644 --- a/app/Filament/Resources/PolicyVersionResource.php +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -815,7 +815,7 @@ public static function table(Table $table): Table public static function getEloquentQuery(): Builder { - $tenantId = Tenant::current()->getKey(); + $tenantId = Tenant::currentOrFail()->getKey(); return parent::getEloquentQuery() ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)) diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index 34f6bc7..f0ee904 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -87,7 +87,7 @@ public static function form(Schema $schema): Schema Forms\Components\Select::make('backup_set_id') ->label('Backup set') ->options(function () { - $tenantId = Tenant::current()->getKey(); + $tenantId = Tenant::currentOrFail()->getKey(); return BackupSet::query() ->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId)) @@ -219,7 +219,7 @@ public static function getWizardSteps(): array Forms\Components\Select::make('backup_set_id') ->label('Backup set') ->options(function () { - $tenantId = Tenant::current()->getKey(); + $tenantId = Tenant::currentOrFail()->getKey(); return BackupSet::query() ->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId)) diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index d52f5a6..386b02a 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -9,6 +9,7 @@ use App\Jobs\SyncPoliciesJob; 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; @@ -30,6 +31,7 @@ use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\Rbac\UiEnforcement; +use App\Support\Workspaces\WorkspaceContext; use BackedEnum; use Filament\Actions; use Filament\Actions\ActionGroup; @@ -70,7 +72,21 @@ public static function canCreate(): bool return false; } - return static::userCanManageAnyTenant($user); + 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(); } public static function canEdit(Model $record): bool @@ -179,8 +195,15 @@ public static function getEloquentQuery(): Builder return parent::getEloquentQuery()->whereRaw('1 = 0'); } + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if ($workspaceId === null) { + return parent::getEloquentQuery()->whereRaw('1 = 0'); + } + $tenantIds = $user->tenants() ->withTrashed() + ->where('workspace_id', $workspaceId) ->pluck('tenants.id'); return parent::getEloquentQuery() diff --git a/app/Filament/Resources/TenantResource/Pages/CreateTenant.php b/app/Filament/Resources/TenantResource/Pages/CreateTenant.php index 6d3bd89..c5fec3e 100644 --- a/app/Filament/Resources/TenantResource/Pages/CreateTenant.php +++ b/app/Filament/Resources/TenantResource/Pages/CreateTenant.php @@ -4,12 +4,28 @@ 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 $data + * @return array + */ + protected function mutateFormDataBeforeCreate(array $data): array + { + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(); + + if ($workspaceId !== null) { + $data['workspace_id'] = $workspaceId; + } + + return $data; + } + protected function afterCreate(): void { $user = auth()->user(); diff --git a/app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php b/app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php new file mode 100644 index 0000000..aa29f70 --- /dev/null +++ b/app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php @@ -0,0 +1,19 @@ +modifyQueryUsing(fn (Builder $query) => $query->with('user')) + ->columns([ + Tables\Columns\TextColumn::make('user.name') + ->label(__('User')) + ->searchable(), + Tables\Columns\TextColumn::make('user.email') + ->label(__('Email')) + ->toggleable(isToggledHiddenByDefault: true), + Tables\Columns\TextColumn::make('role') + ->badge() + ->sortable(), + Tables\Columns\TextColumn::make('created_at')->since(), + ]) + ->headerActions([ + UiEnforcement::forTableAction( + Action::make('add_member') + ->label(__('Add member')) + ->icon('heroicon-o-plus') + ->form([ + Forms\Components\Select::make('user_id') + ->label(__('User')) + ->required() + ->searchable() + ->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()), + Forms\Components\Select::make('role') + ->label(__('Role')) + ->required() + ->options([ + WorkspaceRole::Owner->value => __('Owner'), + WorkspaceRole::Manager->value => __('Manager'), + WorkspaceRole::Operator->value => __('Operator'), + WorkspaceRole::Readonly->value => __('Readonly'), + ]), + ]) + ->action(function (array $data, WorkspaceMembershipManager $manager): void { + $workspace = $this->getOwnerRecord(); + + if (! $workspace instanceof Workspace) { + abort(404); + } + + $actor = auth()->user(); + if (! $actor instanceof User) { + abort(403); + } + + $member = User::query()->find((int) $data['user_id']); + if (! $member) { + Notification::make()->title(__('User not found'))->danger()->send(); + + return; + } + + try { + $manager->addMember( + workspace: $workspace, + actor: $actor, + member: $member, + role: (string) $data['role'], + source: 'manual', + ); + } catch (\Throwable $throwable) { + Notification::make() + ->title(__('Failed to add member')) + ->body($throwable->getMessage()) + ->danger() + ->send(); + + return; + } + + Notification::make()->title(__('Member added'))->success()->send(); + $this->resetTable(); + }), + fn () => $this->getOwnerRecord(), + ) + ->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE) + ->tooltip('You do not have permission to manage workspace memberships.') + ->apply(), + ]) + ->actions([ + UiEnforcement::forTableAction( + Action::make('change_role') + ->label(__('Change role')) + ->icon('heroicon-o-pencil') + ->requiresConfirmation() + ->form([ + Forms\Components\Select::make('role') + ->label(__('Role')) + ->required() + ->options([ + WorkspaceRole::Owner->value => __('Owner'), + WorkspaceRole::Manager->value => __('Manager'), + WorkspaceRole::Operator->value => __('Operator'), + WorkspaceRole::Readonly->value => __('Readonly'), + ]), + ]) + ->action(function (WorkspaceMembership $record, array $data, WorkspaceMembershipManager $manager): void { + $workspace = $this->getOwnerRecord(); + + if (! $workspace instanceof Workspace) { + abort(404); + } + + $actor = auth()->user(); + if (! $actor instanceof User) { + abort(403); + } + + try { + $manager->changeRole( + workspace: $workspace, + actor: $actor, + membership: $record, + newRole: (string) $data['role'], + ); + } catch (\Throwable $throwable) { + Notification::make() + ->title(__('Failed to change role')) + ->body($throwable->getMessage()) + ->danger() + ->send(); + + return; + } + + Notification::make()->title(__('Role updated'))->success()->send(); + $this->resetTable(); + }), + fn () => $this->getOwnerRecord(), + ) + ->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE) + ->tooltip('You do not have permission to manage workspace memberships.') + ->apply(), + + UiEnforcement::forTableAction( + Action::make('remove') + ->label(__('Remove')) + ->color('danger') + ->icon('heroicon-o-x-mark') + ->requiresConfirmation() + ->action(function (WorkspaceMembership $record, WorkspaceMembershipManager $manager): void { + $workspace = $this->getOwnerRecord(); + + if (! $workspace instanceof Workspace) { + abort(404); + } + + $actor = auth()->user(); + if (! $actor instanceof User) { + abort(403); + } + + try { + $manager->removeMember(workspace: $workspace, actor: $actor, membership: $record); + } catch (\Throwable $throwable) { + Notification::make() + ->title(__('Failed to remove member')) + ->body($throwable->getMessage()) + ->danger() + ->send(); + + return; + } + + Notification::make()->title(__('Member removed'))->success()->send(); + $this->resetTable(); + }), + fn () => $this->getOwnerRecord(), + ) + ->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE) + ->tooltip('You do not have permission to manage workspace memberships.') + ->destructive() + ->apply(), + ]) + ->bulkActions([]); + } +} diff --git a/app/Filament/Resources/Workspaces/WorkspaceResource.php b/app/Filament/Resources/Workspaces/WorkspaceResource.php index c58ddde..7bd5576 100644 --- a/app/Filament/Resources/Workspaces/WorkspaceResource.php +++ b/app/Filament/Resources/Workspaces/WorkspaceResource.php @@ -2,8 +2,10 @@ namespace App\Filament\Resources\Workspaces; +use App\Filament\Resources\Workspaces\RelationManagers\WorkspaceMembershipsRelationManager; use App\Models\Workspace; use BackedEnum; +use Filament\Actions; use Filament\Forms; use Filament\Resources\Resource; use Filament\Schemas\Schema; @@ -13,10 +15,14 @@ class WorkspaceResource extends Resource { + protected static bool $isDiscovered = false; + protected static ?string $model = Workspace::class; protected static bool $isScopedToTenant = false; + protected static ?string $recordTitleAttribute = 'name'; + protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2'; protected static string|UnitEnum|null $navigationGroup = 'Settings'; @@ -47,7 +53,8 @@ public static function table(Table $table): Table ->sortable(), ]) ->actions([ - Tables\Actions\EditAction::make(), + Actions\ViewAction::make(), + Actions\EditAction::make(), ]); } @@ -56,7 +63,15 @@ public static function getPages(): array return [ 'index' => Pages\ListWorkspaces::route('/'), 'create' => Pages\CreateWorkspace::route('/create'), + 'view' => Pages\ViewWorkspace::route('/{record}'), 'edit' => Pages\EditWorkspace::route('/{record}/edit'), ]; } + + public static function getRelations(): array + { + return [ + WorkspaceMembershipsRelationManager::class, + ]; + } } diff --git a/app/Filament/System/Pages/RepairWorkspaceOwners.php b/app/Filament/System/Pages/RepairWorkspaceOwners.php new file mode 100644 index 0000000..72d41ae --- /dev/null +++ b/app/Filament/System/Pages/RepairWorkspaceOwners.php @@ -0,0 +1,169 @@ +user(); + + if (! $user instanceof PlatformUser) { + return false; + } + + return $user->hasCapability(PlatformCapabilities::USE_BREAK_GLASS); + } + + /** + * @return array + */ + protected function getHeaderActions(): array + { + $breakGlass = app(BreakGlassSession::class); + + return [ + Action::make('assign_owner') + ->label('Assign owner (break-glass)') + ->color('danger') + ->requiresConfirmation() + ->modalHeading('Assign workspace owner') + ->modalDescription('This is a recovery action. It is audited and should only be used when the workspace owner set is broken.') + ->form([ + Select::make('workspace_id') + ->label('Workspace') + ->required() + ->searchable() + ->getSearchResultsUsing(function (string $search): array { + return Workspace::query() + ->where('name', 'like', "%{$search}%") + ->orderBy('name') + ->limit(25) + ->pluck('name', 'id') + ->all(); + }) + ->getOptionLabelUsing(function ($value): ?string { + if (! is_numeric($value)) { + return null; + } + + return Workspace::query()->whereKey((int) $value)->value('name'); + }), + + Select::make('target_user_id') + ->label('User') + ->required() + ->searchable() + ->getSearchResultsUsing(function (string $search): array { + return User::query() + ->where('email', 'like', "%{$search}%") + ->orderBy('email') + ->limit(25) + ->pluck('email', 'id') + ->all(); + }) + ->getOptionLabelUsing(function ($value): ?string { + if (! is_numeric($value)) { + return null; + } + + return User::query()->whereKey((int) $value)->value('email'); + }), + + Textarea::make('reason') + ->label('Reason') + ->required() + ->minLength(5) + ->maxLength(500) + ->rows(4), + ]) + ->action(function (array $data, BreakGlassSession $breakGlass, WorkspaceAuditLogger $auditLogger): void { + $platformUser = auth('platform')->user(); + + if (! $platformUser instanceof PlatformUser) { + abort(403); + } + + if (! $platformUser->hasCapability(PlatformCapabilities::USE_BREAK_GLASS)) { + abort(403); + } + + if (! $breakGlass->isActive()) { + abort(403); + } + + $workspaceId = (int) ($data['workspace_id'] ?? 0); + $targetUserId = (int) ($data['target_user_id'] ?? 0); + $reason = (string) ($data['reason'] ?? ''); + + $workspace = Workspace::query()->whereKey($workspaceId)->firstOrFail(); + $targetUser = User::query()->whereKey($targetUserId)->firstOrFail(); + + $membership = WorkspaceMembership::query()->firstOrNew([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $targetUser->getKey(), + ]); + + $fromRole = $membership->exists ? (string) $membership->role : null; + + $membership->forceFill([ + 'role' => WorkspaceRole::Owner->value, + ])->save(); + + $auditLogger->log( + workspace: $workspace, + action: AuditActionId::WorkspaceMembershipBreakGlassAssignOwner->value, + context: [ + 'metadata' => [ + 'workspace_id' => (int) $workspace->getKey(), + 'actor_user_id' => (int) $platformUser->getKey(), + 'target_user_id' => (int) $targetUser->getKey(), + 'attempted_role' => WorkspaceRole::Owner->value, + 'from_role' => $fromRole, + 'reason' => trim($reason), + 'source' => 'break_glass', + ], + ], + actor: null, + status: 'success', + resourceType: 'workspace', + resourceId: (string) $workspace->getKey(), + actorId: (int) $platformUser->getKey(), + actorEmail: $platformUser->email, + actorName: $platformUser->name, + ); + + Notification::make() + ->title('Owner assigned') + ->success() + ->send(); + }) + ->disabled(fn (): bool => ! $breakGlass->isActive()), + ]; + } +} diff --git a/app/Http/Controllers/SelectTenantController.php b/app/Http/Controllers/SelectTenantController.php new file mode 100644 index 0000000..e95bb6f --- /dev/null +++ b/app/Http/Controllers/SelectTenantController.php @@ -0,0 +1,72 @@ +user(); + + if (! $user instanceof User) { + abort(403); + } + + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId($request); + + if ($workspaceId === null) { + return redirect()->to('/admin/choose-workspace'); + } + + $validated = $request->validate([ + 'tenant_id' => ['required', 'integer'], + ]); + + $tenant = Tenant::query() + ->where('status', 'active') + ->where('workspace_id', $workspaceId) + ->whereKey($validated['tenant_id']) + ->first(); + + if (! $tenant instanceof Tenant) { + abort(404); + } + + if (! $user->canAccessTenant($tenant)) { + abort(404); + } + + $this->persistLastTenant($user, $tenant); + + return redirect()->to(TenantDashboard::getUrl(tenant: $tenant)); + } + + private function persistLastTenant(User $user, Tenant $tenant): void + { + if (Schema::hasColumn('users', 'last_tenant_id')) { + $user->forceFill(['last_tenant_id' => $tenant->getKey()])->save(); + + return; + } + + if (! Schema::hasTable('user_tenant_preferences')) { + return; + } + + UserTenantPreference::query()->updateOrCreate( + ['user_id' => $user->getKey(), 'tenant_id' => $tenant->getKey()], + ['last_used_at' => now()] + ); + } +} diff --git a/app/Http/Controllers/SwitchWorkspaceController.php b/app/Http/Controllers/SwitchWorkspaceController.php new file mode 100644 index 0000000..79a1a0f --- /dev/null +++ b/app/Http/Controllers/SwitchWorkspaceController.php @@ -0,0 +1,61 @@ +user(); + + if (! $user instanceof User) { + abort(403); + } + + $validated = $request->validate([ + 'workspace_id' => ['required', 'integer'], + ]); + + $workspace = Workspace::query()->whereKey($validated['workspace_id'])->first(); + + if (! $workspace instanceof Workspace) { + abort(404); + } + + if (! empty($workspace->archived_at)) { + abort(404); + } + + $context = app(WorkspaceContext::class); + + if (! $context->isMember($user, $workspace)) { + abort(404); + } + + $context->setCurrentWorkspace($workspace, $user, $request); + + $tenants = $user->getTenants(Filament::getCurrentOrDefaultPanel()); + $tenants = $tenants instanceof \Illuminate\Database\Eloquent\Collection ? $tenants : collect($tenants); + + if ($tenants->isEmpty()) { + if (RegisterTenantPage::canView()) { + return redirect()->route('filament.admin.tenant.registration'); + } + + return redirect()->to(ChooseTenant::getUrl()); + } + + return redirect()->to(ChooseTenant::getUrl()); + } +} diff --git a/app/Http/Middleware/EnsureWorkspaceSelected.php b/app/Http/Middleware/EnsureWorkspaceSelected.php index b904ff7..8b1e618 100644 --- a/app/Http/Middleware/EnsureWorkspaceSelected.php +++ b/app/Http/Middleware/EnsureWorkspaceSelected.php @@ -32,6 +32,10 @@ public function handle(Request $request, Closure $next): Response return $next($request); } + if (str_starts_with($path, '/admin/workspaces')) { + return $next($request); + } + if (in_array($path, ['/admin/no-access', '/admin/choose-workspace'], true)) { return $next($request); } diff --git a/app/Livewire/BackupSetPolicyPickerTable.php b/app/Livewire/BackupSetPolicyPickerTable.php index 3882dfa..8ba8d6f 100644 --- a/app/Livewire/BackupSetPolicyPickerTable.php +++ b/app/Livewire/BackupSetPolicyPickerTable.php @@ -61,7 +61,7 @@ public static function externalIdShort(?string $externalId): string public function table(Table $table): Table { $backupSet = BackupSet::query()->find($this->backupSetId); - $tenantId = $backupSet?->tenant_id ?? Tenant::current()->getKey(); + $tenantId = $backupSet?->tenant_id ?? Tenant::currentOrFail()->getKey(); $existingPolicyIds = $backupSet ? $backupSet->items()->pluck('policy_id')->filter()->all() : []; diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index 0f6159c..223c83e 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -117,7 +117,7 @@ public function makeCurrent(): void $this->forceFill(['is_current' => true]); } - public static function current(): self + public static function current(): ?self { $filamentTenant = Filament::getTenant(); @@ -146,6 +146,13 @@ public static function current(): self ->where('is_current', true) ->first(); + return $tenant; + } + + public static function currentOrFail(): self + { + $tenant = static::current(); + if (! $tenant) { throw new RuntimeException('No current tenant selected.'); } diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index d52e8f8..c75f6b0 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -26,7 +26,11 @@ public function boot(): void $resolver = app(CapabilityResolver::class); $defineTenantCapability = function (string $capability) use ($resolver): void { - Gate::define($capability, function (User $user, Tenant $tenant) use ($resolver, $capability): bool { + Gate::define($capability, function (User $user, ?Tenant $tenant = null) use ($resolver, $capability): bool { + if (! $tenant instanceof Tenant) { + return false; + } + return $resolver->can($user, $tenant, $capability); }); }; diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 5664e14..2e9c599 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -8,6 +8,7 @@ use App\Filament\Pages\NoAccess; use App\Filament\Pages\Tenancy\RegisterTenant; use App\Filament\Pages\TenantDashboard; +use App\Filament\Resources\Workspaces\WorkspaceResource; use App\Models\Tenant; use App\Support\Middleware\DenyNonMemberTenantAccess; use Filament\Facades\Filament; @@ -15,6 +16,7 @@ use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DispatchServingFilamentEvent; +use Filament\Navigation\NavigationItem; use Filament\Panel; use Filament\PanelProvider; use Filament\Support\Colors\Color; @@ -38,6 +40,7 @@ public function panel(Panel $panel): Panel ->path('admin') ->login(Login::class) ->authenticatedRoutes(function (Panel $panel): void { + WorkspaceResource::registerRoutes($panel); ChooseWorkspace::registerRoutes($panel); ChooseTenant::registerRoutes($panel); NoAccess::registerRoutes($panel); @@ -50,10 +53,21 @@ public function panel(Panel $panel): Panel ->colors([ 'primary' => Color::Amber, ]) + ->navigationItems([ + NavigationItem::make('Workspaces') + ->url(fn (): string => route('filament.admin.resources.workspaces.index')) + ->icon('heroicon-o-squares-2x2') + ->group('Settings') + ->sort(10), + ]) ->renderHook( PanelsRenderHook::HEAD_END, fn () => view('filament.partials.livewire-intercept-shim')->render() ) + ->renderHook( + PanelsRenderHook::USER_MENU_PROFILE_AFTER, + fn () => view('filament.partials.workspace-switcher')->render() + ) ->renderHook( PanelsRenderHook::BODY_END, fn () => (bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true) diff --git a/app/Services/Audit/WorkspaceAuditLogger.php b/app/Services/Audit/WorkspaceAuditLogger.php index ac0f466..f70f489 100644 --- a/app/Services/Audit/WorkspaceAuditLogger.php +++ b/app/Services/Audit/WorkspaceAuditLogger.php @@ -19,6 +19,9 @@ public function log( string $status = 'success', ?string $resourceType = null, ?string $resourceId = null, + ?int $actorId = null, + ?string $actorEmail = null, + ?string $actorName = null, ): AuditLog { $metadata = $context['metadata'] ?? []; unset($context['metadata']); @@ -26,9 +29,9 @@ public function log( return AuditLog::create([ 'tenant_id' => null, 'workspace_id' => (int) $workspace->getKey(), - 'actor_id' => $actor?->getKey(), - 'actor_email' => $actor?->email, - 'actor_name' => $actor?->name, + 'actor_id' => $actor?->getKey() ?? $actorId, + 'actor_email' => $actor?->email ?? $actorEmail, + 'actor_name' => $actor?->name ?? $actorName, 'action' => $action, 'resource_type' => $resourceType, 'resource_id' => $resourceId, diff --git a/app/Services/Auth/WorkspaceMembershipManager.php b/app/Services/Auth/WorkspaceMembershipManager.php index 6941777..a98fb9f 100644 --- a/app/Services/Auth/WorkspaceMembershipManager.php +++ b/app/Services/Auth/WorkspaceMembershipManager.php @@ -28,65 +28,82 @@ public function addMember( $this->assertValidRole($role); $this->assertActorCanManage($actor, $workspace); - return DB::transaction(function () use ($workspace, $actor, $member, $role, $source): WorkspaceMembership { - $existing = WorkspaceMembership::query() - ->where('workspace_id', (int) $workspace->getKey()) - ->where('user_id', (int) $member->getKey()) - ->first(); + try { + return DB::transaction(function () use ($workspace, $actor, $member, $role, $source): WorkspaceMembership { + $existing = WorkspaceMembership::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('user_id', (int) $member->getKey()) + ->first(); - if ($existing) { - if ($existing->role !== $role) { - $fromRole = (string) $existing->role; + if ($existing) { + if ($existing->role !== $role) { + $fromRole = (string) $existing->role; - $existing->forceFill([ - 'role' => $role, - ])->save(); + $this->guardLastOwnerDemotion($workspace, $existing, $role); - $this->auditLogger->log( - workspace: $workspace, - action: AuditActionId::WorkspaceMembershipRoleChange->value, - context: [ - 'metadata' => [ - 'member_user_id' => (int) $member->getKey(), - 'from_role' => $fromRole, - 'to_role' => $role, - 'source' => $source, + $existing->forceFill([ + 'role' => $role, + ])->save(); + + $this->auditLogger->log( + workspace: $workspace, + action: AuditActionId::WorkspaceMembershipRoleChange->value, + context: [ + 'metadata' => [ + 'member_user_id' => (int) $member->getKey(), + 'from_role' => $fromRole, + 'to_role' => $role, + 'source' => $source, + ], ], - ], - actor: $actor, - status: 'success', - resourceType: 'workspace', - resourceId: (string) $workspace->getKey(), - ); + actor: $actor, + status: 'success', + resourceType: 'workspace', + resourceId: (string) $workspace->getKey(), + ); + } + + return $existing->refresh(); } - return $existing->refresh(); + $membership = WorkspaceMembership::query()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $member->getKey(), + 'role' => $role, + ]); + + $this->auditLogger->log( + workspace: $workspace, + action: AuditActionId::WorkspaceMembershipAdd->value, + context: [ + 'metadata' => [ + 'member_user_id' => (int) $member->getKey(), + 'role' => $role, + 'source' => $source, + ], + ], + actor: $actor, + status: 'success', + resourceType: 'workspace', + resourceId: (string) $workspace->getKey(), + ); + + return $membership; + }); + } catch (DomainException $exception) { + if ($exception->getMessage() === 'You cannot demote the last remaining owner.') { + $this->auditLastOwnerBlocked( + workspace: $workspace, + actor: $actor, + targetUserId: (int) $member->getKey(), + attemptedRole: $role, + currentRole: WorkspaceRole::Owner->value, + attemptedAction: 'role_change', + ); } - $membership = WorkspaceMembership::query()->create([ - 'workspace_id' => (int) $workspace->getKey(), - 'user_id' => (int) $member->getKey(), - 'role' => $role, - ]); - - $this->auditLogger->log( - workspace: $workspace, - action: AuditActionId::WorkspaceMembershipAdd->value, - context: [ - 'metadata' => [ - 'member_user_id' => (int) $member->getKey(), - 'role' => $role, - 'source' => $source, - ], - ], - actor: $actor, - status: 'success', - resourceType: 'workspace', - resourceId: (string) $workspace->getKey(), - ); - - return $membership; - }); + throw $exception; + } } public function changeRole(Workspace $workspace, User $actor, WorkspaceMembership $membership, string $newRole): WorkspaceMembership @@ -134,20 +151,13 @@ public function changeRole(Workspace $workspace, User $actor, WorkspaceMembershi }); } catch (DomainException $exception) { if ($exception->getMessage() === 'You cannot demote the last remaining owner.') { - $this->auditLogger->log( + $this->auditLastOwnerBlocked( workspace: $workspace, - action: AuditActionId::WorkspaceMembershipLastOwnerBlocked->value, - context: [ - 'metadata' => [ - 'member_user_id' => (int) $membership->user_id, - 'from_role' => (string) $membership->role, - 'attempted_to_role' => $newRole, - ], - ], actor: $actor, - status: 'blocked', - resourceType: 'workspace', - resourceId: (string) $workspace->getKey(), + targetUserId: (int) $membership->user_id, + attemptedRole: $newRole, + currentRole: (string) $membership->role, + attemptedAction: 'role_change', ); } @@ -191,20 +201,13 @@ public function removeMember(Workspace $workspace, User $actor, WorkspaceMembers }); } catch (DomainException $exception) { if ($exception->getMessage() === 'You cannot remove the last remaining owner.') { - $this->auditLogger->log( + $this->auditLastOwnerBlocked( workspace: $workspace, - action: AuditActionId::WorkspaceMembershipLastOwnerBlocked->value, - context: [ - 'metadata' => [ - 'member_user_id' => (int) $membership->user_id, - 'role' => (string) $membership->role, - 'attempted_action' => 'remove', - ], - ], actor: $actor, - status: 'blocked', - resourceType: 'workspace', - resourceId: (string) $workspace->getKey(), + targetUserId: (int) $membership->user_id, + attemptedRole: (string) $membership->role, + currentRole: (string) $membership->role, + attemptedAction: 'remove', ); } @@ -269,4 +272,32 @@ private function guardLastOwnerRemoval(Workspace $workspace, WorkspaceMembership throw new DomainException('You cannot remove the last remaining owner.'); } } + + private function auditLastOwnerBlocked( + Workspace $workspace, + User $actor, + int $targetUserId, + string $attemptedRole, + string $currentRole, + string $attemptedAction, + ): void { + $this->auditLogger->log( + workspace: $workspace, + action: AuditActionId::WorkspaceMembershipLastOwnerBlocked->value, + context: [ + 'metadata' => [ + 'workspace_id' => (int) $workspace->getKey(), + 'actor_user_id' => (int) $actor->getKey(), + 'target_user_id' => $targetUserId, + 'attempted_role' => $attemptedRole, + 'current_role' => $currentRole, + 'attempted_action' => $attemptedAction, + ], + ], + actor: $actor, + status: 'blocked', + resourceType: 'workspace', + resourceId: (string) $workspace->getKey(), + ); + } } diff --git a/app/Support/Audit/AuditActionId.php b/app/Support/Audit/AuditActionId.php index e093b64..c1a8ff5 100644 --- a/app/Support/Audit/AuditActionId.php +++ b/app/Support/Audit/AuditActionId.php @@ -6,6 +6,12 @@ enum AuditActionId: string { + case WorkspaceMembershipAdd = 'workspace_membership.add'; + case WorkspaceMembershipRoleChange = 'workspace_membership.role_change'; + case WorkspaceMembershipRemove = 'workspace_membership.remove'; + case WorkspaceMembershipLastOwnerBlocked = 'workspace_membership.last_owner_blocked'; + case WorkspaceMembershipBreakGlassAssignOwner = 'workspace_membership.break_glass.assign_owner'; + case TenantMembershipAdd = 'tenant_membership.add'; case TenantMembershipRoleChange = 'tenant_membership.role_change'; case TenantMembershipRemove = 'tenant_membership.remove'; diff --git a/app/Support/Middleware/EnsureFilamentTenantSelected.php b/app/Support/Middleware/EnsureFilamentTenantSelected.php index 04fb305..246bd4c 100644 --- a/app/Support/Middleware/EnsureFilamentTenantSelected.php +++ b/app/Support/Middleware/EnsureFilamentTenantSelected.php @@ -10,6 +10,8 @@ use Closure; use Filament\Facades\Filament; use Filament\Models\Contracts\HasTenants; +use Filament\Navigation\NavigationBuilder; +use Filament\Navigation\NavigationItem; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; @@ -20,6 +22,8 @@ class EnsureFilamentTenantSelected */ public function handle(Request $request, Closure $next): Response { + $panel = Filament::getCurrentOrDefaultPanel(); + if ($request->route()?->hasParameter('tenant')) { $user = $request->user(); @@ -31,8 +35,6 @@ public function handle(Request $request, Closure $next): Response abort(404); } - $panel = Filament::getCurrentOrDefaultPanel(); - if (! $panel->hasTenancy()) { return $next($request); } @@ -72,54 +74,105 @@ public function handle(Request $request, Closure $next): Response Filament::setTenant($tenant, true); + $this->configureNavigationForRequest($panel); + return $next($request); } if (filled(Filament::getTenant())) { + $this->configureNavigationForRequest($panel); + return $next($request); } $user = $request->user(); if (! $user instanceof User) { + $this->configureNavigationForRequest($panel); + return $next($request); } $tenant = null; - try { - $tenant = Tenant::current(); - } catch (\RuntimeException) { - $tenant = null; + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId($request); + + if ($workspaceId !== null) { + $tenant = $user->tenants() + ->where('workspace_id', $workspaceId) + ->where('status', 'active') + ->first(); + + if (! $tenant) { + $tenant = $user->tenants() + ->where('workspace_id', $workspaceId) + ->first(); + } + + if (! $tenant) { + $tenant = $user->tenants() + ->withTrashed() + ->where('workspace_id', $workspaceId) + ->first(); + } } - if ($tenant instanceof Tenant && ! app(CapabilityResolver::class)->isMember($user, $tenant)) { - $tenant = null; + if (! $tenant) { + try { + $tenant = Tenant::current(); + } catch (\RuntimeException) { + $tenant = null; + } + + if ($tenant instanceof Tenant && ! app(CapabilityResolver::class)->isMember($user, $tenant)) { + $tenant = null; + } } if (! $tenant) { $tenant = $user->tenants() - ->whereNull('deleted_at') ->where('status', 'active') ->first(); } if (! $tenant) { - $tenant = $user->tenants() - ->whereNull('deleted_at') - ->first(); + $tenant = $user->tenants()->first(); } if (! $tenant) { - $tenant = $user->tenants() - ->withTrashed() - ->first(); + $tenant = $user->tenants()->withTrashed()->first(); } if ($tenant) { Filament::setTenant($tenant, true); } + $this->configureNavigationForRequest($panel); + return $next($request); } + + private function configureNavigationForRequest(\Filament\Panel $panel): void + { + if (! $panel->hasTenancy()) { + return; + } + + if (filled(Filament::getTenant())) { + $panel->navigation(true); + + return; + } + + $panel->navigation(function (): NavigationBuilder { + return app(NavigationBuilder::class) + ->item( + NavigationItem::make('Workspaces') + ->url(fn (): string => route('filament.admin.resources.workspaces.index')) + ->icon('heroicon-o-squares-2x2') + ->group('Settings') + ->sort(10), + ); + }); + } } diff --git a/app/Support/Workspaces/WorkspaceContext.php b/app/Support/Workspaces/WorkspaceContext.php index b927f3c..6ed0554 100644 --- a/app/Support/Workspaces/WorkspaceContext.php +++ b/app/Support/Workspaces/WorkspaceContext.php @@ -88,10 +88,6 @@ public function resolveInitialWorkspaceFor(User $user, ?Request $request = null) if (! $workspace instanceof Workspace || ! $this->isWorkspaceSelectable($workspace) || ! $this->isMember($user, $workspace)) { $user->forceFill(['last_workspace_id' => null])->save(); - } else { - $session->put(self::SESSION_KEY, (int) $workspace->getKey()); - - return $workspace; } } diff --git a/resources/views/filament/pages/choose-tenant.blade.php b/resources/views/filament/pages/choose-tenant.blade.php index 6e16aa7..4c16619 100644 --- a/resources/views/filament/pages/choose-tenant.blade.php +++ b/resources/views/filament/pages/choose-tenant.blade.php @@ -11,25 +11,62 @@ @if ($tenants->isEmpty())
- No tenants are available for your account. +
No tenants are available for your account.
+
+ @if ($this->canRegisterTenant()) + Register a tenant for this workspace, or switch workspaces. + @else + Switch workspaces, or contact an administrator. + @endif +
+ +
+ @if ($this->canRegisterTenant()) + + Register tenant + + @endif + + + Change workspace + +
@else
@foreach ($tenants as $tenant) -
-
+
+
+ @csrf + +
{{ $tenant->name }}
Continue -
+
@endforeach
diff --git a/resources/views/filament/pages/choose-workspace.blade.php b/resources/views/filament/pages/choose-workspace.blade.php index 77fde00..913b9ba 100644 --- a/resources/views/filament/pages/choose-workspace.blade.php +++ b/resources/views/filament/pages/choose-workspace.blade.php @@ -7,6 +7,14 @@ @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()) @@ -17,20 +25,42 @@ @else
@foreach ($workspaces as $workspace) -
-
-
- {{ $workspace->name }} + @php + $isRecommended = $recommendedWorkspaceId > 0 && (int) $workspace->id === $recommendedWorkspaceId; + @endphp + +
+
+ @csrf + + +
+
+ {{ $workspace->name }} +
+ + @if ($isRecommended) +
+ + Last used + +
+ @endif
Continue -
+
@endforeach
diff --git a/resources/views/filament/partials/workspace-switcher.blade.php b/resources/views/filament/partials/workspace-switcher.blade.php new file mode 100644 index 0000000..7c2020f --- /dev/null +++ b/resources/views/filament/partials/workspace-switcher.blade.php @@ -0,0 +1,47 @@ +@php + /** @var \App\Support\Workspaces\WorkspaceContext $workspaceContext */ + $workspaceContext = app(\App\Support\Workspaces\WorkspaceContext::class); + + $user = auth()->user(); + $currentWorkspaceId = $workspaceContext->currentWorkspaceId(request()); + + $workspaces = collect(); + if ($user instanceof \App\Models\User) { + $workspaces = \App\Models\Workspace::query() + ->whereIn('id', function ($query) use ($user): void { + $query->from('workspace_memberships') + ->select('workspace_id') + ->where('user_id', $user->getKey()); + }) + ->whereNull('archived_at') + ->orderBy('name') + ->get(); + } +@endphp + +@if ($workspaces->isNotEmpty()) + +
+
+ @csrf + +
Workspace
+ + + +
Switch workspace
+
+
+
+@endif diff --git a/resources/views/filament/system/pages/repair-workspace-owners.blade.php b/resources/views/filament/system/pages/repair-workspace-owners.blade.php new file mode 100644 index 0000000..44a3a91 --- /dev/null +++ b/resources/views/filament/system/pages/repair-workspace-owners.blade.php @@ -0,0 +1,13 @@ + +
+
+
+

Purpose

+

+ This page exists to recover from broken workspace ownership state (e.g. a workspace with zero owners due to manual DB edits). + Actions here require break-glass mode and are fully audited. +

+
+
+
+
diff --git a/routes/web.php b/routes/web.php index ac4f48c..00df6b5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -4,6 +4,8 @@ use App\Http\Controllers\AdminConsentCallbackController; use App\Http\Controllers\Auth\EntraController; use App\Http\Controllers\RbacDelegatedAuthController; +use App\Http\Controllers\SelectTenantController; +use App\Http\Controllers\SwitchWorkspaceController; use App\Http\Controllers\TenantOnboardingController; use App\Models\Workspace; use App\Support\Middleware\DenyNonMemberTenantAccess; @@ -25,6 +27,30 @@ Route::get('/admin/consent/start', TenantOnboardingController::class) ->name('admin.consent.start'); +// Panel root override: keep the app's workspace-first flow. +// Avoid Filament's tenancy root redirect which otherwise sends users to /admin/register-tenant +// when no default tenant can be resolved. +Route::middleware([ + 'web', + 'panel:admin', + 'ensure-correct-guard:web', + DenyNonMemberTenantAccess::class, + DisableBladeIconComponents::class, + DispatchServingFilamentEvent::class, + FilamentAuthenticate::class, + 'ensure-workspace-selected', +]) + ->get('/admin', function (Request $request) { + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId($request); + + if ($workspaceId === null) { + return redirect()->to('/admin/choose-workspace'); + } + + return redirect()->to('/admin/choose-tenant'); + }) + ->name('admin.home'); + // Fallback route: Filament's layout generates this URL when tenancy registration is enabled. // In this app, package route registration may not always define it early enough, which breaks // rendering on tenant-scoped routes. @@ -92,6 +118,14 @@ }) ->name('admin.legacy.onboarding'); +Route::middleware(['web', 'auth', 'ensure-correct-guard:web']) + ->post('/admin/switch-workspace', SwitchWorkspaceController::class) + ->name('admin.switch-workspace'); + +Route::middleware(['web', 'auth', 'ensure-correct-guard:web', 'ensure-workspace-selected']) + ->post('/admin/select-tenant', SelectTenantController::class) + ->name('admin.select-tenant'); + Route::bind('workspace', function (string $value): Workspace { /** @var WorkspaceResolver $resolver */ $resolver = app(WorkspaceResolver::class); diff --git a/specs/072-managed-tenants-workspace-enforcement/plan.md b/specs/072-managed-tenants-workspace-enforcement/plan.md index cb5fb9f..e00eafe 100644 --- a/specs/072-managed-tenants-workspace-enforcement/plan.md +++ b/specs/072-managed-tenants-workspace-enforcement/plan.md @@ -10,11 +10,13 @@ ## Approach 2. Move Managed Tenants list/onboarding UX to workspace-scoped routes. 3. Make `/admin/managed-tenants/*` legacy-only (redirect to the correct workspace-scoped URL). 4. Enforce workspace/tenant consistency for all `/admin/t/{tenant}` routes (deny-as-not-found on mismatch). +5. Ensure the panel UX makes the active workspace visible and switchable from the topbar. ## Key decisions - **Workspace is not Filament tenancy**; it remains session + middleware. - Hard enforcement is implemented in middleware that runs on tenant-scoped routes. - Prefer redirects over removing routes immediately, to avoid breaking deep links, but ensure they are no longer primary UX. +- Default tenant selection must respect the current workspace context to avoid cross-workspace tenant URLs. ## Files (expected) - `routes/web.php` diff --git a/specs/072-managed-tenants-workspace-enforcement/spec.md b/specs/072-managed-tenants-workspace-enforcement/spec.md index e3f1d68..1c1d43e 100644 --- a/specs/072-managed-tenants-workspace-enforcement/spec.md +++ b/specs/072-managed-tenants-workspace-enforcement/spec.md @@ -12,6 +12,7 @@ ## Mental model (source of truth) ## Goals - Workspace becomes a real, enforced context for all tenant-scoped pages. - Keep Filament tenancy URL space unchanged: `/admin/t/{tenant_external_id}/...`. +- Keep Workspaces UI tenantless: `/admin/workspaces/*` (never under `/admin/t/{tenant}/...`). - Introduce / use a workspace-scoped landing space for portfolio UX: `/admin/w/{workspace}/...`. - Eliminate or redirect legacy unscoped Managed Tenants routes under `/admin/managed-tenants/*`. diff --git a/specs/072-managed-tenants-workspace-enforcement/tasks.md b/specs/072-managed-tenants-workspace-enforcement/tasks.md index f2d5053..c25422a 100644 --- a/specs/072-managed-tenants-workspace-enforcement/tasks.md +++ b/specs/072-managed-tenants-workspace-enforcement/tasks.md @@ -14,7 +14,23 @@ ## Core - [x] T110 Make unscoped `/admin/managed-tenants/*` legacy-only (redirect to workspace-scoped URLs). - [x] T120 Implement hard enforcement: tenant routes require workspace context and tenant.workspace_id match. - [x] T130 Ensure `/admin/choose-tenant` requires selected workspace. +- [x] T140 Move Workspaces UI out of tenant routing (serve at `/admin/workspaces/*`, not `/admin/t/{tenant}/workspaces`). + +## UX follow-ups +- [x] T200 Ensure default tenant selection respects current workspace context. +- [x] T210 Add a workspace switcher in the user menu (link to Choose Workspace). +- [x] T220 Add regression tests for workspace switcher + tenant selection. +- [x] T230 Ensure `/admin` lands on workspace-first flow (avoid redirecting to tenant registration). +- [x] T240 After choosing a workspace with zero tenants, route into tenant registration (not empty Choose Tenant). +- [x] T250 Allow workspace owners to register the first tenant in a workspace (bootstrap). + +## Security hardening (owners / audit / recovery) +- [x] T260 Enforce rule: workspaces can never have 0 owners (block last-owner removal + demotion). +- [x] T270 Audit every blocked last-owner attempt with `workspace_membership.last_owner_blocked` + required metadata. +- [x] T280 Optional: break-glass recovery flow to re-assign a workspace owner (fully audited). ## Validation - [x] T900 Run Pint on dirty files. - [x] T910 Run targeted Pest tests. + +- [x] T920 Run targeted Pest tests for last-owner + recovery flow. diff --git a/tests/Feature/Auth/BreakGlassWorkspaceOwnerRecoveryTest.php b/tests/Feature/Auth/BreakGlassWorkspaceOwnerRecoveryTest.php new file mode 100644 index 0000000..6f73b66 --- /dev/null +++ b/tests/Feature/Auth/BreakGlassWorkspaceOwnerRecoveryTest.php @@ -0,0 +1,89 @@ +create([ + 'tenant_id' => null, + 'external_id' => 'platform', + 'name' => 'Platform', + ]); + + config()->set('tenantpilot.break_glass.enabled', true); + config()->set('tenantpilot.break_glass.ttl_minutes', 15); +}); + +it('can assign a workspace owner via break-glass and audits it', function () { + $platformUser = PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::USE_BREAK_GLASS, + ], + ]); + + $this->actingAs($platformUser, 'platform'); + + $workspace = Workspace::factory()->create(); + $targetUser = User::factory()->create(); + + // Ensure the workspace is in a "broken" state: zero owners. + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $targetUser->getKey(), + 'role' => WorkspaceRole::Operator->value, + ]); + + Livewire::test(Dashboard::class) + ->callAction('enter_break_glass', data: [ + 'reason' => 'Recover workspace ownership', + ]); + + Livewire::test(RepairWorkspaceOwners::class) + ->callAction('assign_owner', data: [ + 'workspace_id' => (int) $workspace->getKey(), + 'target_user_id' => (int) $targetUser->getKey(), + 'reason' => 'Fix last owner removed via DB edit', + ]); + + $membership = WorkspaceMembership::query() + ->where('workspace_id', $workspace->getKey()) + ->where('user_id', $targetUser->getKey()) + ->firstOrFail(); + + expect($membership->role)->toBe(WorkspaceRole::Owner->value); + + $audit = AuditLog::query() + ->where('workspace_id', $workspace->getKey()) + ->where('action', 'workspace_membership.break_glass.assign_owner') + ->where('status', 'success') + ->latest('id') + ->first(); + + expect($audit)->not->toBeNull(); + expect($audit->metadata)->toMatchArray([ + 'workspace_id' => (int) $workspace->getKey(), + 'actor_user_id' => (int) $platformUser->getKey(), + 'target_user_id' => (int) $targetUser->getKey(), + 'attempted_role' => WorkspaceRole::Owner->value, + 'source' => 'break_glass', + ]); +}); diff --git a/tests/Feature/Auth/WorkspaceLastOwnerGuardTest.php b/tests/Feature/Auth/WorkspaceLastOwnerGuardTest.php new file mode 100644 index 0000000..d688869 --- /dev/null +++ b/tests/Feature/Auth/WorkspaceLastOwnerGuardTest.php @@ -0,0 +1,96 @@ +create(); + + $actor = User::factory()->create(); + $target = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $actor->getKey(), + 'role' => WorkspaceRole::Manager->value, + ]); + + $targetMembership = WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $target->getKey(), + 'role' => WorkspaceRole::Owner->value, + ]); + + $manager = app(WorkspaceMembershipManager::class); + + expect(fn () => $manager->changeRole($workspace, $actor, $targetMembership, WorkspaceRole::Manager->value)) + ->toThrow(DomainException::class, 'You cannot demote the last remaining owner.'); + + $targetMembership->refresh(); + expect($targetMembership->role)->toBe(WorkspaceRole::Owner->value); + + $audit = AuditLog::query() + ->where('workspace_id', $workspace->getKey()) + ->where('action', 'workspace_membership.last_owner_blocked') + ->where('status', 'blocked') + ->latest('id') + ->first(); + + expect($audit)->not->toBeNull(); + expect($audit->metadata)->toMatchArray([ + 'workspace_id' => (int) $workspace->getKey(), + 'actor_user_id' => (int) $actor->getKey(), + 'target_user_id' => (int) $target->getKey(), + 'attempted_role' => WorkspaceRole::Manager->value, + ]); +}); + +it('blocks removing the last remaining workspace owner and audits it', function () { + $workspace = Workspace::factory()->create(); + + $actor = User::factory()->create(); + $target = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $actor->getKey(), + 'role' => WorkspaceRole::Manager->value, + ]); + + $targetMembership = WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $target->getKey(), + 'role' => WorkspaceRole::Owner->value, + ]); + + $manager = app(WorkspaceMembershipManager::class); + + expect(fn () => $manager->removeMember($workspace, $actor, $targetMembership)) + ->toThrow(DomainException::class, 'You cannot remove the last remaining owner.'); + + expect(WorkspaceMembership::query()->whereKey($targetMembership->getKey())->exists())->toBeTrue(); + + $audit = AuditLog::query() + ->where('workspace_id', $workspace->getKey()) + ->where('action', 'workspace_membership.last_owner_blocked') + ->where('status', 'blocked') + ->latest('id') + ->first(); + + expect($audit)->not->toBeNull(); + expect($audit->metadata)->toMatchArray([ + 'workspace_id' => (int) $workspace->getKey(), + 'actor_user_id' => (int) $actor->getKey(), + 'target_user_id' => (int) $target->getKey(), + 'attempted_action' => 'remove', + ]); +}); diff --git a/tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php b/tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php new file mode 100644 index 0000000..a6c6fe5 --- /dev/null +++ b/tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php @@ -0,0 +1,29 @@ +create(); + + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $this + ->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get('/admin') + ->assertRedirect(route('filament.admin.pages.choose-tenant')); +}); diff --git a/tests/Feature/Filament/ChooseTenantEmptyStateRegisterTenantCtaVisibilityTest.php b/tests/Feature/Filament/ChooseTenantEmptyStateRegisterTenantCtaVisibilityTest.php new file mode 100644 index 0000000..df632bf --- /dev/null +++ b/tests/Feature/Filament/ChooseTenantEmptyStateRegisterTenantCtaVisibilityTest.php @@ -0,0 +1,48 @@ +create(); + + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'readonly', + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get('/admin/choose-tenant') + ->assertSuccessful() + ->assertSee('No tenants are available') + ->assertDontSee('Register tenant'); +}); + +it('shows the register-tenant CTA for owner workspace members when there are no tenants', 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) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get('/admin/choose-tenant') + ->assertSuccessful() + ->assertSee('Register tenant'); +}); diff --git a/tests/Feature/Filament/ChooseWorkspaceShowsLastUsedRecommendationTest.php b/tests/Feature/Filament/ChooseWorkspaceShowsLastUsedRecommendationTest.php new file mode 100644 index 0000000..8958113 --- /dev/null +++ b/tests/Feature/Filament/ChooseWorkspaceShowsLastUsedRecommendationTest.php @@ -0,0 +1,45 @@ +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', + ]); + + $user->forceFill(['last_workspace_id' => (int) $workspaceB->getKey()])->save(); + + $this->actingAs($user) + ->get(route('filament.admin.pages.choose-workspace')) + ->assertOk() + ->assertSee('Last used') + ->assertSeeInOrder([ + 'Workspace B', + 'Workspace A', + ]); +}); diff --git a/tests/Feature/Filament/LoginRedirectsToChooseWorkspaceWhenMultipleWorkspacesTest.php b/tests/Feature/Filament/LoginRedirectsToChooseWorkspaceWhenMultipleWorkspacesTest.php new file mode 100644 index 0000000..4bd094d --- /dev/null +++ b/tests/Feature/Filament/LoginRedirectsToChooseWorkspaceWhenMultipleWorkspacesTest.php @@ -0,0 +1,40 @@ +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', + ]); + + $user->forceFill(['last_workspace_id' => (int) $workspaceA->getKey()])->save(); + + $this->actingAs($user) + ->get('/admin') + ->assertRedirect(route('filament.admin.pages.choose-workspace')); +}); diff --git a/tests/Feature/Filament/SelectTenantPostPersistsLastUsedTest.php b/tests/Feature/Filament/SelectTenantPostPersistsLastUsedTest.php new file mode 100644 index 0000000..95d942e --- /dev/null +++ b/tests/Feature/Filament/SelectTenantPostPersistsLastUsedTest.php @@ -0,0 +1,65 @@ +create(); + + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $tenant = Tenant::factory()->create([ + 'status' => 'active', + 'workspace_id' => $workspace->getKey(), + ]); + + TenantMembership::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + 'source' => 'manual', + 'source_ref' => null, + 'created_by_user_id' => null, + ]); + + $response = $this + ->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->post(route('admin.select-tenant'), ['tenant_id' => (int) $tenant->getKey()]); + + $response->assertRedirect(TenantDashboard::getUrl(tenant: $tenant)); + + $user->refresh(); + + if (Schema::hasColumn('users', 'last_tenant_id')) { + expect($user->last_tenant_id)->toBe($tenant->getKey()); + + return; + } + + if (Schema::hasTable('user_tenant_preferences')) { + $preference = $user->tenantPreferences() + ->where('tenant_id', $tenant->getKey()) + ->first(); + + expect($preference)->not->toBeNull(); + expect($preference?->last_used_at)->not->toBeNull(); + } +}); diff --git a/tests/Feature/Filament/TenantResourceIndexIsWorkspaceScopedTest.php b/tests/Feature/Filament/TenantResourceIndexIsWorkspaceScopedTest.php new file mode 100644 index 0000000..ce7af0d --- /dev/null +++ b/tests/Feature/Filament/TenantResourceIndexIsWorkspaceScopedTest.php @@ -0,0 +1,73 @@ +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', + ]); + + $tenantA = Tenant::factory()->create([ + 'workspace_id' => $workspaceA->getKey(), + 'external_id' => '11111111-1111-1111-1111-111111111111', + 'tenant_id' => '11111111-1111-1111-1111-111111111111', + 'name' => 'Tenant A', + 'status' => 'active', + ]); + + $tenantB = Tenant::factory()->create([ + 'workspace_id' => $workspaceB->getKey(), + 'external_id' => '22222222-2222-2222-2222-222222222222', + 'tenant_id' => '22222222-2222-2222-2222-222222222222', + 'name' => 'Tenant B', + 'status' => 'active', + ]); + + TenantMembership::query()->create([ + 'tenant_id' => $tenantA->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + 'source' => 'manual', + 'source_ref' => null, + 'created_by_user_id' => null, + ]); + + TenantMembership::query()->create([ + 'tenant_id' => $tenantB->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + 'source' => 'manual', + 'source_ref' => null, + 'created_by_user_id' => null, + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceA->getKey()]) + ->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenantA))) + ->assertOk() + ->assertSee('Tenant A') + ->assertDontSee('Tenant B'); +}); diff --git a/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php b/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php new file mode 100644 index 0000000..9e062c3 --- /dev/null +++ b/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php @@ -0,0 +1,72 @@ +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', + ]); + + $tenantA = Tenant::factory()->create([ + 'workspace_id' => $workspaceA->getKey(), + 'status' => 'active', + ]); + + $tenantB = Tenant::factory()->create([ + 'workspace_id' => $workspaceB->getKey(), + 'status' => 'active', + ]); + + $user->tenants()->syncWithoutDetaching([ + $tenantA->getKey() => ['role' => 'owner'], + $tenantB->getKey() => ['role' => 'owner'], + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspaceA->getKey()); + $user->forceFill(['last_workspace_id' => (int) $workspaceA->getKey()])->save(); + + Filament::setTenant(null, true); + + $this->actingAs($user) + ->get(route('filament.admin.pages.choose-tenant')) + ->assertOk(); + + expect(Filament::getTenant()) + ->toBeInstanceOf(Tenant::class) + ->and(Filament::getTenant()?->getKey()) + ->toBe($tenantA->getKey()); +}); + +test('user menu renders a workspace switcher when a workspace is selected', function () { + [$user, $tenant] = createUserWithTenant(); + + $workspace = Workspace::query()->whereKey($tenant->workspace_id)->firstOrFail(); + + $this->actingAs($user) + ->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenant))) + ->assertOk() + ->assertSee($workspace->name) + ->assertSee('Switch workspace') + ->assertSee('name="workspace_id"', escape: false); +}); diff --git a/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php b/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php index fae599b..f0cee18 100644 --- a/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php +++ b/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php @@ -3,6 +3,8 @@ declare(strict_types=1); use App\Filament\Pages\ChooseWorkspace; +use App\Models\Tenant; +use App\Models\TenantMembership; use App\Models\User; use App\Models\Workspace; use App\Models\WorkspaceMembership; @@ -11,7 +13,7 @@ uses(RefreshDatabase::class); -it('redirects to choose-tenant after selecting a workspace', function (): void { +it('redirects to tenant registration after selecting a workspace with no tenants', function (): void { $user = User::factory()->create(); $workspace = Workspace::factory()->create(); @@ -21,6 +23,36 @@ 'role' => 'owner', ]); + Livewire::actingAs($user) + ->test(ChooseWorkspace::class) + ->call('selectWorkspace', $workspace->getKey()) + ->assertRedirect(route('filament.admin.tenant.registration')); +}); + +it('redirects to choose-tenant after selecting a workspace with tenants', 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([ + 'status' => 'active', + 'workspace_id' => $workspace->getKey(), + ]); + + TenantMembership::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + 'source' => 'manual', + 'source_ref' => null, + 'created_by_user_id' => null, + ]); + Livewire::actingAs($user) ->test(ChooseWorkspace::class) ->call('selectWorkspace', $workspace->getKey()) diff --git a/tests/Feature/Workspaces/SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php b/tests/Feature/Workspaces/SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php new file mode 100644 index 0000000..09a6886 --- /dev/null +++ b/tests/Feature/Workspaces/SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php @@ -0,0 +1,30 @@ +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()]) + ->assertRedirect(route('filament.admin.tenant.registration')); + + expect(session(WorkspaceContext::SESSION_KEY))->toBe((int) $workspace->getKey()); +}); diff --git a/tests/Feature/Workspaces/WorkspacesResourceIsTenantlessTest.php b/tests/Feature/Workspaces/WorkspacesResourceIsTenantlessTest.php new file mode 100644 index 0000000..df29fdb --- /dev/null +++ b/tests/Feature/Workspaces/WorkspacesResourceIsTenantlessTest.php @@ -0,0 +1,75 @@ +create(); + + $workspace = Workspace::factory()->create(['slug' => 'acme']); + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get('/admin/workspaces') + ->assertOk(); +}); + +it('serves the Workspaces view page tenantless at /admin/workspaces/{record}', function (): void { + $user = User::factory()->create(); + + $workspace = Workspace::factory()->create(['slug' => 'acme']); + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get('/admin/workspaces/'.(int) $workspace->getKey()) + ->assertOk(); +}); + +it('does not expose the Workspaces UI under the tenant route prefix', function (): void { + $user = User::factory()->create(); + + $workspace = Workspace::factory()->create(['slug' => 'acme']); + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'external_id' => '11111111-1111-1111-1111-111111111111', + 'tenant_id' => '11111111-1111-1111-1111-111111111111', + ]); + + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get('/admin/t/11111111-1111-1111-1111-111111111111/workspaces') + ->assertNotFound(); +});