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..33aec13 100644 --- a/app/Filament/Pages/ChooseWorkspace.php +++ b/app/Filament/Pages/ChooseWorkspace.php @@ -100,7 +100,7 @@ public function selectWorkspace(int $workspaceId): void $context->setCurrentWorkspace($workspace, $user, request()); - $this->redirect(ChooseTenant::getUrl()); + $this->redirect($this->redirectAfterWorkspaceSelected($user)); } /** @@ -132,6 +132,41 @@ public function createWorkspace(array $data): void ->success() ->send(); - $this->redirect(ChooseTenant::getUrl()); + $this->redirect($this->redirectAfterWorkspaceSelected($user)); + } + + 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(tenant: $tenant); + } + } + + 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/Pages/Workspaces/ManagedTenantsLanding.php b/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php new file mode 100644 index 0000000..922ee8b --- /dev/null +++ b/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php @@ -0,0 +1,85 @@ +workspace = $workspace; + } + + /** + * @return Collection + */ + public function getTenants(): Collection + { + $user = auth()->user(); + + if (! $user instanceof User) { + return Tenant::query()->whereRaw('1 = 0')->get(); + } + + return $user->tenants() + ->where('workspace_id', $this->workspace->getKey()) + ->where('status', 'active') + ->orderBy('name') + ->get(); + } + + public function canRegisterTenant(): bool + { + return RegisterTenantPage::canView(); + } + + public function goToChooseTenant(): void + { + $this->redirect(ChooseTenant::getUrl()); + } + + public function openTenant(int $tenantId): void + { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + $tenant = Tenant::query() + ->where('status', 'active') + ->where('workspace_id', $this->workspace->getKey()) + ->whereKey($tenantId) + ->first(); + + if (! $tenant instanceof Tenant) { + abort(404); + } + + if (! $user->canAccessTenant($tenant)) { + abort(404); + } + + $this->redirect(TenantDashboard::getUrl(tenant: $tenant)); + } +} 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..e2e268e 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; @@ -17,6 +19,10 @@ class WorkspaceResource extends Resource protected static bool $isScopedToTenant = false; + protected static ?string $recordTitleAttribute = 'name'; + + protected static bool $shouldRegisterNavigation = false; + 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..15dbab8 --- /dev/null +++ b/app/Http/Controllers/SwitchWorkspaceController.php @@ -0,0 +1,67 @@ +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); + + $tenantsQuery = $user->tenants() + ->where('workspace_id', $workspace->getKey()) + ->where('status', 'active'); + + $tenantCount = (int) $tenantsQuery->count(); + + if ($tenantCount === 0) { + return redirect()->route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()]); + } + + if ($tenantCount === 1) { + $tenant = $tenantsQuery->first(); + + if ($tenant !== null) { + return redirect()->to(TenantDashboard::getUrl(tenant: $tenant)); + } + } + + return redirect()->to(ChooseTenant::getUrl()); + } +} 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..406ec27 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -15,6 +15,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; @@ -50,10 +51,29 @@ public function panel(Panel $panel): Panel ->colors([ 'primary' => Color::Amber, ]) + ->navigationItems([ + NavigationItem::make('Workspaces') + ->url(function (): string { + $tenant = Filament::getTenant(); + + if ($tenant instanceof Tenant) { + return route('filament.admin.resources.workspaces.index', ['tenant' => $tenant->external_id]); + } + + return ChooseWorkspace::getUrl(); + }) + ->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/PostLoginRedirectResolver.php b/app/Services/Auth/PostLoginRedirectResolver.php index 06fce10..e45e15c 100644 --- a/app/Services/Auth/PostLoginRedirectResolver.php +++ b/app/Services/Auth/PostLoginRedirectResolver.php @@ -4,39 +4,27 @@ namespace App\Services\Auth; -use App\Filament\Pages\TenantDashboard; -use App\Models\Tenant; use App\Models\User; -use Illuminate\Support\Collection; +use App\Models\WorkspaceMembership; +use Illuminate\Support\Facades\Schema; class PostLoginRedirectResolver { public function resolve(User $user): string { - $tenants = $this->getActiveTenants($user); + $membershipQuery = WorkspaceMembership::query()->where('user_id', $user->getKey()); - if ($tenants->isEmpty()) { + $hasAnyActiveMembership = Schema::hasColumn('workspaces', 'archived_at') + ? $membershipQuery + ->join('workspaces', 'workspace_memberships.workspace_id', '=', 'workspaces.id') + ->whereNull('workspaces.archived_at') + ->exists() + : $membershipQuery->exists(); + + if (! $hasAnyActiveMembership) { return '/admin/no-access'; } - if ($tenants->count() === 1) { - /** @var Tenant $tenant */ - $tenant = $tenants->first(); - - return TenantDashboard::getUrl(tenant: $tenant); - } - - return '/admin/choose-tenant'; - } - - /** - * @return Collection - */ - private function getActiveTenants(User $user): Collection - { - return $user->tenants() - ->where('status', 'active') - ->orderBy('name') - ->get(); + return '/admin'; } } 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..b789998 100644 --- a/app/Support/Middleware/EnsureFilamentTenantSelected.php +++ b/app/Support/Middleware/EnsureFilamentTenantSelected.php @@ -2,6 +2,7 @@ namespace App\Support\Middleware; +use App\Filament\Pages\ChooseWorkspace; use App\Models\Tenant; use App\Models\User; use App\Models\Workspace; @@ -10,6 +11,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 +23,10 @@ class EnsureFilamentTenantSelected */ public function handle(Request $request, Closure $next): Response { + $panel = Filament::getCurrentOrDefaultPanel(); + + $path = '/'.ltrim($request->path(), '/'); + if ($request->route()?->hasParameter('tenant')) { $user = $request->user(); @@ -31,14 +38,11 @@ public function handle(Request $request, Closure $next): Response abort(404); } - $panel = Filament::getCurrentOrDefaultPanel(); - if (! $panel->hasTenancy()) { return $next($request); } $tenantParameter = $request->route()->parameter('tenant'); - $tenant = $panel->getTenant($tenantParameter); if (! $tenant instanceof Tenant) { @@ -71,55 +75,115 @@ public function handle(Request $request, Closure $next): Response } Filament::setTenant($tenant, true); + $this->configureNavigationForRequest($panel); + + return $next($request); + } + + if ( + str_starts_with($path, '/admin/w/') + || str_starts_with($path, '/admin/workspaces') + || in_array($path, ['/admin/choose-workspace', '/admin/choose-tenant', '/admin/no-access'], 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 => ChooseWorkspace::getUrl()) + ->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..3a7efea 100644 --- a/resources/views/filament/pages/choose-tenant.blade.php +++ b/resources/views/filament/pages/choose-tenant.blade.php @@ -11,25 +11,61 @@ @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/pages/workspaces/managed-tenants-landing.blade.php b/resources/views/filament/pages/workspaces/managed-tenants-landing.blade.php new file mode 100644 index 0000000..776dd02 --- /dev/null +++ b/resources/views/filament/pages/workspaces/managed-tenants-landing.blade.php @@ -0,0 +1,78 @@ + + +
+
+ Workspace: {{ $this->workspace->name }} +
+ + @php + $tenants = $this->getTenants(); + @endphp + + @if ($tenants->isEmpty()) +
+
No managed tenants yet.
+
+ Add a managed tenant to start inventory, drift, backups, and policy management. +
+ +
+ @if ($this->canRegisterTenant()) + + Add managed tenant + + @endif + + + Change workspace + +
+
+ @else +
+
+ {{ $tenants->count() }} managed tenant{{ $tenants->count() === 1 ? '' : 's' }} +
+ + + Choose tenant + +
+ +
+ @foreach ($tenants as $tenant) +
+
+
+ {{ $tenant->name }} +
+ + + Open + +
+
+ @endforeach +
+ @endif +
+
+
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..bf89564 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,10 +1,14 @@ 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); + $user = $request->user(); + + if (! $user instanceof User) { + return redirect()->to('/admin/choose-workspace'); + } + + if ($workspaceId === null) { + return redirect()->to('/admin/choose-workspace'); + } + + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + return redirect()->to('/admin/choose-workspace'); + } + + $tenantsQuery = $user->tenants() + ->where('workspace_id', $workspace->getKey()) + ->where('status', 'active'); + + $tenantCount = (int) $tenantsQuery->count(); + + if ($tenantCount === 0) { + return redirect()->route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()]); + } + + if ($tenantCount === 1) { + $tenant = $tenantsQuery->first(); + + if ($tenant !== null) { + return redirect()->to(TenantDashboard::getUrl(tenant: $tenant)); + } + } + + 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 +148,13 @@ }) ->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); @@ -106,18 +169,29 @@ Route::middleware(['web', 'auth', 'ensure-workspace-member']) ->prefix('/admin/w/{workspace}') ->group(function (): void { - Route::get('/', fn () => redirect('/admin/tenants')) + Route::get('/', fn () => redirect()->route('admin.workspace.managed-tenants.index', ['workspace' => request()->route('workspace')])) ->name('admin.workspace.home'); Route::get('/ping', fn () => response()->noContent())->name('admin.workspace.ping'); - Route::get('/managed-tenants', fn () => redirect('/admin/tenants')) - ->name('admin.workspace.managed-tenants.index'); - - Route::get('/managed-tenants/onboarding', fn () => redirect('/admin/tenants/create')) + Route::get('/managed-tenants/onboarding', fn () => redirect('/admin/register-tenant')) ->name('admin.workspace.managed-tenants.onboarding'); }); +Route::middleware([ + 'web', + 'panel:admin', + 'ensure-correct-guard:web', + DenyNonMemberTenantAccess::class, + DisableBladeIconComponents::class, + DispatchServingFilamentEvent::class, + FilamentAuthenticate::class, + 'ensure-workspace-member', + 'ensure-filament-tenant-selected', +]) + ->get('/admin/w/{workspace}/managed-tenants', \App\Filament\Pages\Workspaces\ManagedTenantsLanding::class) + ->name('admin.workspace.managed-tenants.index'); + if (app()->runningUnitTests()) { Route::middleware(['web', 'auth', 'ensure-workspace-selected']) ->get('/admin/_test/workspace-context', function (Request $request) { 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..1ee239c 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 the workspace Managed Tenants landing (with CTA). +- [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/PostLoginRoutingByMembershipTest.php b/tests/Feature/Auth/PostLoginRoutingByMembershipTest.php index b37319d..4032432 100644 --- a/tests/Feature/Auth/PostLoginRoutingByMembershipTest.php +++ b/tests/Feature/Auth/PostLoginRoutingByMembershipTest.php @@ -2,10 +2,11 @@ declare(strict_types=1); -use App\Filament\Pages\TenantDashboard; use App\Models\Tenant; use App\Models\TenantMembership; use App\Models\User; +use App\Models\Workspace; +use App\Models\WorkspaceMembership; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; @@ -72,7 +73,17 @@ function entra_fake_token_exchange(string $tid, string $oid): void 'entra_object_id' => 'object-1', ]); - $tenant = Tenant::factory()->create(['status' => 'active']); + $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(), @@ -89,7 +100,7 @@ function entra_fake_token_exchange(string $tid, string $oid): void ->withSession(['entra_state' => $state]) ->get(route('auth.entra.callback', ['code' => 'code-123', 'state' => $state])); - $response->assertRedirect(TenantDashboard::getUrl(tenant: $tenant)); + $response->assertRedirect('/admin'); }); it('routes to choose-tenant when user has multiple tenant memberships', function () { @@ -101,7 +112,17 @@ function entra_fake_token_exchange(string $tid, string $oid): void 'entra_object_id' => 'object-1', ]); - $tenants = Tenant::factory()->count(2)->create(['status' => 'active']); + $workspace = Workspace::factory()->create(); + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $tenants = Tenant::factory()->count(2)->create([ + 'status' => 'active', + 'workspace_id' => $workspace->getKey(), + ]); foreach ($tenants as $tenant) { TenantMembership::query()->create([ @@ -120,5 +141,5 @@ function entra_fake_token_exchange(string $tid, string $oid): void ->withSession(['entra_state' => $state]) ->get(route('auth.entra.callback', ['code' => 'code-123', 'state' => $state])); - $response->assertRedirect('/admin/choose-tenant'); + $response->assertRedirect('/admin'); }); 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..87aecdb --- /dev/null +++ b/tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php @@ -0,0 +1,98 @@ +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('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()])); +}); + +it('redirects /admin to choose-tenant when a workspace is selected and has multiple tenants', function (): void { + $user = User::factory()->create(); + + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $tenants = Tenant::factory()->count(2)->create([ + 'status' => 'active', + 'workspace_id' => $workspace->getKey(), + ]); + + foreach ($tenants as $tenant) { + TenantMembership::query()->create([ + 'tenant_id' => $tenant->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) $workspace->getKey()]) + ->get('/admin') + ->assertRedirect('/admin/choose-tenant'); +}); + +it('redirects /admin to the tenant dashboard when a workspace is selected and has exactly one 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([ + '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, + ]); + + $this + ->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get('/admin') + ->assertRedirect(TenantDashboard::getUrl(tenant: $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/TenantDashboardDbOnlyTest.php b/tests/Feature/Filament/TenantDashboardDbOnlyTest.php index 150e4f9..524f6d9 100644 --- a/tests/Feature/Filament/TenantDashboardDbOnlyTest.php +++ b/tests/Feature/Filament/TenantDashboardDbOnlyTest.php @@ -32,6 +32,7 @@ assertNoOutboundHttp(function () use ($tenant): void { $this->get(TenantDashboard::getUrl(tenant: $tenant)) ->assertOk() + ->assertSee("/admin/t/{$tenant->external_id}/workspaces", false) ->assertSee('Needs Attention') ->assertSee('Recent Operations') ->assertSee('Recent Drift Findings'); 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..149ff6f 100644 --- a/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php +++ b/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php @@ -3,6 +3,9 @@ declare(strict_types=1); use App\Filament\Pages\ChooseWorkspace; +use App\Filament\Pages\TenantDashboard; +use App\Models\Tenant; +use App\Models\TenantMembership; use App\Models\User; use App\Models\Workspace; use App\Models\WorkspaceMembership; @@ -11,7 +14,7 @@ uses(RefreshDatabase::class); -it('redirects to choose-tenant after selecting a workspace', function (): void { +it('redirects to the workspace managed-tenants landing after selecting a workspace with no tenants', function (): void { $user = User::factory()->create(); $workspace = Workspace::factory()->create(); @@ -21,6 +24,68 @@ 'role' => 'owner', ]); + Livewire::actingAs($user) + ->test(ChooseWorkspace::class) + ->call('selectWorkspace', $workspace->getKey()) + ->assertRedirect(route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()])); +}); + +it('redirects to the tenant dashboard after selecting a workspace with exactly one 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([ + '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()) + ->assertRedirect(TenantDashboard::getUrl(tenant: $tenant)); +}); + +it('redirects to choose-tenant after selecting a workspace with multiple tenants', function (): void { + $user = User::factory()->create(); + + $workspace = Workspace::factory()->create(); + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $tenants = Tenant::factory()->count(2)->create([ + 'status' => 'active', + 'workspace_id' => $workspace->getKey(), + ]); + + foreach ($tenants as $tenant) { + 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/ManagedTenantsWorkspaceRoutingTest.php b/tests/Feature/Workspaces/ManagedTenantsWorkspaceRoutingTest.php index c93f556..18ab164 100644 --- a/tests/Feature/Workspaces/ManagedTenantsWorkspaceRoutingTest.php +++ b/tests/Feature/Workspaces/ManagedTenantsWorkspaceRoutingTest.php @@ -33,6 +33,42 @@ ->assertRedirect("/admin/w/{$workspace->slug}/managed-tenants"); }); +it('keeps the managed-tenants landing tenantless even if the user has a tenant in another workspace', function (): void { + $user = User::factory()->create(); + + $workspaceEmpty = Workspace::factory()->create(['slug' => 'empty']); + $workspaceOther = Workspace::factory()->create(['slug' => 'other']); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceEmpty->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceOther->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $tenantInOther = Tenant::factory()->create([ + 'status' => 'active', + 'workspace_id' => (int) $workspaceOther->getKey(), + 'external_id' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', + 'tenant_id' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', + ]); + + $user->tenants()->syncWithoutDetaching([ + $tenantInOther->getKey() => ['role' => 'owner'], + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceEmpty->getKey()]) + ->get('/admin/w/'.$workspaceEmpty->slug.'/managed-tenants') + ->assertSuccessful() + ->assertDontSee('/admin/t/'.$tenantInOther->external_id, false); +}); + it('returns 404 on tenant routes when workspace context is missing', function (): void { $user = User::factory()->create(); 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(); +});