feat: workspace context enforcement + ownership safeguards #86
@ -34,6 +34,6 @@ private function resolveTenant(): Tenant
|
|||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Tenant::current();
|
return Tenant::currentOrFail();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -138,7 +138,7 @@ private function resolveTenants()
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return collect([Tenant::current()]);
|
return collect([Tenant::currentOrFail()]);
|
||||||
} catch (RuntimeException) {
|
} catch (RuntimeException) {
|
||||||
return collect();
|
return collect();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Pages;
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Pages\Tenancy\RegisterTenant as RegisterTenantPage;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserTenantPreference;
|
use App\Models\UserTenantPreference;
|
||||||
@ -72,6 +73,11 @@ public function selectTenant(int $tenantId): void
|
|||||||
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
|
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function canRegisterTenant(): bool
|
||||||
|
{
|
||||||
|
return RegisterTenantPage::canView();
|
||||||
|
}
|
||||||
|
|
||||||
private function persistLastTenant(User $user, Tenant $tenant): void
|
private function persistLastTenant(User $user, Tenant $tenant): void
|
||||||
{
|
{
|
||||||
if (Schema::hasColumn('users', 'last_tenant_id')) {
|
if (Schema::hasColumn('users', 'last_tenant_id')) {
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
@ -100,7 +101,7 @@ public function selectWorkspace(int $workspaceId): void
|
|||||||
|
|
||||||
$context->setCurrentWorkspace($workspace, $user, request());
|
$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()
|
->success()
|
||||||
->send();
|
->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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,9 +4,11 @@
|
|||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant;
|
use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
@ -27,6 +29,20 @@ public static function canView(): bool
|
|||||||
return false;
|
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');
|
$tenantIds = $user->tenants()->withTrashed()->pluck('tenants.id');
|
||||||
|
|
||||||
if ($tenantIds->isEmpty()) {
|
if ($tenantIds->isEmpty()) {
|
||||||
@ -95,6 +111,12 @@ protected function handleRegistration(array $data): Model
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||||
|
|
||||||
|
if ($workspaceId !== null) {
|
||||||
|
$data['workspace_id'] = $workspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
$tenant = Tenant::create($data);
|
$tenant = Tenant::create($data);
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|||||||
@ -938,7 +938,7 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
$tenantId = Tenant::current()->getKey();
|
$tenantId = Tenant::currentOrFail()->getKey();
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
return parent::getEloquentQuery()
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
@ -1054,7 +1054,7 @@ public static function ensurePolicyTypes(array $data): array
|
|||||||
|
|
||||||
public static function assignTenant(array $data): array
|
public static function assignTenant(array $data): array
|
||||||
{
|
{
|
||||||
$data['tenant_id'] = Tenant::current()->getKey();
|
$data['tenant_id'] = Tenant::currentOrFail()->getKey();
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,7 +21,7 @@ class BackupScheduleRunsRelationManager extends RelationManager
|
|||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $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')
|
->defaultSort('scheduled_for', 'desc')
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('scheduled_for')
|
Tables\Columns\TextColumn::make('scheduled_for')
|
||||||
|
|||||||
@ -894,7 +894,7 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
$tenantId = Tenant::current()->getKey();
|
$tenantId = Tenant::currentOrFail()->getKey();
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
return parent::getEloquentQuery()
|
||||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||||
|
|||||||
@ -815,7 +815,7 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
$tenantId = Tenant::current()->getKey();
|
$tenantId = Tenant::currentOrFail()->getKey();
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
return parent::getEloquentQuery()
|
||||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||||
|
|||||||
@ -87,7 +87,7 @@ public static function form(Schema $schema): Schema
|
|||||||
Forms\Components\Select::make('backup_set_id')
|
Forms\Components\Select::make('backup_set_id')
|
||||||
->label('Backup set')
|
->label('Backup set')
|
||||||
->options(function () {
|
->options(function () {
|
||||||
$tenantId = Tenant::current()->getKey();
|
$tenantId = Tenant::currentOrFail()->getKey();
|
||||||
|
|
||||||
return BackupSet::query()
|
return BackupSet::query()
|
||||||
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
|
->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')
|
Forms\Components\Select::make('backup_set_id')
|
||||||
->label('Backup set')
|
->label('Backup set')
|
||||||
->options(function () {
|
->options(function () {
|
||||||
$tenantId = Tenant::current()->getKey();
|
$tenantId = Tenant::currentOrFail()->getKey();
|
||||||
|
|
||||||
return BackupSet::query()
|
return BackupSet::query()
|
||||||
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
|
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
use App\Jobs\SyncPoliciesJob;
|
use App\Jobs\SyncPoliciesJob;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Auth\RoleCapabilityMap;
|
use App\Services\Auth\RoleCapabilityMap;
|
||||||
use App\Services\Directory\EntraGroupLabelResolver;
|
use App\Services\Directory\EntraGroupLabelResolver;
|
||||||
@ -30,6 +31,7 @@
|
|||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
@ -70,7 +72,21 @@ public static function canCreate(): bool
|
|||||||
return false;
|
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
|
public static function canEdit(Model $record): bool
|
||||||
@ -179,8 +195,15 @@ public static function getEloquentQuery(): Builder
|
|||||||
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
if ($workspaceId === null) {
|
||||||
|
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
||||||
|
}
|
||||||
|
|
||||||
$tenantIds = $user->tenants()
|
$tenantIds = $user->tenants()
|
||||||
->withTrashed()
|
->withTrashed()
|
||||||
|
->where('workspace_id', $workspaceId)
|
||||||
->pluck('tenants.id');
|
->pluck('tenants.id');
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
return parent::getEloquentQuery()
|
||||||
|
|||||||
@ -4,12 +4,28 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\TenantResource;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
class CreateTenant extends CreateRecord
|
class CreateTenant extends CreateRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = TenantResource::class;
|
protected static string $resource = TenantResource::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function mutateFormDataBeforeCreate(array $data): array
|
||||||
|
{
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||||
|
|
||||||
|
if ($workspaceId !== null) {
|
||||||
|
$data['workspace_id'] = $workspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
protected function afterCreate(): void
|
protected function afterCreate(): void
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|||||||
19
app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php
Normal file
19
app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Workspaces\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
|
class ViewWorkspace extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = WorkspaceResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\EditAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,204 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Workspaces\RelationManagers;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Services\Auth\WorkspaceMembershipManager;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\WorkspaceRole;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
class WorkspaceMembershipsRelationManager extends RelationManager
|
||||||
|
{
|
||||||
|
protected static string $relationship = 'memberships';
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->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([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\Workspaces;
|
namespace App\Filament\Resources\Workspaces;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Workspaces\RelationManagers\WorkspaceMembershipsRelationManager;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
|
use Filament\Actions;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
@ -13,10 +15,14 @@
|
|||||||
|
|
||||||
class WorkspaceResource extends Resource
|
class WorkspaceResource extends Resource
|
||||||
{
|
{
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
protected static ?string $model = Workspace::class;
|
protected static ?string $model = Workspace::class;
|
||||||
|
|
||||||
protected static bool $isScopedToTenant = false;
|
protected static bool $isScopedToTenant = false;
|
||||||
|
|
||||||
|
protected static ?string $recordTitleAttribute = 'name';
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Settings';
|
protected static string|UnitEnum|null $navigationGroup = 'Settings';
|
||||||
@ -47,7 +53,8 @@ public static function table(Table $table): Table
|
|||||||
->sortable(),
|
->sortable(),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Tables\Actions\EditAction::make(),
|
Actions\ViewAction::make(),
|
||||||
|
Actions\EditAction::make(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -56,7 +63,15 @@ public static function getPages(): array
|
|||||||
return [
|
return [
|
||||||
'index' => Pages\ListWorkspaces::route('/'),
|
'index' => Pages\ListWorkspaces::route('/'),
|
||||||
'create' => Pages\CreateWorkspace::route('/create'),
|
'create' => Pages\CreateWorkspace::route('/create'),
|
||||||
|
'view' => Pages\ViewWorkspace::route('/{record}'),
|
||||||
'edit' => Pages\EditWorkspace::route('/{record}/edit'),
|
'edit' => Pages\EditWorkspace::route('/{record}/edit'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
WorkspaceMembershipsRelationManager::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
169
app/Filament/System/Pages/RepairWorkspaceOwners.php
Normal file
169
app/Filament/System/Pages/RepairWorkspaceOwners.php
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\System\Pages;
|
||||||
|
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
|
use App\Services\Auth\BreakGlassSession;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
|
use App\Support\Auth\WorkspaceRole;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
|
||||||
|
class RepairWorkspaceOwners extends Page
|
||||||
|
{
|
||||||
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-wrench-screwdriver';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Repair workspace owners';
|
||||||
|
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = 'Recovery';
|
||||||
|
|
||||||
|
protected string $view = 'filament.system.pages.repair-workspace-owners';
|
||||||
|
|
||||||
|
public static function canAccess(): bool
|
||||||
|
{
|
||||||
|
$user = auth('platform')->user();
|
||||||
|
|
||||||
|
if (! $user instanceof PlatformUser) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->hasCapability(PlatformCapabilities::USE_BREAK_GLASS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action>
|
||||||
|
*/
|
||||||
|
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()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
72
app/Http/Controllers/SelectTenantController.php
Normal file
72
app/Http/Controllers/SelectTenantController.php
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Filament\Pages\TenantDashboard;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserTenantPreference;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
final class SelectTenantController
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = auth()->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()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/Http/Controllers/SwitchWorkspaceController.php
Normal file
61
app/Http/Controllers/SwitchWorkspaceController.php
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Filament\Pages\ChooseTenant;
|
||||||
|
use App\Filament\Pages\Tenancy\RegisterTenant as RegisterTenantPage;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class SwitchWorkspaceController
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = auth()->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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -32,6 +32,10 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($path, '/admin/workspaces')) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
if (in_array($path, ['/admin/no-access', '/admin/choose-workspace'], true)) {
|
if (in_array($path, ['/admin/no-access', '/admin/choose-workspace'], true)) {
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,7 +61,7 @@ public static function externalIdShort(?string $externalId): string
|
|||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
$backupSet = BackupSet::query()->find($this->backupSetId);
|
$backupSet = BackupSet::query()->find($this->backupSetId);
|
||||||
$tenantId = $backupSet?->tenant_id ?? Tenant::current()->getKey();
|
$tenantId = $backupSet?->tenant_id ?? Tenant::currentOrFail()->getKey();
|
||||||
$existingPolicyIds = $backupSet
|
$existingPolicyIds = $backupSet
|
||||||
? $backupSet->items()->pluck('policy_id')->filter()->all()
|
? $backupSet->items()->pluck('policy_id')->filter()->all()
|
||||||
: [];
|
: [];
|
||||||
|
|||||||
@ -117,7 +117,7 @@ public function makeCurrent(): void
|
|||||||
$this->forceFill(['is_current' => true]);
|
$this->forceFill(['is_current' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function current(): self
|
public static function current(): ?self
|
||||||
{
|
{
|
||||||
$filamentTenant = Filament::getTenant();
|
$filamentTenant = Filament::getTenant();
|
||||||
|
|
||||||
@ -146,6 +146,13 @@ public static function current(): self
|
|||||||
->where('is_current', true)
|
->where('is_current', true)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function currentOrFail(): self
|
||||||
|
{
|
||||||
|
$tenant = static::current();
|
||||||
|
|
||||||
if (! $tenant) {
|
if (! $tenant) {
|
||||||
throw new RuntimeException('No current tenant selected.');
|
throw new RuntimeException('No current tenant selected.');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,7 +26,11 @@ public function boot(): void
|
|||||||
$resolver = app(CapabilityResolver::class);
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
$defineTenantCapability = function (string $capability) use ($resolver): void {
|
$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);
|
return $resolver->can($user, $tenant, $capability);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
use App\Filament\Pages\NoAccess;
|
use App\Filament\Pages\NoAccess;
|
||||||
use App\Filament\Pages\Tenancy\RegisterTenant;
|
use App\Filament\Pages\Tenancy\RegisterTenant;
|
||||||
use App\Filament\Pages\TenantDashboard;
|
use App\Filament\Pages\TenantDashboard;
|
||||||
|
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
@ -15,6 +16,7 @@
|
|||||||
use Filament\Http\Middleware\AuthenticateSession;
|
use Filament\Http\Middleware\AuthenticateSession;
|
||||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||||
|
use Filament\Navigation\NavigationItem;
|
||||||
use Filament\Panel;
|
use Filament\Panel;
|
||||||
use Filament\PanelProvider;
|
use Filament\PanelProvider;
|
||||||
use Filament\Support\Colors\Color;
|
use Filament\Support\Colors\Color;
|
||||||
@ -38,6 +40,7 @@ public function panel(Panel $panel): Panel
|
|||||||
->path('admin')
|
->path('admin')
|
||||||
->login(Login::class)
|
->login(Login::class)
|
||||||
->authenticatedRoutes(function (Panel $panel): void {
|
->authenticatedRoutes(function (Panel $panel): void {
|
||||||
|
WorkspaceResource::registerRoutes($panel);
|
||||||
ChooseWorkspace::registerRoutes($panel);
|
ChooseWorkspace::registerRoutes($panel);
|
||||||
ChooseTenant::registerRoutes($panel);
|
ChooseTenant::registerRoutes($panel);
|
||||||
NoAccess::registerRoutes($panel);
|
NoAccess::registerRoutes($panel);
|
||||||
@ -50,10 +53,21 @@ public function panel(Panel $panel): Panel
|
|||||||
->colors([
|
->colors([
|
||||||
'primary' => Color::Amber,
|
'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(
|
->renderHook(
|
||||||
PanelsRenderHook::HEAD_END,
|
PanelsRenderHook::HEAD_END,
|
||||||
fn () => view('filament.partials.livewire-intercept-shim')->render()
|
fn () => view('filament.partials.livewire-intercept-shim')->render()
|
||||||
)
|
)
|
||||||
|
->renderHook(
|
||||||
|
PanelsRenderHook::USER_MENU_PROFILE_AFTER,
|
||||||
|
fn () => view('filament.partials.workspace-switcher')->render()
|
||||||
|
)
|
||||||
->renderHook(
|
->renderHook(
|
||||||
PanelsRenderHook::BODY_END,
|
PanelsRenderHook::BODY_END,
|
||||||
fn () => (bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true)
|
fn () => (bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true)
|
||||||
|
|||||||
@ -19,6 +19,9 @@ public function log(
|
|||||||
string $status = 'success',
|
string $status = 'success',
|
||||||
?string $resourceType = null,
|
?string $resourceType = null,
|
||||||
?string $resourceId = null,
|
?string $resourceId = null,
|
||||||
|
?int $actorId = null,
|
||||||
|
?string $actorEmail = null,
|
||||||
|
?string $actorName = null,
|
||||||
): AuditLog {
|
): AuditLog {
|
||||||
$metadata = $context['metadata'] ?? [];
|
$metadata = $context['metadata'] ?? [];
|
||||||
unset($context['metadata']);
|
unset($context['metadata']);
|
||||||
@ -26,9 +29,9 @@ public function log(
|
|||||||
return AuditLog::create([
|
return AuditLog::create([
|
||||||
'tenant_id' => null,
|
'tenant_id' => null,
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
'actor_id' => $actor?->getKey(),
|
'actor_id' => $actor?->getKey() ?? $actorId,
|
||||||
'actor_email' => $actor?->email,
|
'actor_email' => $actor?->email ?? $actorEmail,
|
||||||
'actor_name' => $actor?->name,
|
'actor_name' => $actor?->name ?? $actorName,
|
||||||
'action' => $action,
|
'action' => $action,
|
||||||
'resource_type' => $resourceType,
|
'resource_type' => $resourceType,
|
||||||
'resource_id' => $resourceId,
|
'resource_id' => $resourceId,
|
||||||
|
|||||||
@ -28,65 +28,82 @@ public function addMember(
|
|||||||
$this->assertValidRole($role);
|
$this->assertValidRole($role);
|
||||||
$this->assertActorCanManage($actor, $workspace);
|
$this->assertActorCanManage($actor, $workspace);
|
||||||
|
|
||||||
return DB::transaction(function () use ($workspace, $actor, $member, $role, $source): WorkspaceMembership {
|
try {
|
||||||
$existing = WorkspaceMembership::query()
|
return DB::transaction(function () use ($workspace, $actor, $member, $role, $source): WorkspaceMembership {
|
||||||
->where('workspace_id', (int) $workspace->getKey())
|
$existing = WorkspaceMembership::query()
|
||||||
->where('user_id', (int) $member->getKey())
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
->first();
|
->where('user_id', (int) $member->getKey())
|
||||||
|
->first();
|
||||||
|
|
||||||
if ($existing) {
|
if ($existing) {
|
||||||
if ($existing->role !== $role) {
|
if ($existing->role !== $role) {
|
||||||
$fromRole = (string) $existing->role;
|
$fromRole = (string) $existing->role;
|
||||||
|
|
||||||
$existing->forceFill([
|
$this->guardLastOwnerDemotion($workspace, $existing, $role);
|
||||||
'role' => $role,
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$this->auditLogger->log(
|
$existing->forceFill([
|
||||||
workspace: $workspace,
|
'role' => $role,
|
||||||
action: AuditActionId::WorkspaceMembershipRoleChange->value,
|
])->save();
|
||||||
context: [
|
|
||||||
'metadata' => [
|
$this->auditLogger->log(
|
||||||
'member_user_id' => (int) $member->getKey(),
|
workspace: $workspace,
|
||||||
'from_role' => $fromRole,
|
action: AuditActionId::WorkspaceMembershipRoleChange->value,
|
||||||
'to_role' => $role,
|
context: [
|
||||||
'source' => $source,
|
'metadata' => [
|
||||||
|
'member_user_id' => (int) $member->getKey(),
|
||||||
|
'from_role' => $fromRole,
|
||||||
|
'to_role' => $role,
|
||||||
|
'source' => $source,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
actor: $actor,
|
||||||
actor: $actor,
|
status: 'success',
|
||||||
status: 'success',
|
resourceType: 'workspace',
|
||||||
resourceType: 'workspace',
|
resourceId: (string) $workspace->getKey(),
|
||||||
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([
|
throw $exception;
|
||||||
'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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function changeRole(Workspace $workspace, User $actor, WorkspaceMembership $membership, string $newRole): WorkspaceMembership
|
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) {
|
} catch (DomainException $exception) {
|
||||||
if ($exception->getMessage() === 'You cannot demote the last remaining owner.') {
|
if ($exception->getMessage() === 'You cannot demote the last remaining owner.') {
|
||||||
$this->auditLogger->log(
|
$this->auditLastOwnerBlocked(
|
||||||
workspace: $workspace,
|
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,
|
actor: $actor,
|
||||||
status: 'blocked',
|
targetUserId: (int) $membership->user_id,
|
||||||
resourceType: 'workspace',
|
attemptedRole: $newRole,
|
||||||
resourceId: (string) $workspace->getKey(),
|
currentRole: (string) $membership->role,
|
||||||
|
attemptedAction: 'role_change',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -191,20 +201,13 @@ public function removeMember(Workspace $workspace, User $actor, WorkspaceMembers
|
|||||||
});
|
});
|
||||||
} catch (DomainException $exception) {
|
} catch (DomainException $exception) {
|
||||||
if ($exception->getMessage() === 'You cannot remove the last remaining owner.') {
|
if ($exception->getMessage() === 'You cannot remove the last remaining owner.') {
|
||||||
$this->auditLogger->log(
|
$this->auditLastOwnerBlocked(
|
||||||
workspace: $workspace,
|
workspace: $workspace,
|
||||||
action: AuditActionId::WorkspaceMembershipLastOwnerBlocked->value,
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'member_user_id' => (int) $membership->user_id,
|
|
||||||
'role' => (string) $membership->role,
|
|
||||||
'attempted_action' => 'remove',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
actor: $actor,
|
actor: $actor,
|
||||||
status: 'blocked',
|
targetUserId: (int) $membership->user_id,
|
||||||
resourceType: 'workspace',
|
attemptedRole: (string) $membership->role,
|
||||||
resourceId: (string) $workspace->getKey(),
|
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.');
|
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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,12 @@
|
|||||||
|
|
||||||
enum AuditActionId: string
|
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 TenantMembershipAdd = 'tenant_membership.add';
|
||||||
case TenantMembershipRoleChange = 'tenant_membership.role_change';
|
case TenantMembershipRoleChange = 'tenant_membership.role_change';
|
||||||
case TenantMembershipRemove = 'tenant_membership.remove';
|
case TenantMembershipRemove = 'tenant_membership.remove';
|
||||||
|
|||||||
@ -10,6 +10,8 @@
|
|||||||
use Closure;
|
use Closure;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Models\Contracts\HasTenants;
|
use Filament\Models\Contracts\HasTenants;
|
||||||
|
use Filament\Navigation\NavigationBuilder;
|
||||||
|
use Filament\Navigation\NavigationItem;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
@ -20,6 +22,8 @@ class EnsureFilamentTenantSelected
|
|||||||
*/
|
*/
|
||||||
public function handle(Request $request, Closure $next): Response
|
public function handle(Request $request, Closure $next): Response
|
||||||
{
|
{
|
||||||
|
$panel = Filament::getCurrentOrDefaultPanel();
|
||||||
|
|
||||||
if ($request->route()?->hasParameter('tenant')) {
|
if ($request->route()?->hasParameter('tenant')) {
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
|
||||||
@ -31,8 +35,6 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$panel = Filament::getCurrentOrDefaultPanel();
|
|
||||||
|
|
||||||
if (! $panel->hasTenancy()) {
|
if (! $panel->hasTenancy()) {
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
@ -72,54 +74,105 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
|
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$this->configureNavigationForRequest($panel);
|
||||||
|
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filled(Filament::getTenant())) {
|
if (filled(Filament::getTenant())) {
|
||||||
|
$this->configureNavigationForRequest($panel);
|
||||||
|
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User) {
|
||||||
|
$this->configureNavigationForRequest($panel);
|
||||||
|
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = null;
|
$tenant = null;
|
||||||
|
|
||||||
try {
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId($request);
|
||||||
$tenant = Tenant::current();
|
|
||||||
} catch (\RuntimeException) {
|
if ($workspaceId !== null) {
|
||||||
$tenant = 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)) {
|
if (! $tenant) {
|
||||||
$tenant = null;
|
try {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
} catch (\RuntimeException) {
|
||||||
|
$tenant = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant && ! app(CapabilityResolver::class)->isMember($user, $tenant)) {
|
||||||
|
$tenant = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $tenant) {
|
if (! $tenant) {
|
||||||
$tenant = $user->tenants()
|
$tenant = $user->tenants()
|
||||||
->whereNull('deleted_at')
|
|
||||||
->where('status', 'active')
|
->where('status', 'active')
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $tenant) {
|
if (! $tenant) {
|
||||||
$tenant = $user->tenants()
|
$tenant = $user->tenants()->first();
|
||||||
->whereNull('deleted_at')
|
|
||||||
->first();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $tenant) {
|
if (! $tenant) {
|
||||||
$tenant = $user->tenants()
|
$tenant = $user->tenants()->withTrashed()->first();
|
||||||
->withTrashed()
|
|
||||||
->first();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($tenant) {
|
if ($tenant) {
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->configureNavigationForRequest($panel);
|
||||||
|
|
||||||
return $next($request);
|
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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -88,10 +88,6 @@ public function resolveInitialWorkspaceFor(User $user, ?Request $request = null)
|
|||||||
|
|
||||||
if (! $workspace instanceof Workspace || ! $this->isWorkspaceSelectable($workspace) || ! $this->isMember($user, $workspace)) {
|
if (! $workspace instanceof Workspace || ! $this->isWorkspaceSelectable($workspace) || ! $this->isMember($user, $workspace)) {
|
||||||
$user->forceFill(['last_workspace_id' => null])->save();
|
$user->forceFill(['last_workspace_id' => null])->save();
|
||||||
} else {
|
|
||||||
$session->put(self::SESSION_KEY, (int) $workspace->getKey());
|
|
||||||
|
|
||||||
return $workspace;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,25 +11,62 @@
|
|||||||
|
|
||||||
@if ($tenants->isEmpty())
|
@if ($tenants->isEmpty())
|
||||||
<div class="rounded-md border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
|
<div class="rounded-md border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
|
||||||
No tenants are available for your account.
|
<div class="font-medium text-gray-900 dark:text-gray-100">No tenants are available for your account.</div>
|
||||||
|
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
@if ($this->canRegisterTenant())
|
||||||
|
Register a tenant for this workspace, or switch workspaces.
|
||||||
|
@else
|
||||||
|
Switch workspaces, or contact an administrator.
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
|
||||||
|
@if ($this->canRegisterTenant())
|
||||||
|
<x-filament::button
|
||||||
|
type="button"
|
||||||
|
color="primary"
|
||||||
|
tag="a"
|
||||||
|
href="{{ route('filament.admin.tenant.registration') }}"
|
||||||
|
>
|
||||||
|
Register tenant
|
||||||
|
</x-filament::button>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<x-filament::button
|
||||||
|
type="button"
|
||||||
|
color="gray"
|
||||||
|
tag="a"
|
||||||
|
href="{{ route('filament.admin.pages.choose-workspace') }}"
|
||||||
|
>
|
||||||
|
Change workspace
|
||||||
|
</x-filament::button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
@foreach ($tenants as $tenant)
|
@foreach ($tenants as $tenant)
|
||||||
<div wire:key="tenant-{{ $tenant->id }}" class="rounded-lg border border-gray-200 p-4 dark:border-gray-800">
|
<div
|
||||||
<div class="flex flex-col gap-3">
|
wire:key="tenant-{{ $tenant->id }}"
|
||||||
|
x-data
|
||||||
|
@click="if ($event.target.closest('button,a,input,select,textarea')) return; $refs.form.submit();"
|
||||||
|
class="cursor-pointer rounded-lg border border-gray-200 p-4 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
<form x-ref="form" method="POST" action="{{ route('admin.select-tenant') }}" class="flex flex-col gap-3">
|
||||||
|
@csrf
|
||||||
|
<input type="hidden" name="tenant_id" value="{{ (int) $tenant->id }}" />
|
||||||
|
|
||||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
<div class="font-medium text-gray-900 dark:text-gray-100">
|
||||||
{{ $tenant->name }}
|
{{ $tenant->name }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<x-filament::button
|
<x-filament::button
|
||||||
type="button"
|
type="submit"
|
||||||
color="primary"
|
color="primary"
|
||||||
wire:click="selectTenant({{ (int) $tenant->id }})"
|
class="w-full"
|
||||||
>
|
>
|
||||||
Continue
|
Continue
|
||||||
</x-filament::button>
|
</x-filament::button>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,6 +7,14 @@
|
|||||||
|
|
||||||
@php
|
@php
|
||||||
$workspaces = $this->getWorkspaces();
|
$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
|
@endphp
|
||||||
|
|
||||||
@if ($workspaces->isEmpty())
|
@if ($workspaces->isEmpty())
|
||||||
@ -17,20 +25,42 @@
|
|||||||
@else
|
@else
|
||||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
@foreach ($workspaces as $workspace)
|
@foreach ($workspaces as $workspace)
|
||||||
<div wire:key="workspace-{{ $workspace->id }}" class="rounded-lg border border-gray-200 p-4 dark:border-gray-800">
|
@php
|
||||||
<div class="flex flex-col gap-3">
|
$isRecommended = $recommendedWorkspaceId > 0 && (int) $workspace->id === $recommendedWorkspaceId;
|
||||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
@endphp
|
||||||
{{ $workspace->name }}
|
|
||||||
|
<div
|
||||||
|
wire:key="workspace-{{ $workspace->id }}"
|
||||||
|
x-data
|
||||||
|
@click="if ($event.target.closest('button,a,input,select,textarea')) return; $refs.form.submit();"
|
||||||
|
class="cursor-pointer rounded-lg border p-4 dark:border-gray-800 {{ $isRecommended ? 'border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/30' : 'border-gray-200' }}"
|
||||||
|
>
|
||||||
|
<form x-ref="form" method="POST" action="{{ route('admin.switch-workspace') }}" class="flex flex-col gap-3">
|
||||||
|
@csrf
|
||||||
|
<input type="hidden" name="workspace_id" value="{{ (int) $workspace->id }}" />
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{{ $workspace->name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($isRecommended)
|
||||||
|
<div>
|
||||||
|
<x-filament::badge color="warning" size="sm">
|
||||||
|
Last used
|
||||||
|
</x-filament::badge>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<x-filament::button
|
<x-filament::button
|
||||||
type="button"
|
type="submit"
|
||||||
color="primary"
|
color="primary"
|
||||||
wire:click="selectWorkspace({{ (int) $workspace->id }})"
|
class="w-full"
|
||||||
>
|
>
|
||||||
Continue
|
Continue
|
||||||
</x-filament::button>
|
</x-filament::button>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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())
|
||||||
|
<x-filament::dropdown.list>
|
||||||
|
<div class="px-3 py-2">
|
||||||
|
<form method="POST" action="{{ route('admin.switch-workspace') }}" class="space-y-2">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Workspace</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
name="workspace_id"
|
||||||
|
class="fi-input fi-select w-full"
|
||||||
|
x-data
|
||||||
|
x-on:change="$el.form.submit()"
|
||||||
|
>
|
||||||
|
@foreach ($workspaces as $workspace)
|
||||||
|
<option value="{{ $workspace->getKey() }}" {{ (int) $workspace->getKey() === (int) $currentWorkspaceId ? 'selected' : '' }}>
|
||||||
|
{{ $workspace->name }}
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Switch workspace</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</x-filament::dropdown.list>
|
||||||
|
@endif
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
|
||||||
|
<div class="text-sm text-gray-700 dark:text-gray-200">
|
||||||
|
<p class="font-medium">Purpose</p>
|
||||||
|
<p class="mt-1">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-filament-panels::page>
|
||||||
@ -4,6 +4,8 @@
|
|||||||
use App\Http\Controllers\AdminConsentCallbackController;
|
use App\Http\Controllers\AdminConsentCallbackController;
|
||||||
use App\Http\Controllers\Auth\EntraController;
|
use App\Http\Controllers\Auth\EntraController;
|
||||||
use App\Http\Controllers\RbacDelegatedAuthController;
|
use App\Http\Controllers\RbacDelegatedAuthController;
|
||||||
|
use App\Http\Controllers\SelectTenantController;
|
||||||
|
use App\Http\Controllers\SwitchWorkspaceController;
|
||||||
use App\Http\Controllers\TenantOnboardingController;
|
use App\Http\Controllers\TenantOnboardingController;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
||||||
@ -25,6 +27,30 @@
|
|||||||
Route::get('/admin/consent/start', TenantOnboardingController::class)
|
Route::get('/admin/consent/start', TenantOnboardingController::class)
|
||||||
->name('admin.consent.start');
|
->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.
|
// 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
|
// In this app, package route registration may not always define it early enough, which breaks
|
||||||
// rendering on tenant-scoped routes.
|
// rendering on tenant-scoped routes.
|
||||||
@ -92,6 +118,14 @@
|
|||||||
})
|
})
|
||||||
->name('admin.legacy.onboarding');
|
->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 {
|
Route::bind('workspace', function (string $value): Workspace {
|
||||||
/** @var WorkspaceResolver $resolver */
|
/** @var WorkspaceResolver $resolver */
|
||||||
$resolver = app(WorkspaceResolver::class);
|
$resolver = app(WorkspaceResolver::class);
|
||||||
|
|||||||
@ -10,11 +10,13 @@ ## Approach
|
|||||||
2. Move Managed Tenants list/onboarding UX to workspace-scoped routes.
|
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).
|
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).
|
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
|
## Key decisions
|
||||||
- **Workspace is not Filament tenancy**; it remains session + middleware.
|
- **Workspace is not Filament tenancy**; it remains session + middleware.
|
||||||
- Hard enforcement is implemented in middleware that runs on tenant-scoped routes.
|
- 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.
|
- 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)
|
## Files (expected)
|
||||||
- `routes/web.php`
|
- `routes/web.php`
|
||||||
|
|||||||
@ -12,6 +12,7 @@ ## Mental model (source of truth)
|
|||||||
## Goals
|
## Goals
|
||||||
- Workspace becomes a real, enforced context for all tenant-scoped pages.
|
- Workspace becomes a real, enforced context for all tenant-scoped pages.
|
||||||
- Keep Filament tenancy URL space unchanged: `/admin/t/{tenant_external_id}/...`.
|
- 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}/...`.
|
- 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/*`.
|
- Eliminate or redirect legacy unscoped Managed Tenants routes under `/admin/managed-tenants/*`.
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,23 @@ ## Core
|
|||||||
- [x] T110 Make unscoped `/admin/managed-tenants/*` legacy-only (redirect to workspace-scoped URLs).
|
- [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] T120 Implement hard enforcement: tenant routes require workspace context and tenant.workspace_id match.
|
||||||
- [x] T130 Ensure `/admin/choose-tenant` requires selected workspace.
|
- [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
|
## Validation
|
||||||
- [x] T900 Run Pint on dirty files.
|
- [x] T900 Run Pint on dirty files.
|
||||||
- [x] T910 Run targeted Pest tests.
|
- [x] T910 Run targeted Pest tests.
|
||||||
|
|
||||||
|
- [x] T920 Run targeted Pest tests for last-owner + recovery flow.
|
||||||
|
|||||||
89
tests/Feature/Auth/BreakGlassWorkspaceOwnerRecoveryTest.php
Normal file
89
tests/Feature/Auth/BreakGlassWorkspaceOwnerRecoveryTest.php
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\System\Pages\Dashboard;
|
||||||
|
use App\Filament\System\Pages\RepairWorkspaceOwners;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
|
use App\Support\Auth\WorkspaceRole;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
Filament::setCurrentPanel('system');
|
||||||
|
Filament::bootCurrentPanel();
|
||||||
|
|
||||||
|
Tenant::factory()->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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
96
tests/Feature/Auth/WorkspaceLastOwnerGuardTest.php
Normal file
96
tests/Feature/Auth/WorkspaceLastOwnerGuardTest.php
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Services\Auth\WorkspaceMembershipManager;
|
||||||
|
use App\Support\Auth\WorkspaceRole;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('blocks demoting 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->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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('redirects /admin to choose-tenant when a workspace is selected', 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')
|
||||||
|
->assertRedirect(route('filament.admin.pages.choose-tenant'));
|
||||||
|
});
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('does not show the register-tenant CTA for readonly 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' => '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');
|
||||||
|
});
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
Http::preventStrayRequests();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('highlights and prioritizes the last used workspace on choose-workspace', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$workspaceA = Workspace::factory()->create(['name' => 'Workspace A']);
|
||||||
|
$workspaceB = Workspace::factory()->create(['name' => 'Workspace B']);
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => $workspaceA->getKey(),
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => $workspaceB->getKey(),
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
Http::preventStrayRequests();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects to choose-workspace after login when user has multiple workspaces and no workspace is selected', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$workspaceA = Workspace::factory()->create(['name' => 'Workspace A']);
|
||||||
|
$workspaceB = Workspace::factory()->create(['name' => 'Workspace B']);
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => $workspaceA->getKey(),
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => $workspaceB->getKey(),
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->forceFill(['last_workspace_id' => (int) $workspaceA->getKey()])->save();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get('/admin')
|
||||||
|
->assertRedirect(route('filament.admin.pages.choose-workspace'));
|
||||||
|
});
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
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 App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('selects a tenant via POST and persists last-used', 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,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantMembership;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('does not show tenants from other workspaces on the tenants index', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$workspaceA = Workspace::factory()->create(['name' => 'Workspace A']);
|
||||||
|
$workspaceB = Workspace::factory()->create(['name' => 'Workspace B']);
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => $workspaceA->getKey(),
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => $workspaceB->getKey(),
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$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');
|
||||||
|
});
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('default Filament tenant selection respects current workspace context', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$workspaceA = Workspace::factory()->create(['name' => 'Workspace A']);
|
||||||
|
$workspaceB = Workspace::factory()->create(['name' => 'Workspace B']);
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => $workspaceA->getKey(),
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => $workspaceB->getKey(),
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$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);
|
||||||
|
});
|
||||||
@ -3,6 +3,8 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Filament\Pages\ChooseWorkspace;
|
use App\Filament\Pages\ChooseWorkspace;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantMembership;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
@ -11,7 +13,7 @@
|
|||||||
|
|
||||||
uses(RefreshDatabase::class);
|
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();
|
$user = User::factory()->create();
|
||||||
|
|
||||||
$workspace = Workspace::factory()->create();
|
$workspace = Workspace::factory()->create();
|
||||||
@ -21,6 +23,36 @@
|
|||||||
'role' => 'owner',
|
'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)
|
Livewire::actingAs($user)
|
||||||
->test(ChooseWorkspace::class)
|
->test(ChooseWorkspace::class)
|
||||||
->call('selectWorkspace', $workspace->getKey())
|
->call('selectWorkspace', $workspace->getKey())
|
||||||
|
|||||||
@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('redirects to tenant registration after switching to a workspace with 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)
|
||||||
|
->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());
|
||||||
|
});
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
Http::preventStrayRequests();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves the Workspaces UI tenantless at /admin/workspaces', 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')
|
||||||
|
->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();
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user