Compare commits
7 Commits
dev
...
feat/066-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccfd491260 | ||
|
|
d4148020bc | ||
|
|
ea72c34398 | ||
|
|
95ccc3008c | ||
|
|
a53bb3f708 | ||
|
|
07dda36d6e | ||
|
|
e5ad9b6cf8 |
8
.github/agents/copilot-instructions.md
vendored
8
.github/agents/copilot-instructions.md
vendored
@ -12,10 +12,6 @@ ## Active Technologies
|
||||
- PostgreSQL (JSONB for `InventoryItem.meta_jsonb`) (feat/047-inventory-foundations-nodes)
|
||||
- PostgreSQL (JSONB in `operation_runs.context`, `operation_runs.summary_counts`) (056-remove-legacy-bulkops)
|
||||
- PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1 (058-tenant-ui-polish)
|
||||
- PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4 (067-rbac-troubleshooting)
|
||||
- PostgreSQL (via Laravel Sail) (067-rbac-troubleshooting)
|
||||
- PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x (073-unified-managed-tenant-onboarding-wizard)
|
||||
- PostgreSQL (Sail) + SQLite in tests where applicable (073-unified-managed-tenant-onboarding-wizard)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -35,9 +31,9 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 073-unified-managed-tenant-onboarding-wizard: Added PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x
|
||||
- 067-rbac-troubleshooting: Added PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4
|
||||
- 058-tenant-ui-polish: Added PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1
|
||||
- 058-tenant-ui-polish: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
|
||||
- 056-remove-legacy-bulkops: Added PHP 8.4.x + Laravel 12, Filament v4, Livewire v3
|
||||
|
||||
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
|
||||
@ -34,6 +34,6 @@ private function resolveTenant(): Tenant
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
return Tenant::currentOrFail();
|
||||
return Tenant::current();
|
||||
}
|
||||
}
|
||||
|
||||
@ -138,7 +138,7 @@ private function resolveTenants()
|
||||
}
|
||||
|
||||
try {
|
||||
return collect([Tenant::currentOrFail()]);
|
||||
return collect([Tenant::current()]);
|
||||
} catch (RuntimeException) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
@ -1,172 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class ChooseWorkspace extends Page
|
||||
{
|
||||
protected static string $layout = 'filament-panels::components.layout.simple';
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static ?string $slug = 'choose-workspace';
|
||||
|
||||
protected static ?string $title = 'Choose workspace';
|
||||
|
||||
protected string $view = 'filament.pages.choose-workspace';
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('createWorkspace')
|
||||
->label('Create workspace')
|
||||
->modalHeading('Create workspace')
|
||||
->form([
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('slug')
|
||||
->helperText('Optional. Used in URLs if set.')
|
||||
->maxLength(255)
|
||||
->rules(['nullable', 'string', 'max:255', 'alpha_dash', 'unique:workspaces,slug'])
|
||||
->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null)
|
||||
->dehydrated(fn ($state) => filled($state)),
|
||||
])
|
||||
->action(fn (array $data) => $this->createWorkspace($data)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Workspace>
|
||||
*/
|
||||
public function getWorkspaces(): Collection
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return Workspace::query()->whereRaw('1 = 0')->get();
|
||||
}
|
||||
|
||||
return 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();
|
||||
}
|
||||
|
||||
public function selectWorkspace(int $workspaceId): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! empty($workspace->archived_at)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$context = app(WorkspaceContext::class);
|
||||
|
||||
if (! $context->isMember($user, $workspace)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$context->setCurrentWorkspace($workspace, $user, request());
|
||||
|
||||
$this->redirect($this->redirectAfterWorkspaceSelected($user));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{name: string, slug?: string|null} $data
|
||||
*/
|
||||
public function createWorkspace(array $data): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->create([
|
||||
'name' => $data['name'],
|
||||
'slug' => $data['slug'] ?? null,
|
||||
]);
|
||||
|
||||
WorkspaceMembership::query()->create([
|
||||
'workspace_id' => $workspace->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
app(WorkspaceContext::class)->setCurrentWorkspace($workspace, $user, request());
|
||||
|
||||
Notification::make()
|
||||
->title('Workspace created')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->redirect($this->redirectAfterWorkspaceSelected($user));
|
||||
}
|
||||
|
||||
private function redirectAfterWorkspaceSelected(User $user): string
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||
|
||||
if ($workspaceId === null) {
|
||||
return self::getUrl();
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return self::getUrl();
|
||||
}
|
||||
|
||||
$tenantsQuery = $user->tenants()
|
||||
->where('workspace_id', $workspace->getKey())
|
||||
->where('status', 'active');
|
||||
|
||||
$tenantCount = (int) $tenantsQuery->count();
|
||||
|
||||
if ($tenantCount === 0) {
|
||||
return route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()]);
|
||||
}
|
||||
|
||||
if ($tenantCount === 1) {
|
||||
$tenant = $tenantsQuery->first();
|
||||
|
||||
if ($tenant !== null) {
|
||||
return TenantDashboard::getUrl(tenant: $tenant);
|
||||
}
|
||||
}
|
||||
|
||||
return ChooseTenant::getUrl();
|
||||
}
|
||||
}
|
||||
@ -4,13 +4,6 @@
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
|
||||
class NoAccess extends Page
|
||||
@ -26,60 +19,4 @@ class NoAccess extends Page
|
||||
protected static ?string $title = 'No access';
|
||||
|
||||
protected string $view = 'filament.pages.no-access';
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('createWorkspace')
|
||||
->label('Create workspace')
|
||||
->modalHeading('Create workspace')
|
||||
->form([
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('slug')
|
||||
->helperText('Optional. Used in URLs if set.')
|
||||
->maxLength(255)
|
||||
->rules(['nullable', 'string', 'max:255', 'alpha_dash', 'unique:workspaces,slug'])
|
||||
->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null)
|
||||
->dehydrated(fn ($state) => filled($state)),
|
||||
])
|
||||
->action(fn (array $data) => $this->createWorkspace($data)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{name: string, slug?: string|null} $data
|
||||
*/
|
||||
public function createWorkspace(array $data): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->create([
|
||||
'name' => $data['name'],
|
||||
'slug' => $data['slug'] ?? null,
|
||||
]);
|
||||
|
||||
WorkspaceMembership::query()->create([
|
||||
'workspace_id' => $workspace->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
app(WorkspaceContext::class)->setCurrentWorkspace($workspace, $user, request());
|
||||
|
||||
Notification::make()
|
||||
->title('Workspace created')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->redirect(ChooseTenant::getUrl());
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,11 +4,9 @@
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Forms;
|
||||
use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant;
|
||||
use Filament\Schemas\Schema;
|
||||
@ -29,20 +27,6 @@ public static function canView(): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||
|
||||
if ($workspaceId !== null) {
|
||||
$canRegisterInWorkspace = WorkspaceMembership::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('user_id', $user->getKey())
|
||||
->whereIn('role', ['owner', 'manager'])
|
||||
->exists();
|
||||
|
||||
if ($canRegisterInWorkspace) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$tenantIds = $user->tenants()->withTrashed()->pluck('tenants.id');
|
||||
|
||||
if ($tenantIds->isEmpty()) {
|
||||
@ -111,12 +95,6 @@ protected function handleRegistration(array $data): Model
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||
|
||||
if ($workspaceId !== null) {
|
||||
$data['workspace_id'] = $workspaceId;
|
||||
}
|
||||
|
||||
$tenant = Tenant::create($data);
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
@ -1,108 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantMembership;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\TenantDiagnosticsService;
|
||||
use App\Services\Auth\TenantMembershipManager;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Pages\Page;
|
||||
|
||||
class TenantDiagnostics extends Page
|
||||
{
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static ?string $slug = 'diagnostics';
|
||||
|
||||
protected string $view = 'filament.pages.tenant-diagnostics';
|
||||
|
||||
public bool $missingOwner = false;
|
||||
|
||||
public bool $hasDuplicateMembershipsForCurrentUser = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
|
||||
$this->missingOwner = ! TenantMembership::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('role', 'owner')
|
||||
->exists();
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
abort(403, 'Not allowed');
|
||||
}
|
||||
|
||||
$this->hasDuplicateMembershipsForCurrentUser = app(TenantDiagnosticsService::class)
|
||||
->userHasDuplicateMemberships($tenant, $user);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
UiEnforcement::forAction(
|
||||
Action::make('bootstrapOwner')
|
||||
->label('Bootstrap owner')
|
||||
->requiresConfirmation()
|
||||
->action(fn () => $this->bootstrapOwner()),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->destructive()
|
||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||
->apply()
|
||||
->visible(fn (): bool => $this->missingOwner),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Action::make('mergeDuplicateMemberships')
|
||||
->label('Merge duplicate memberships')
|
||||
->requiresConfirmation()
|
||||
->action(fn () => $this->mergeDuplicateMemberships()),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->destructive()
|
||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||
->apply()
|
||||
->visible(fn (): bool => $this->hasDuplicateMembershipsForCurrentUser),
|
||||
];
|
||||
}
|
||||
|
||||
public function bootstrapOwner(): void
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
abort(403, 'Not allowed');
|
||||
}
|
||||
|
||||
app(TenantMembershipManager::class)->bootstrapRecover($tenant, $user, $user);
|
||||
|
||||
$this->mount();
|
||||
}
|
||||
|
||||
public function mergeDuplicateMemberships(): void
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
abort(403, 'Not allowed');
|
||||
}
|
||||
|
||||
app(TenantDiagnosticsService::class)->mergeDuplicateMembershipsForUser($tenant, $user, $user);
|
||||
|
||||
$this->mount();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,79 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages\Workspaces;
|
||||
|
||||
use App\Filament\Pages\ChooseTenant;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class ManagedTenantsLanding extends Page
|
||||
{
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static ?string $title = 'Managed tenants';
|
||||
|
||||
protected string $view = 'filament.pages.workspaces.managed-tenants-landing';
|
||||
|
||||
public Workspace $workspace;
|
||||
|
||||
public function mount(Workspace $workspace): void
|
||||
{
|
||||
$this->workspace = $workspace;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Tenant>
|
||||
*/
|
||||
public function getTenants(): Collection
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return Tenant::query()->whereRaw('1 = 0')->get();
|
||||
}
|
||||
|
||||
return $user->tenants()
|
||||
->where('workspace_id', $this->workspace->getKey())
|
||||
->where('status', 'active')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function goToChooseTenant(): void
|
||||
{
|
||||
$this->redirect(ChooseTenant::getUrl());
|
||||
}
|
||||
|
||||
public function openTenant(int $tenantId): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()
|
||||
->where('status', 'active')
|
||||
->where('workspace_id', $this->workspace->getKey())
|
||||
->whereKey($tenantId)
|
||||
->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
|
||||
}
|
||||
}
|
||||
@ -938,7 +938,7 @@ public static function table(Table $table): Table
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenantId = Tenant::currentOrFail()->getKey();
|
||||
$tenantId = Tenant::current()->getKey();
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->where('tenant_id', $tenantId)
|
||||
@ -1054,7 +1054,7 @@ public static function ensurePolicyTypes(array $data): array
|
||||
|
||||
public static function assignTenant(array $data): array
|
||||
{
|
||||
$data['tenant_id'] = Tenant::currentOrFail()->getKey();
|
||||
$data['tenant_id'] = Tenant::current()->getKey();
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
@ -21,7 +21,7 @@ class BackupScheduleRunsRelationManager extends RelationManager
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey())->with('backupSet'))
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::current()->getKey())->with('backupSet'))
|
||||
->defaultSort('scheduled_for', 'desc')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('scheduled_for')
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\OperationRunResource\Pages;
|
||||
use App\Filament\Support\VerificationReportViewer;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
@ -137,35 +136,12 @@ public static function infolist(Schema $schema): Schema
|
||||
->visible(fn (OperationRun $record): bool => ! empty($record->failure_summary))
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Verification report')
|
||||
->schema([
|
||||
ViewEntry::make('verification_report')
|
||||
->label('')
|
||||
->view('filament.components.verification-report-viewer')
|
||||
->state(fn (OperationRun $record): ?array => VerificationReportViewer::report($record))
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->visible(fn (OperationRun $record): bool => VerificationReportViewer::shouldRenderForRun($record))
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Context')
|
||||
->schema([
|
||||
ViewEntry::make('context')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(function (OperationRun $record): array {
|
||||
$context = $record->context ?? [];
|
||||
$context = is_array($context) ? $context : [];
|
||||
|
||||
if (array_key_exists('verification_report', $context)) {
|
||||
$context['verification_report'] = [
|
||||
'redacted' => true,
|
||||
'note' => 'Rendered in the Verification report section.',
|
||||
];
|
||||
}
|
||||
|
||||
return $context;
|
||||
})
|
||||
->state(fn (OperationRun $record): array => $record->context ?? [])
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
|
||||
@ -894,7 +894,7 @@ public static function table(Table $table): Table
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenantId = Tenant::currentOrFail()->getKey();
|
||||
$tenantId = Tenant::current()->getKey();
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->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
|
||||
{
|
||||
$tenantId = Tenant::currentOrFail()->getKey();
|
||||
$tenantId = Tenant::current()->getKey();
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages;
|
||||
use App\Jobs\ProviderComplianceSnapshotJob;
|
||||
use App\Jobs\ProviderConnectionHealthCheckJob;
|
||||
use App\Jobs\ProviderInventorySyncJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
@ -14,7 +15,6 @@
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Providers\CredentialManager;
|
||||
use App\Services\Providers\ProviderOperationStartGate;
|
||||
use App\Services\Verification\StartVerification;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
@ -175,22 +175,29 @@ public static function table(Table $table): Table
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('success')
|
||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||
->action(function (ProviderConnection $record, StartVerification $verification): void {
|
||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
$initiator = $user;
|
||||
|
||||
$result = $verification->providerConnectionCheck(
|
||||
$result = $gate->start(
|
||||
tenant: $tenant,
|
||||
connection: $record,
|
||||
initiator: $user,
|
||||
operationType: 'provider.connection.check',
|
||||
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
|
||||
ProviderConnectionHealthCheckJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $initiator->getKey(),
|
||||
providerConnectionId: (int) $record->getKey(),
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $initiator,
|
||||
);
|
||||
|
||||
if ($result->status === 'scope_busy') {
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Jobs\ProviderComplianceSnapshotJob;
|
||||
use App\Jobs\ProviderConnectionHealthCheckJob;
|
||||
use App\Jobs\ProviderInventorySyncJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
@ -13,7 +14,6 @@
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Providers\CredentialManager;
|
||||
use App\Services\Providers\ProviderOperationStartGate;
|
||||
use App\Services\Verification\StartVerification;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
@ -163,11 +163,11 @@ protected function getHeaderActions(): array
|
||||
$user = auth()->user();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& $user instanceof User
|
||||
&& $user instanceof User
|
||||
&& $user->canAccessTenant($tenant)
|
||||
&& $record->status !== 'disabled';
|
||||
})
|
||||
->action(function (ProviderConnection $record, StartVerification $verification): void {
|
||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
@ -185,9 +185,18 @@ protected function getHeaderActions(): array
|
||||
|
||||
$initiator = $user;
|
||||
|
||||
$result = $verification->providerConnectionCheck(
|
||||
$result = $gate->start(
|
||||
tenant: $tenant,
|
||||
connection: $record,
|
||||
operationType: 'provider.connection.check',
|
||||
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
|
||||
ProviderConnectionHealthCheckJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $initiator->getKey(),
|
||||
providerConnectionId: (int) $record->getKey(),
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $initiator,
|
||||
);
|
||||
|
||||
@ -233,8 +242,9 @@ protected function getHeaderActions(): array
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||
->tooltip('You do not have permission to run provider operations.')
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
|
||||
@ -87,7 +87,7 @@ public static function form(Schema $schema): Schema
|
||||
Forms\Components\Select::make('backup_set_id')
|
||||
->label('Backup set')
|
||||
->options(function () {
|
||||
$tenantId = Tenant::currentOrFail()->getKey();
|
||||
$tenantId = Tenant::current()->getKey();
|
||||
|
||||
return BackupSet::query()
|
||||
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
|
||||
@ -219,7 +219,7 @@ public static function getWizardSteps(): array
|
||||
Forms\Components\Select::make('backup_set_id')
|
||||
->label('Backup set')
|
||||
->options(function () {
|
||||
$tenantId = Tenant::currentOrFail()->getKey();
|
||||
$tenantId = Tenant::current()->getKey();
|
||||
|
||||
return BackupSet::query()
|
||||
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
|
||||
|
||||
@ -9,7 +9,6 @@
|
||||
use App\Jobs\SyncPoliciesJob;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\RoleCapabilityMap;
|
||||
use App\Services\Directory\EntraGroupLabelResolver;
|
||||
@ -22,7 +21,6 @@
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\UiTooltips;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
@ -30,8 +28,6 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\ActionGroup;
|
||||
@ -72,21 +68,7 @@ public static function canCreate(): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
if (static::userCanManageAnyTenant($user)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||
|
||||
if ($workspaceId === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return WorkspaceMembership::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('user_id', $user->getKey())
|
||||
->whereIn('role', ['owner', 'manager'])
|
||||
->exists();
|
||||
return static::userCanManageAnyTenant($user);
|
||||
}
|
||||
|
||||
public static function canEdit(Model $record): bool
|
||||
@ -195,15 +177,8 @@ public static function getEloquentQuery(): Builder
|
||||
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if ($workspaceId === null) {
|
||||
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
$tenantIds = $user->tenants()
|
||||
->withTrashed()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->pluck('tenants.id');
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
@ -287,57 +262,95 @@ public static function table(Table $table): Table
|
||||
->label('View')
|
||||
->icon('heroicon-o-eye')
|
||||
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record], tenant: $record)),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('syncTenant')
|
||||
->label('Sync')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->requiresConfirmation()
|
||||
->visible(function (Tenant $record): bool {
|
||||
if (! $record->isActive()) {
|
||||
return false;
|
||||
}
|
||||
Actions\Action::make('syncTenant')
|
||||
->label('Sync')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->requiresConfirmation()
|
||||
->visible(function (Tenant $record): bool {
|
||||
if (! $record->isActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->canAccessTenant($record);
|
||||
})
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger, \Filament\Tables\Contracts\HasTable $livewire): void {
|
||||
$user = auth()->user();
|
||||
return $user->canAccessTenant($record);
|
||||
})
|
||||
->disabled(function (Tenant $record): bool {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
if (! $user instanceof User) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($record)) {
|
||||
abort(404);
|
||||
}
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
return ! $resolver->can($user, $record, Capabilities::TENANT_SYNC);
|
||||
})
|
||||
->tooltip(function (Tenant $record): ?string {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_SYNC)) {
|
||||
abort(403);
|
||||
}
|
||||
if (! $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
$supportedTypes = config('tenantpilot.supported_policy_types', []);
|
||||
$typeNames = array_map(
|
||||
static fn (array $typeConfig): string => (string) $typeConfig['type'],
|
||||
$supportedTypes,
|
||||
return $resolver->can($user, $record, Capabilities::TENANT_SYNC)
|
||||
? null
|
||||
: 'You do not have permission to sync this tenant.';
|
||||
})
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger, \Filament\Tables\Contracts\HasTable $livewire): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($record)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_SYNC)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
|
||||
$supportedTypes = config('tenantpilot.supported_policy_types', []);
|
||||
$typeNames = array_map(
|
||||
static fn (array $typeConfig): string => (string) $typeConfig['type'],
|
||||
$supportedTypes,
|
||||
);
|
||||
sort($typeNames);
|
||||
|
||||
$inputs = [
|
||||
'scope' => 'full',
|
||||
'types' => $typeNames,
|
||||
];
|
||||
|
||||
$opRun = $opService->ensureRun(
|
||||
tenant: $record,
|
||||
type: 'policy.sync',
|
||||
inputs: $inputs,
|
||||
initiator: auth()->user()
|
||||
);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && $opService->isStaleQueuedRun($opRun)) {
|
||||
$opService->failStaleQueuedRun(
|
||||
$opRun,
|
||||
message: 'Run was queued but never started (likely a previous dispatch error). Re-queuing.'
|
||||
);
|
||||
sort($typeNames);
|
||||
|
||||
$inputs = [
|
||||
'scope' => 'full',
|
||||
'types' => $typeNames,
|
||||
];
|
||||
|
||||
$opRun = $opService->ensureRun(
|
||||
tenant: $record,
|
||||
@ -345,262 +358,295 @@ public static function table(Table $table): Table
|
||||
inputs: $inputs,
|
||||
initiator: auth()->user()
|
||||
);
|
||||
}
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && $opService->isStaleQueuedRun($opRun)) {
|
||||
$opService->failStaleQueuedRun(
|
||||
$opRun,
|
||||
message: 'Run was queued but never started (likely a previous dispatch error). Re-queuing.'
|
||||
);
|
||||
|
||||
$opRun = $opService->ensureRun(
|
||||
tenant: $record,
|
||||
type: 'policy.sync',
|
||||
inputs: $inputs,
|
||||
initiator: auth()->user()
|
||||
);
|
||||
}
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
Notification::make()
|
||||
->title('Policy sync already active')
|
||||
->body('This operation is already queued or running.')
|
||||
->warning()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View Run')
|
||||
->url(OperationRunLinks::view($opRun, $record)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$opService->dispatchOrFail($opRun, function () use ($record, $supportedTypes, $opRun): void {
|
||||
SyncPoliciesJob::dispatch((int) $record->getKey(), $supportedTypes, null, $opRun);
|
||||
});
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $record,
|
||||
action: 'tenant.sync_dispatched',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]],
|
||||
);
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
Notification::make()
|
||||
->title('Policy sync already active')
|
||||
->body('This operation is already queued or running.')
|
||||
->warning()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View Run')
|
||||
->url(OperationRunLinks::view($opRun, $record)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->apply(),
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$opService->dispatchOrFail($opRun, function () use ($record, $supportedTypes, $opRun): void {
|
||||
SyncPoliciesJob::dispatch((int) $record->getKey(), $supportedTypes, null, $opRun);
|
||||
});
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $record,
|
||||
action: 'tenant.sync_dispatched',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]],
|
||||
);
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View Run')
|
||||
->url(OperationRunLinks::view($opRun, $record)),
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
Actions\Action::make('openTenant')
|
||||
->label('Open')
|
||||
->icon('heroicon-o-arrow-right')
|
||||
->color('primary')
|
||||
->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', tenant: $record))
|
||||
->visible(fn (Tenant $record) => $record->isActive()),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('edit')
|
||||
->label('Edit')
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record], tenant: $record))
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('restore')
|
||||
->label('Restore')
|
||||
->color('success')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->successNotificationTitle('Tenant reactivated')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record): bool => $record->trashed())
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
||||
$user = auth()->user();
|
||||
Actions\Action::make('edit')
|
||||
->label('Edit')
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record], tenant: $record))
|
||||
->disabled(fn (Tenant $record): bool => ! static::canEdit($record))
|
||||
->tooltip(fn (Tenant $record): ?string => static::canEdit($record) ? null : 'You do not have permission to edit this tenant.'),
|
||||
Actions\Action::make('restore')
|
||||
->label('Restore')
|
||||
->color('success')
|
||||
->successNotificationTitle('Tenant reactivated')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record): bool => $record->trashed())
|
||||
->disabled(function (Tenant $record): bool {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
if (! $user instanceof User) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||
abort(403);
|
||||
}
|
||||
return ! $resolver->can($user, $record, Capabilities::TENANT_DELETE);
|
||||
})
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
||||
$user = auth()->user();
|
||||
|
||||
$record->restore();
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $record,
|
||||
action: 'tenant.restored',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
||||
);
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('admin_consent')
|
||||
->label('Admin consent')
|
||||
->icon('heroicon-o-clipboard-document')
|
||||
->url(fn (Tenant $record) => static::adminConsentUrl($record))
|
||||
->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null)
|
||||
->openUrlInNewTab(),
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$record->restore();
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $record,
|
||||
action: 'tenant.restored',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
||||
);
|
||||
}),
|
||||
Actions\Action::make('admin_consent')
|
||||
->label('Admin consent')
|
||||
->icon('heroicon-o-clipboard-document')
|
||||
->url(fn (Tenant $record) => static::adminConsentUrl($record))
|
||||
->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null)
|
||||
->disabled(function (Tenant $record): bool {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return ! $resolver->can($user, $record, Capabilities::TENANT_MANAGE);
|
||||
})
|
||||
->tooltip(function (Tenant $record): ?string {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $record, Capabilities::TENANT_MANAGE)
|
||||
? null
|
||||
: 'You do not have permission to manage tenant consent.';
|
||||
})
|
||||
->openUrlInNewTab(),
|
||||
Actions\Action::make('open_in_entra')
|
||||
->label('Open in Entra')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->url(fn (Tenant $record) => static::entraUrl($record))
|
||||
->visible(fn (Tenant $record) => static::entraUrl($record) !== null)
|
||||
->openUrlInNewTab(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('verify')
|
||||
->label('Verify configuration')
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record): bool => $record->isActive())
|
||||
->action(function (
|
||||
Tenant $record,
|
||||
TenantConfigService $configService,
|
||||
TenantPermissionService $permissionService,
|
||||
RbacHealthService $rbacHealthService,
|
||||
AuditLogger $auditLogger
|
||||
): void {
|
||||
$user = auth()->user();
|
||||
Actions\Action::make('verify')
|
||||
->label('Verify configuration')
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record): bool => $record->isActive())
|
||||
->disabled(function (Tenant $record): bool {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
if (! $user instanceof User) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGE)) {
|
||||
abort(403);
|
||||
}
|
||||
return ! $resolver->can($user, $record, Capabilities::TENANT_MANAGE);
|
||||
})
|
||||
->action(function (
|
||||
Tenant $record,
|
||||
TenantConfigService $configService,
|
||||
TenantPermissionService $permissionService,
|
||||
RbacHealthService $rbacHealthService,
|
||||
AuditLogger $auditLogger
|
||||
) {
|
||||
$user = auth()->user();
|
||||
|
||||
static::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
|
||||
}),
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
static::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
|
||||
}),
|
||||
static::rbacAction(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('archive')
|
||||
->label('Deactivate')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
||||
$user = auth()->user();
|
||||
Actions\Action::make('archive')
|
||||
->label('Deactivate')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
||||
->disabled(function (Tenant $record): bool {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
if (! $user instanceof User) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||
abort(403);
|
||||
}
|
||||
return ! $resolver->can($user, $record, Capabilities::TENANT_DELETE);
|
||||
})
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger) {
|
||||
$user = auth()->user();
|
||||
|
||||
$record->delete();
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $record,
|
||||
action: 'tenant.archived',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
||||
);
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$record->delete();
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $record,
|
||||
action: 'tenant.archived',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Tenant deactivated')
|
||||
->body('The tenant has been archived and hidden from lists.')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Actions\Action::make('forceDelete')
|
||||
->label('Force delete')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-trash')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (?Tenant $record): bool => (bool) $record?->trashed())
|
||||
->disabled(function (?Tenant $record): bool {
|
||||
if (! $record instanceof Tenant) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return ! $resolver->can($user, $record, Capabilities::TENANT_DELETE);
|
||||
})
|
||||
->action(function (?Tenant $record, AuditLogger $auditLogger) {
|
||||
if ($record === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = Tenant::withTrashed()->find($record->id);
|
||||
|
||||
if (! $tenant?->trashed()) {
|
||||
Notification::make()
|
||||
->title('Tenant deactivated')
|
||||
->body('The tenant has been archived and hidden from lists.')
|
||||
->success()
|
||||
->title('Tenant must be archived first')
|
||||
->danger()
|
||||
->send();
|
||||
}),
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('forceDelete')
|
||||
->label('Force delete')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-trash')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (?Tenant $record): bool => (bool) $record?->trashed())
|
||||
->action(function (?Tenant $record, AuditLogger $auditLogger): void {
|
||||
if ($record === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'tenant.force_deleted',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $tenant->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $tenant->tenant_id]]
|
||||
);
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
$tenant->forceDelete();
|
||||
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = Tenant::withTrashed()->find($record->id);
|
||||
|
||||
if (! $tenant?->trashed()) {
|
||||
Notification::make()
|
||||
->title('Tenant must be archived first')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'tenant.force_deleted',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $tenant->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $tenant->tenant_id]]
|
||||
);
|
||||
|
||||
$tenant->forceDelete();
|
||||
|
||||
Notification::make()
|
||||
->title('Tenant permanently deleted')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->apply(),
|
||||
Notification::make()
|
||||
->title('Tenant permanently deleted')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
]),
|
||||
])
|
||||
->bulkActions([
|
||||
@ -609,45 +655,27 @@ public static function table(Table $table): Table
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (): bool => auth()->user() instanceof User)
|
||||
->authorize(fn (): bool => auth()->user() instanceof User)
|
||||
->disabled(function (Collection $records): bool {
|
||||
->visible(function (): bool {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($records->isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $records
|
||||
->filter(fn ($record) => $record instanceof Tenant)
|
||||
->contains(fn (Tenant $tenant): bool => ! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
|
||||
return $user->tenants()
|
||||
->whereIn('role', RoleCapabilityMap::rolesWithCapability(Capabilities::TENANT_SYNC))
|
||||
->exists();
|
||||
})
|
||||
->tooltip(function (Collection $records): ?string {
|
||||
->authorize(function (): bool {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return UiTooltips::insufficientPermission();
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($records->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
$isDenied = $records
|
||||
->filter(fn ($record) => $record instanceof Tenant)
|
||||
->contains(fn (Tenant $tenant): bool => ! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
|
||||
|
||||
return $isDenied ? UiTooltips::insufficientPermission() : null;
|
||||
return $user->tenants()
|
||||
->whereIn('role', RoleCapabilityMap::rolesWithCapability(Capabilities::TENANT_SYNC))
|
||||
->exists();
|
||||
})
|
||||
->action(function (Collection $records, AuditLogger $auditLogger): void {
|
||||
$user = auth()->user();
|
||||
@ -954,7 +982,9 @@ public static function rbacAction(): Actions\Action
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $service->run($record, $data, $user, $token);
|
||||
$actor = auth()->user();
|
||||
|
||||
$result = $service->run($record, $data, $actor, $token);
|
||||
|
||||
Cache::forget($cacheKey);
|
||||
|
||||
|
||||
@ -4,28 +4,12 @@
|
||||
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\User;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateTenant extends CreateRecord
|
||||
{
|
||||
protected static string $resource = TenantResource::class;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||
|
||||
if ($workspaceId !== null) {
|
||||
$data['workspace_id'] = $workspaceId;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function afterCreate(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
@ -3,14 +3,11 @@
|
||||
namespace App\Filament\Resources\TenantResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\RbacHealthService;
|
||||
use App\Services\Intune\TenantConfigService;
|
||||
use App\Services\Intune\TenantPermissionService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
@ -19,25 +16,11 @@ class ViewTenant extends ViewRecord
|
||||
{
|
||||
protected static string $resource = TenantResource::class;
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
TenantArchivedBanner::class,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\ActionGroup::make([
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('edit')
|
||||
->label('Edit')
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->url(fn (Tenant $record): string => TenantResource::getUrl('edit', ['record' => $record]))
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
Actions\EditAction::make(),
|
||||
Actions\Action::make('admin_consent')
|
||||
->label('Admin consent')
|
||||
->icon('heroicon-o-clipboard-document')
|
||||
@ -65,40 +48,30 @@ protected function getHeaderActions(): array
|
||||
TenantResource::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
|
||||
}),
|
||||
TenantResource::rbacAction(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('archive')
|
||||
->label('Deactivate')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
||||
$record->delete();
|
||||
Actions\Action::make('archive')
|
||||
->label('Deactivate')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record) => ! $record->trashed())
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger) {
|
||||
$record->delete();
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $record,
|
||||
action: 'tenant.archived',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $record->getKey(),
|
||||
status: 'success',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'internal_tenant_id' => (int) $record->getKey(),
|
||||
'tenant_guid' => (string) $record->tenant_id,
|
||||
],
|
||||
]
|
||||
);
|
||||
$auditLogger->log(
|
||||
tenant: $record,
|
||||
action: 'tenant.archived',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Tenant deactivated')
|
||||
->body('The tenant has been archived and hidden from lists.')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->destructive()
|
||||
->apply(),
|
||||
Notification::make()
|
||||
->title('Tenant deactivated')
|
||||
->body('The tenant has been archived and hidden from lists.')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
])
|
||||
->label('Actions')
|
||||
->icon('heroicon-o-ellipsis-vertical')
|
||||
|
||||
@ -25,22 +25,11 @@ public function table(Table $table): Table
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('user.email')
|
||||
Tables\Columns\TextColumn::make('user.name')
|
||||
->label(__('User'))
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('user_domain')
|
||||
->label(__('Domain'))
|
||||
->getStateUsing(function (TenantMembership $record): ?string {
|
||||
$email = $record->user?->email;
|
||||
|
||||
if (! is_string($email) || $email === '' || ! str_contains($email, '@')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (string) str($email)->after('@')->lower();
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('user.name')
|
||||
->label(__('Name'))
|
||||
Tables\Columns\TextColumn::make('user.email')
|
||||
->label(__('Email'))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('role')
|
||||
->badge()
|
||||
@ -60,13 +49,7 @@ public function table(Table $table): Table
|
||||
->label(__('User'))
|
||||
->required()
|
||||
->searchable()
|
||||
->options(fn () => User::query()
|
||||
->orderBy('email')
|
||||
->get(['id', 'name', 'email'])
|
||||
->mapWithKeys(fn (User $user): array => [
|
||||
(string) $user->id => trim((string) ($user->name ? "{$user->name} ({$user->email})" : $user->email)),
|
||||
])
|
||||
->all()),
|
||||
->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()),
|
||||
Forms\Components\Select::make('role')
|
||||
->label(__('Role'))
|
||||
->required()
|
||||
|
||||
@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Workspaces\Pages;
|
||||
|
||||
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateWorkspace extends CreateRecord
|
||||
{
|
||||
protected static string $resource = WorkspaceResource::class;
|
||||
|
||||
protected function afterCreate(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
WorkspaceMembership::query()->firstOrCreate(
|
||||
[
|
||||
'workspace_id' => $this->record->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
],
|
||||
[
|
||||
'role' => 'owner',
|
||||
],
|
||||
);
|
||||
|
||||
app(WorkspaceContext::class)->setCurrentWorkspace($this->record, $user, request());
|
||||
}
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Workspaces\Pages;
|
||||
|
||||
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditWorkspace extends EditRecord
|
||||
{
|
||||
protected static string $resource = WorkspaceResource::class;
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Workspaces\Pages;
|
||||
|
||||
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListWorkspaces extends ListRecords
|
||||
{
|
||||
protected static string $resource = WorkspaceResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
<?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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,221 +0,0 @@
|
||||
<?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\WorkspaceUiEnforcement;
|
||||
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.email')
|
||||
->label(__('User'))
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('user_domain')
|
||||
->label(__('Domain'))
|
||||
->getStateUsing(function (WorkspaceMembership $record): ?string {
|
||||
$email = $record->user?->email;
|
||||
|
||||
if (! is_string($email) || $email === '' || ! str_contains($email, '@')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (string) str($email)->after('@')->lower();
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('user.name')
|
||||
->label(__('Name'))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('role')
|
||||
->badge()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_at')->since(),
|
||||
])
|
||||
->headerActions([
|
||||
WorkspaceUiEnforcement::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('email')
|
||||
->get(['id', 'name', 'email'])
|
||||
->mapWithKeys(fn (User $user): array => [
|
||||
(string) $user->id => trim((string) ($user->name ? "{$user->name} ({$user->email})" : $user->email)),
|
||||
])
|
||||
->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([
|
||||
WorkspaceUiEnforcement::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(),
|
||||
|
||||
WorkspaceUiEnforcement::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([]);
|
||||
}
|
||||
}
|
||||
@ -1,79 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Workspaces;
|
||||
|
||||
use App\Filament\Resources\Workspaces\RelationManagers\WorkspaceMembershipsRelationManager;
|
||||
use App\Models\Workspace;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use UnitEnum;
|
||||
|
||||
class WorkspaceResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Workspace::class;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $isScopedToTenant = false;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'name';
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Settings';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Forms\Components\TextInput::make('slug')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->unique(ignoreRecord: true),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('slug')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
])
|
||||
->actions([
|
||||
Actions\ViewAction::make(),
|
||||
Actions\EditAction::make(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListWorkspaces::route('/'),
|
||||
'create' => Pages\CreateWorkspace::route('/create'),
|
||||
'view' => Pages\ViewWorkspace::route('/{record}'),
|
||||
'edit' => Pages\EditWorkspace::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
WorkspaceMembershipsRelationManager::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Support;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Verification\VerificationReportSanitizer;
|
||||
use App\Support\Verification\VerificationReportSchema;
|
||||
|
||||
final class VerificationReportViewer
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function report(OperationRun $run): ?array
|
||||
{
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$report = $context['verification_report'] ?? null;
|
||||
|
||||
if (! is_array($report)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$report = VerificationReportSanitizer::sanitizeReport($report);
|
||||
|
||||
if (! VerificationReportSchema::isValidReport($report)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
public static function shouldRenderForRun(OperationRun $run): bool
|
||||
{
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
|
||||
if (array_key_exists('verification_report', $context)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array((string) $run->type, ['provider.connection.check'], true);
|
||||
}
|
||||
}
|
||||
@ -1,169 +0,0 @@
|
||||
<?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()),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Widgets\Tenant;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
class TenantArchivedBanner extends Widget
|
||||
{
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected string $view = 'filament.widgets.tenant.tenant-archived-banner';
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
return [
|
||||
'tenant' => $tenant instanceof Tenant ? $tenant : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,72 +0,0 @@
|
||||
<?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()]
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,67 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Filament\Pages\ChooseTenant;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
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);
|
||||
|
||||
$tenantsQuery = $user->tenants()
|
||||
->where('workspace_id', $workspace->getKey())
|
||||
->where('status', 'active');
|
||||
|
||||
$tenantCount = (int) $tenantsQuery->count();
|
||||
|
||||
if ($tenantCount === 0) {
|
||||
return redirect()->route('admin.workspace.managed-tenants.onboarding', ['workspace' => $workspace->slug ?? $workspace->getKey()]);
|
||||
}
|
||||
|
||||
if ($tenantCount === 1) {
|
||||
$tenant = $tenantsQuery->first();
|
||||
|
||||
if ($tenant !== null) {
|
||||
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->to(ChooseTenant::getUrl());
|
||||
}
|
||||
}
|
||||
@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use App\Support\Workspaces\WorkspaceResolver;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureWorkspaceMember
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$workspaceParam = $request->route()?->parameter('workspace');
|
||||
|
||||
$workspace = $workspaceParam instanceof Workspace
|
||||
? $workspaceParam
|
||||
: (is_scalar($workspaceParam)
|
||||
? app(WorkspaceResolver::class)->resolve((string) $workspaceParam)
|
||||
: null);
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
/** @var WorkspaceContext $context */
|
||||
$context = app(WorkspaceContext::class);
|
||||
|
||||
if (! $context->isMember($user, $workspace)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$context->setCurrentWorkspace($workspace, $user, $request);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@ -1,67 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response as HttpResponse;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureWorkspaceSelected
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$routeName = $request->route()?->getName();
|
||||
|
||||
if (is_string($routeName) && str_contains($routeName, '.auth.')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$path = '/'.ltrim($request->path(), '/');
|
||||
|
||||
if (str_starts_with($path, '/admin/t/')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (in_array($path, ['/admin/no-access', '/admin/choose-workspace'], true)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/** @var WorkspaceContext $context */
|
||||
$context = app(WorkspaceContext::class);
|
||||
|
||||
$workspace = $context->resolveInitialWorkspaceFor($user, $request);
|
||||
|
||||
if ($workspace !== null) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$membershipQuery = WorkspaceMembership::query()->where('user_id', $user->getKey());
|
||||
|
||||
$hasAnyActiveMembership = Schema::hasColumn('workspaces', 'archived_at')
|
||||
? $membershipQuery
|
||||
->join('workspaces', 'workspace_memberships.workspace_id', '=', 'workspaces.id')
|
||||
->whereNull('workspaces.archived_at')
|
||||
->exists()
|
||||
: $membershipQuery->exists();
|
||||
|
||||
$target = $hasAnyActiveMembership ? '/admin/choose-workspace' : '/admin/no-access';
|
||||
|
||||
return new HttpResponse('', 302, ['Location' => $target]);
|
||||
}
|
||||
}
|
||||
@ -7,14 +7,11 @@
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Providers\Contracts\HealthResult;
|
||||
use App\Services\Providers\MicrosoftProviderHealthCheck;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Verification\VerificationReportWriter;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@ -86,64 +83,17 @@ public function handle(
|
||||
|
||||
$this->updateRunTargetScope($this->operationRun, $connection, $entraTenantName);
|
||||
|
||||
$report = VerificationReportWriter::write(
|
||||
run: $this->operationRun,
|
||||
checks: [
|
||||
[
|
||||
'key' => 'provider.connection.check',
|
||||
'title' => 'Provider connection check',
|
||||
'status' => $result->healthy ? 'pass' : 'fail',
|
||||
'severity' => $result->healthy ? 'info' : 'critical',
|
||||
'blocking' => ! $result->healthy,
|
||||
'reason_code' => $result->healthy ? 'ok' : ($result->reasonCode ?? 'unknown_error'),
|
||||
'message' => $result->healthy ? 'Connection is healthy.' : ($result->message ?? 'Health check failed.'),
|
||||
'evidence' => array_values(array_filter([
|
||||
[
|
||||
'kind' => 'provider_connection_id',
|
||||
'value' => (int) $connection->getKey(),
|
||||
],
|
||||
[
|
||||
'kind' => 'entra_tenant_id',
|
||||
'value' => (string) $connection->entra_tenant_id,
|
||||
],
|
||||
is_numeric($result->meta['http_status'] ?? null) ? [
|
||||
'kind' => 'http_status',
|
||||
'value' => (int) $result->meta['http_status'],
|
||||
] : null,
|
||||
is_string($result->meta['organization_id'] ?? null) ? [
|
||||
'kind' => 'organization_id',
|
||||
'value' => (string) $result->meta['organization_id'],
|
||||
] : null,
|
||||
])),
|
||||
'next_steps' => $result->healthy
|
||||
? []
|
||||
: [[
|
||||
'label' => 'Review provider connection',
|
||||
'url' => \App\Filament\Resources\ProviderConnectionResource::getUrl('edit', [
|
||||
'record' => (int) $connection->getKey(),
|
||||
], tenant: $tenant),
|
||||
]],
|
||||
],
|
||||
],
|
||||
identity: [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
||||
],
|
||||
);
|
||||
|
||||
if ($result->healthy) {
|
||||
$run = $runs->updateRun(
|
||||
$runs->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
);
|
||||
|
||||
$this->logVerificationCompletion($tenant, $user, $run, $report);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$run = $runs->updateRun(
|
||||
$runs->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
@ -153,8 +103,6 @@ public function handle(
|
||||
'message' => $result->message ?? 'Health check failed.',
|
||||
]],
|
||||
);
|
||||
|
||||
$this->logVerificationCompletion($tenant, $user, $run, $report);
|
||||
}
|
||||
|
||||
private function resolveEntraTenantName(ProviderConnection $connection, HealthResult $result): ?string
|
||||
@ -197,34 +145,4 @@ private function applyHealthResult(ProviderConnection $connection, HealthResult
|
||||
'last_error_message' => $result->healthy ? null : $result->message,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $report
|
||||
*/
|
||||
private function logVerificationCompletion(Tenant $tenant, User $actor, OperationRun $run, array $report): void
|
||||
{
|
||||
$workspace = $tenant->workspace;
|
||||
|
||||
if (! $workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
$counts = $report['summary']['counts'] ?? [];
|
||||
$counts = is_array($counts) ? $counts : [];
|
||||
|
||||
app(WorkspaceAuditLogger::class)->log(
|
||||
workspace: $workspace,
|
||||
action: AuditActionId::VerificationCompleted->value,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'counts' => $counts,
|
||||
],
|
||||
],
|
||||
actor: $actor,
|
||||
status: $run->outcome === OperationRunOutcome::Succeeded->value ? 'success' : 'failed',
|
||||
resourceType: 'operation_run',
|
||||
resourceId: (string) $run->getKey(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,7 +61,7 @@ public static function externalIdShort(?string $externalId): string
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
$backupSet = BackupSet::query()->find($this->backupSetId);
|
||||
$tenantId = $backupSet?->tenant_id ?? Tenant::currentOrFail()->getKey();
|
||||
$tenantId = $backupSet?->tenant_id ?? Tenant::current()->getKey();
|
||||
$existingPolicyIds = $backupSet
|
||||
? $backupSet->items()->pluck('policy_id')->filter()->all()
|
||||
: [];
|
||||
|
||||
@ -7,7 +7,6 @@
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||
@ -117,7 +116,7 @@ public function makeCurrent(): void
|
||||
$this->forceFill(['is_current' => true]);
|
||||
}
|
||||
|
||||
public static function current(): ?self
|
||||
public static function current(): self
|
||||
{
|
||||
$filamentTenant = Filament::getTenant();
|
||||
|
||||
@ -146,13 +145,6 @@ public static function current(): ?self
|
||||
->where('is_current', true)
|
||||
->first();
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
public static function currentOrFail(): self
|
||||
{
|
||||
$tenant = static::current();
|
||||
|
||||
if (! $tenant) {
|
||||
throw new RuntimeException('No current tenant selected.');
|
||||
}
|
||||
@ -160,29 +152,11 @@ public static function currentOrFail(): self
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
public function resolveRouteBinding($value, $field = null): ?Model
|
||||
{
|
||||
$field ??= $this->getRouteKeyName();
|
||||
|
||||
$query = static::query();
|
||||
|
||||
if ($field === 'external_id') {
|
||||
$query = $query->withTrashed();
|
||||
}
|
||||
|
||||
return $query->where($field, $value)->first();
|
||||
}
|
||||
|
||||
public function memberships(): HasMany
|
||||
{
|
||||
return $this->hasMany(TenantMembership::class);
|
||||
}
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
public function roleMappings(): HasMany
|
||||
{
|
||||
return $this->hasMany(TenantRoleMapping::class);
|
||||
|
||||
@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class TenantOnboardingSession extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\TenantOnboardingSessionFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'managed_tenant_onboarding_sessions';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'state' => 'array',
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Workspace, $this>
|
||||
*/
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Tenant, $this>
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function startedByUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'started_by_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function updatedByUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'updated_by_user_id');
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,6 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Models\Contracts\FilamentUser;
|
||||
use Filament\Models\Contracts\HasDefaultTenant;
|
||||
use Filament\Models\Contracts\HasTenants;
|
||||
@ -131,8 +130,8 @@ public function canAccessTenant(Model $tenant): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->tenantMemberships()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
return $this->tenants()
|
||||
->whereKey($tenant->getKey())
|
||||
->exists();
|
||||
}
|
||||
|
||||
@ -142,10 +141,7 @@ public function getTenants(Panel $panel): array|Collection
|
||||
return collect();
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||
|
||||
return $this->tenants()
|
||||
->when($workspaceId !== null, fn ($query) => $query->where('tenants.workspace_id', $workspaceId))
|
||||
->where('status', 'active')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
@ -157,8 +153,6 @@ public function getDefaultTenant(Panel $panel): ?Model
|
||||
return null;
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||
|
||||
$tenantId = null;
|
||||
|
||||
if ($this->tenantPreferencesTableExists()) {
|
||||
@ -170,7 +164,6 @@ public function getDefaultTenant(Panel $panel): ?Model
|
||||
|
||||
if ($tenantId !== null) {
|
||||
$tenant = $this->tenants()
|
||||
->when($workspaceId !== null, fn ($query) => $query->where('tenants.workspace_id', $workspaceId))
|
||||
->where('status', 'active')
|
||||
->whereKey($tenantId)
|
||||
->first();
|
||||
@ -181,7 +174,6 @@ public function getDefaultTenant(Panel $panel): ?Model
|
||||
}
|
||||
|
||||
return $this->tenants()
|
||||
->when($workspaceId !== null, fn ($query) => $query->where('tenants.workspace_id', $workspaceId))
|
||||
->where('status', 'active')
|
||||
->orderBy('name')
|
||||
->first();
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Workspace extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\WorkspaceFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
/**
|
||||
* @return HasMany<WorkspaceMembership, $this>
|
||||
*/
|
||||
public function memberships(): HasMany
|
||||
{
|
||||
return $this->hasMany(WorkspaceMembership::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<User, $this>
|
||||
*/
|
||||
public function users(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'workspace_memberships')
|
||||
->using(WorkspaceMembership::class)
|
||||
->withPivot(['id', 'role'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Tenant, $this>
|
||||
*/
|
||||
public function tenants(): HasMany
|
||||
{
|
||||
return $this->hasMany(Tenant::class);
|
||||
}
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class WorkspaceMembership extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\WorkspaceMembershipFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Workspace, $this>
|
||||
*/
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@ -1,108 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\WorkspaceRole;
|
||||
|
||||
class WorkspaceMembershipPolicy
|
||||
{
|
||||
/**
|
||||
* Determine whether the user can view any models.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view the model.
|
||||
*/
|
||||
public function view(User $user, WorkspaceMembership $workspaceMembership): bool
|
||||
{
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $workspaceMembership->workspace, Capabilities::WORKSPACE_MEMBERSHIP_VIEW);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create models.
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the model.
|
||||
*/
|
||||
public function update(User $user, WorkspaceMembership $workspaceMembership): bool
|
||||
{
|
||||
if ($this->isLastOwner($workspaceMembership)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $workspaceMembership->workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the model.
|
||||
*/
|
||||
public function delete(User $user, WorkspaceMembership $workspaceMembership): bool
|
||||
{
|
||||
if ($this->isLastOwner($workspaceMembership)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $workspaceMembership->workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can restore the model.
|
||||
*/
|
||||
public function restore(User $user, WorkspaceMembership $workspaceMembership): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can permanently delete the model.
|
||||
*/
|
||||
public function forceDelete(User $user, WorkspaceMembership $workspaceMembership): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function manageForWorkspace(User $user, Workspace $workspace): bool
|
||||
{
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
|
||||
}
|
||||
|
||||
private function isLastOwner(WorkspaceMembership $membership): bool
|
||||
{
|
||||
if ($membership->role !== WorkspaceRole::Owner->value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ownerCount = WorkspaceMembership::query()
|
||||
->where('workspace_id', $membership->workspace_id)
|
||||
->where('role', WorkspaceRole::Owner->value)
|
||||
->count();
|
||||
|
||||
return $ownerCount <= 1;
|
||||
}
|
||||
}
|
||||
@ -1,74 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
|
||||
class WorkspacePolicy
|
||||
{
|
||||
/**
|
||||
* Determine whether the user can view any models.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view the model.
|
||||
*/
|
||||
public function view(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return WorkspaceMembership::query()
|
||||
->where('user_id', $user->getKey())
|
||||
->where('workspace_id', $workspace->getKey())
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create models.
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the model.
|
||||
*/
|
||||
public function update(User $user, Workspace $workspace): bool
|
||||
{
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MANAGE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the model.
|
||||
*/
|
||||
public function delete(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can restore the model.
|
||||
*/
|
||||
public function restore(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can permanently delete the model.
|
||||
*/
|
||||
public function forceDelete(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -6,10 +6,8 @@
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Policies\ProviderConnectionPolicy;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||
@ -25,36 +23,15 @@ public function boot(): void
|
||||
{
|
||||
$this->registerPolicies();
|
||||
|
||||
$tenantResolver = app(CapabilityResolver::class);
|
||||
$workspaceResolver = app(WorkspaceCapabilityResolver::class);
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
$defineTenantCapability = function (string $capability) use ($tenantResolver): void {
|
||||
Gate::define($capability, function (User $user, ?Tenant $tenant = null) use ($tenantResolver, $capability): bool {
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $tenantResolver->can($user, $tenant, $capability);
|
||||
});
|
||||
};
|
||||
|
||||
$defineWorkspaceCapability = function (string $capability) use ($workspaceResolver): void {
|
||||
Gate::define($capability, function (User $user, ?Workspace $workspace = null) use ($workspaceResolver, $capability): bool {
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $workspaceResolver->can($user, $workspace, $capability);
|
||||
$defineTenantCapability = function (string $capability) use ($resolver): void {
|
||||
Gate::define($capability, function (User $user, Tenant $tenant) use ($resolver, $capability): bool {
|
||||
return $resolver->can($user, $tenant, $capability);
|
||||
});
|
||||
};
|
||||
|
||||
foreach (Capabilities::all() as $capability) {
|
||||
if (str_starts_with($capability, 'workspace')) {
|
||||
$defineWorkspaceCapability($capability);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$defineTenantCapability($capability);
|
||||
}
|
||||
|
||||
|
||||
@ -4,10 +4,9 @@
|
||||
|
||||
use App\Filament\Pages\Auth\Login;
|
||||
use App\Filament\Pages\ChooseTenant;
|
||||
use App\Filament\Pages\ChooseWorkspace;
|
||||
use App\Filament\Pages\NoAccess;
|
||||
use App\Filament\Pages\Tenancy\RegisterTenant;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
||||
use Filament\Facades\Filament;
|
||||
@ -15,7 +14,6 @@
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
use Filament\Navigation\NavigationItem;
|
||||
use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
use Filament\Support\Colors\Color;
|
||||
@ -39,36 +37,21 @@ public function panel(Panel $panel): Panel
|
||||
->path('admin')
|
||||
->login(Login::class)
|
||||
->authenticatedRoutes(function (Panel $panel): void {
|
||||
ChooseWorkspace::registerRoutes($panel);
|
||||
ChooseTenant::registerRoutes($panel);
|
||||
NoAccess::registerRoutes($panel);
|
||||
|
||||
WorkspaceResource::registerRoutes($panel);
|
||||
})
|
||||
->tenant(Tenant::class, slugAttribute: 'external_id')
|
||||
->tenantRoutePrefix('t')
|
||||
->tenantMenu(fn (): bool => filled(Filament::getTenant()))
|
||||
->searchableTenantMenu()
|
||||
->tenantRegistration(RegisterTenant::class)
|
||||
->colors([
|
||||
'primary' => Color::Amber,
|
||||
])
|
||||
->navigationItems([
|
||||
NavigationItem::make('Workspaces')
|
||||
->url(function (): string {
|
||||
return route('filament.admin.resources.workspaces.index');
|
||||
})
|
||||
->icon('heroicon-o-squares-2x2')
|
||||
->group('Settings')
|
||||
->sort(10),
|
||||
])
|
||||
->renderHook(
|
||||
PanelsRenderHook::HEAD_END,
|
||||
fn () => view('filament.partials.livewire-intercept-shim')->render()
|
||||
)
|
||||
->renderHook(
|
||||
PanelsRenderHook::USER_MENU_PROFILE_AFTER,
|
||||
fn () => view('filament.partials.workspace-switcher')->render()
|
||||
)
|
||||
->renderHook(
|
||||
PanelsRenderHook::BODY_END,
|
||||
fn () => (bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true)
|
||||
@ -96,8 +79,6 @@ public function panel(Panel $panel): Panel
|
||||
VerifyCsrfToken::class,
|
||||
SubstituteBindings::class,
|
||||
'ensure-correct-guard:web',
|
||||
'ensure-workspace-selected',
|
||||
'ensure-filament-tenant-selected',
|
||||
DenyNonMemberTenantAccess::class,
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
|
||||
@ -1,48 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Audit;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Audit\AuditContextSanitizer;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
class WorkspaceAuditLogger
|
||||
{
|
||||
public function log(
|
||||
Workspace $workspace,
|
||||
string $action,
|
||||
array $context = [],
|
||||
?User $actor = null,
|
||||
string $status = 'success',
|
||||
?string $resourceType = null,
|
||||
?string $resourceId = null,
|
||||
?int $actorId = null,
|
||||
?string $actorEmail = null,
|
||||
?string $actorName = null,
|
||||
): AuditLog {
|
||||
$metadata = $context['metadata'] ?? [];
|
||||
unset($context['metadata']);
|
||||
|
||||
$metadata = is_array($metadata) ? $metadata : [];
|
||||
|
||||
$sanitizedMetadata = AuditContextSanitizer::sanitize($metadata + $context);
|
||||
|
||||
return AuditLog::create([
|
||||
'tenant_id' => null,
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'actor_id' => $actor?->getKey() ?? $actorId,
|
||||
'actor_email' => $actor?->email ?? $actorEmail,
|
||||
'actor_name' => $actor?->name ?? $actorName,
|
||||
'action' => $action,
|
||||
'resource_type' => $resourceType,
|
||||
'resource_id' => $resourceId,
|
||||
'status' => $status,
|
||||
'metadata' => $sanitizedMetadata,
|
||||
'recorded_at' => CarbonImmutable::now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -4,27 +4,39 @@
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class PostLoginRedirectResolver
|
||||
{
|
||||
public function resolve(User $user): string
|
||||
{
|
||||
$membershipQuery = WorkspaceMembership::query()->where('user_id', $user->getKey());
|
||||
$tenants = $this->getActiveTenants($user);
|
||||
|
||||
$hasAnyActiveMembership = Schema::hasColumn('workspaces', 'archived_at')
|
||||
? $membershipQuery
|
||||
->join('workspaces', 'workspace_memberships.workspace_id', '=', 'workspaces.id')
|
||||
->whereNull('workspaces.archived_at')
|
||||
->exists()
|
||||
: $membershipQuery->exists();
|
||||
|
||||
if (! $hasAnyActiveMembership) {
|
||||
if ($tenants->isEmpty()) {
|
||||
return '/admin/no-access';
|
||||
}
|
||||
|
||||
return '/admin';
|
||||
if ($tenants->count() === 1) {
|
||||
/** @var Tenant $tenant */
|
||||
$tenant = $tenants->first();
|
||||
|
||||
return TenantDashboard::getUrl(tenant: $tenant);
|
||||
}
|
||||
|
||||
return '/admin/choose-tenant';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Tenant>
|
||||
*/
|
||||
private function getActiveTenants(User $user): Collection
|
||||
{
|
||||
return $user->tenants()
|
||||
->where('status', 'active')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,114 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantMembership;
|
||||
use App\Models\User;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class TenantDiagnosticsService
|
||||
{
|
||||
public function __construct(public AuditLogger $auditLogger) {}
|
||||
|
||||
public function tenantHasNoOwners(Tenant $tenant): bool
|
||||
{
|
||||
return ! TenantMembership::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('role', 'owner')
|
||||
->exists();
|
||||
}
|
||||
|
||||
public function userHasDuplicateMemberships(Tenant $tenant, User $user): bool
|
||||
{
|
||||
return TenantMembership::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('user_id', (int) $user->getKey())
|
||||
->count() > 1;
|
||||
}
|
||||
|
||||
public function mergeDuplicateMembershipsForUser(Tenant $tenant, User $actor, User $member): void
|
||||
{
|
||||
DB::transaction(function () use ($tenant, $actor, $member): void {
|
||||
$memberships = TenantMembership::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('user_id', (int) $member->getKey())
|
||||
->orderBy('created_at')
|
||||
->get();
|
||||
|
||||
if ($memberships->count() <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$roles = $memberships->pluck('role')->all();
|
||||
$roleToKeep = $this->highestRole($roles);
|
||||
|
||||
$membershipToKeep = $memberships->firstWhere('role', $roleToKeep) ?? $memberships->first();
|
||||
if (! $membershipToKeep instanceof TenantMembership) {
|
||||
return;
|
||||
}
|
||||
|
||||
$idsToDelete = $memberships
|
||||
->reject(fn (TenantMembership $m): bool => $m->getKey() === $membershipToKeep->getKey())
|
||||
->pluck($membershipToKeep->getKeyName())
|
||||
->all();
|
||||
|
||||
$membershipToKeep->forceFill([
|
||||
'role' => $roleToKeep,
|
||||
])->save();
|
||||
|
||||
TenantMembership::query()
|
||||
->whereIn($membershipToKeep->getKeyName(), $idsToDelete)
|
||||
->delete();
|
||||
|
||||
$this->auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: AuditActionId::TenantMembershipDuplicatesMerged->value,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'member_user_id' => (int) $member->getKey(),
|
||||
'kept_membership_id' => (string) $membershipToKeep->getKey(),
|
||||
'deleted_membership_ids' => array_values(array_map('strval', $idsToDelete)),
|
||||
'result_role' => $roleToKeep,
|
||||
'source_roles' => $roles,
|
||||
],
|
||||
],
|
||||
actorId: (int) $actor->getKey(),
|
||||
actorEmail: $actor->email,
|
||||
actorName: $actor->name,
|
||||
status: 'success',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $tenant->getKey(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string|null> $roles
|
||||
*/
|
||||
private function highestRole(array $roles): string
|
||||
{
|
||||
$priority = [
|
||||
'owner' => 3,
|
||||
'manager' => 2,
|
||||
'readonly' => 1,
|
||||
];
|
||||
|
||||
$bestRole = 'readonly';
|
||||
$bestScore = 0;
|
||||
|
||||
foreach ($roles as $role) {
|
||||
$score = $priority[$role] ?? 0;
|
||||
if ($score > $bestScore) {
|
||||
$bestScore = $score;
|
||||
$bestRole = (string) $role;
|
||||
}
|
||||
}
|
||||
|
||||
return $bestRole;
|
||||
}
|
||||
}
|
||||
@ -1,100 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\WorkspaceRole;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Workspace Capability Resolver
|
||||
*
|
||||
* Resolves user memberships and capabilities for a given workspace.
|
||||
* Caches results per request to avoid N+1 queries.
|
||||
*/
|
||||
class WorkspaceCapabilityResolver
|
||||
{
|
||||
private array $resolvedMemberships = [];
|
||||
|
||||
private array $loggedDenials = [];
|
||||
|
||||
public function getRole(User $user, Workspace $workspace): ?WorkspaceRole
|
||||
{
|
||||
$membership = $this->getMembership($user, $workspace);
|
||||
|
||||
if ($membership === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return WorkspaceRole::tryFrom($membership['role']);
|
||||
}
|
||||
|
||||
public function can(User $user, Workspace $workspace, string $capability): bool
|
||||
{
|
||||
if (! Capabilities::isKnown($capability)) {
|
||||
throw new \InvalidArgumentException("Unknown capability: {$capability}");
|
||||
}
|
||||
|
||||
$role = $this->getRole($user, $workspace);
|
||||
|
||||
if ($role === null) {
|
||||
$this->logDenial($user, $workspace, $capability);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$allowed = WorkspaceRoleCapabilityMap::hasCapability($role, $capability);
|
||||
|
||||
if (! $allowed) {
|
||||
$this->logDenial($user, $workspace, $capability);
|
||||
}
|
||||
|
||||
return $allowed;
|
||||
}
|
||||
|
||||
public function isMember(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return $this->getMembership($user, $workspace) !== null;
|
||||
}
|
||||
|
||||
public function clearCache(): void
|
||||
{
|
||||
$this->resolvedMemberships = [];
|
||||
}
|
||||
|
||||
private function logDenial(User $user, Workspace $workspace, string $capability): void
|
||||
{
|
||||
$key = implode(':', [(string) $user->getKey(), (string) $workspace->getKey(), $capability]);
|
||||
|
||||
if (isset($this->loggedDenials[$key])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->loggedDenials[$key] = true;
|
||||
|
||||
Log::warning('rbac.workspace.denied', [
|
||||
'capability' => $capability,
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'actor_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function getMembership(User $user, Workspace $workspace): ?array
|
||||
{
|
||||
$cacheKey = "workspace_membership_{$user->id}_{$workspace->id}";
|
||||
|
||||
if (! isset($this->resolvedMemberships[$cacheKey])) {
|
||||
$membership = WorkspaceMembership::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('workspace_id', $workspace->id)
|
||||
->first(['role']);
|
||||
|
||||
$this->resolvedMemberships[$cacheKey] = $membership?->toArray();
|
||||
}
|
||||
|
||||
return $this->resolvedMemberships[$cacheKey];
|
||||
}
|
||||
}
|
||||
@ -1,303 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\WorkspaceRole;
|
||||
use DomainException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class WorkspaceMembershipManager
|
||||
{
|
||||
public function __construct(public WorkspaceAuditLogger $auditLogger) {}
|
||||
|
||||
public function addMember(
|
||||
Workspace $workspace,
|
||||
User $actor,
|
||||
User $member,
|
||||
string $role,
|
||||
string $source = 'manual',
|
||||
): WorkspaceMembership {
|
||||
$this->assertValidRole($role);
|
||||
$this->assertActorCanManage($actor, $workspace);
|
||||
|
||||
try {
|
||||
return DB::transaction(function () use ($workspace, $actor, $member, $role, $source): WorkspaceMembership {
|
||||
$existing = WorkspaceMembership::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('user_id', (int) $member->getKey())
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
if ($existing->role !== $role) {
|
||||
$fromRole = (string) $existing->role;
|
||||
|
||||
$this->guardLastOwnerDemotion($workspace, $existing, $role);
|
||||
|
||||
$existing->forceFill([
|
||||
'role' => $role,
|
||||
])->save();
|
||||
|
||||
$this->auditLogger->log(
|
||||
workspace: $workspace,
|
||||
action: AuditActionId::WorkspaceMembershipRoleChange->value,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'member_user_id' => (int) $member->getKey(),
|
||||
'from_role' => $fromRole,
|
||||
'to_role' => $role,
|
||||
'source' => $source,
|
||||
],
|
||||
],
|
||||
actor: $actor,
|
||||
status: 'success',
|
||||
resourceType: 'workspace',
|
||||
resourceId: (string) $workspace->getKey(),
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
public function changeRole(Workspace $workspace, User $actor, WorkspaceMembership $membership, string $newRole): WorkspaceMembership
|
||||
{
|
||||
$this->assertValidRole($newRole);
|
||||
$this->assertActorCanManage($actor, $workspace);
|
||||
|
||||
try {
|
||||
return DB::transaction(function () use ($workspace, $actor, $membership, $newRole): WorkspaceMembership {
|
||||
$membership->refresh();
|
||||
|
||||
if ($membership->workspace_id !== (int) $workspace->getKey()) {
|
||||
throw new DomainException('Membership belongs to a different workspace.');
|
||||
}
|
||||
|
||||
$oldRole = (string) $membership->role;
|
||||
|
||||
if ($oldRole === $newRole) {
|
||||
return $membership;
|
||||
}
|
||||
|
||||
$this->guardLastOwnerDemotion($workspace, $membership, $newRole);
|
||||
|
||||
$membership->forceFill([
|
||||
'role' => $newRole,
|
||||
])->save();
|
||||
|
||||
$this->auditLogger->log(
|
||||
workspace: $workspace,
|
||||
action: AuditActionId::WorkspaceMembershipRoleChange->value,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'member_user_id' => (int) $membership->user_id,
|
||||
'from_role' => $oldRole,
|
||||
'to_role' => $newRole,
|
||||
],
|
||||
],
|
||||
actor: $actor,
|
||||
status: 'success',
|
||||
resourceType: 'workspace',
|
||||
resourceId: (string) $workspace->getKey(),
|
||||
);
|
||||
|
||||
return $membership->refresh();
|
||||
});
|
||||
} catch (DomainException $exception) {
|
||||
if ($exception->getMessage() === 'You cannot demote the last remaining owner.') {
|
||||
$this->auditLastOwnerBlocked(
|
||||
workspace: $workspace,
|
||||
actor: $actor,
|
||||
targetUserId: (int) $membership->user_id,
|
||||
attemptedRole: $newRole,
|
||||
currentRole: (string) $membership->role,
|
||||
attemptedAction: 'role_change',
|
||||
);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
public function removeMember(Workspace $workspace, User $actor, WorkspaceMembership $membership): void
|
||||
{
|
||||
$this->assertActorCanManage($actor, $workspace);
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($workspace, $actor, $membership): void {
|
||||
$membership->refresh();
|
||||
|
||||
if ($membership->workspace_id !== (int) $workspace->getKey()) {
|
||||
throw new DomainException('Membership belongs to a different workspace.');
|
||||
}
|
||||
|
||||
$this->guardLastOwnerRemoval($workspace, $membership);
|
||||
|
||||
$memberUserId = (int) $membership->user_id;
|
||||
$oldRole = (string) $membership->role;
|
||||
|
||||
$membership->delete();
|
||||
|
||||
$this->auditLogger->log(
|
||||
workspace: $workspace,
|
||||
action: AuditActionId::WorkspaceMembershipRemove->value,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'member_user_id' => $memberUserId,
|
||||
'role' => $oldRole,
|
||||
],
|
||||
],
|
||||
actor: $actor,
|
||||
status: 'success',
|
||||
resourceType: 'workspace',
|
||||
resourceId: (string) $workspace->getKey(),
|
||||
);
|
||||
});
|
||||
} catch (DomainException $exception) {
|
||||
if ($exception->getMessage() === 'You cannot remove the last remaining owner.') {
|
||||
$this->auditLastOwnerBlocked(
|
||||
workspace: $workspace,
|
||||
actor: $actor,
|
||||
targetUserId: (int) $membership->user_id,
|
||||
attemptedRole: (string) $membership->role,
|
||||
currentRole: (string) $membership->role,
|
||||
attemptedAction: 'remove',
|
||||
);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
private function assertActorCanManage(User $actor, Workspace $workspace): void
|
||||
{
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($actor, $workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)) {
|
||||
throw new DomainException('Forbidden.');
|
||||
}
|
||||
}
|
||||
|
||||
private function assertValidRole(string $role): void
|
||||
{
|
||||
$valid = array_map(
|
||||
static fn (WorkspaceRole $workspaceRole): string => $workspaceRole->value,
|
||||
WorkspaceRole::cases(),
|
||||
);
|
||||
|
||||
if (! in_array($role, $valid, true)) {
|
||||
throw new DomainException('Invalid role.');
|
||||
}
|
||||
}
|
||||
|
||||
private function guardLastOwnerDemotion(Workspace $workspace, WorkspaceMembership $membership, string $newRole): void
|
||||
{
|
||||
if ($membership->role !== WorkspaceRole::Owner->value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($newRole === WorkspaceRole::Owner->value) {
|
||||
return;
|
||||
}
|
||||
|
||||
$owners = WorkspaceMembership::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('role', WorkspaceRole::Owner->value)
|
||||
->count();
|
||||
|
||||
if ($owners <= 1) {
|
||||
throw new DomainException('You cannot demote the last remaining owner.');
|
||||
}
|
||||
}
|
||||
|
||||
private function guardLastOwnerRemoval(Workspace $workspace, WorkspaceMembership $membership): void
|
||||
{
|
||||
if ($membership->role !== WorkspaceRole::Owner->value) {
|
||||
return;
|
||||
}
|
||||
|
||||
$owners = WorkspaceMembership::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('role', WorkspaceRole::Owner->value)
|
||||
->count();
|
||||
|
||||
if ($owners <= 1) {
|
||||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,76 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\WorkspaceRole;
|
||||
|
||||
/**
|
||||
* Workspace Role to Capability Mapping (Single Source of Truth)
|
||||
*
|
||||
* This class defines which capabilities each workspace role has.
|
||||
* All capability strings MUST be references from the Capabilities registry.
|
||||
*/
|
||||
class WorkspaceRoleCapabilityMap
|
||||
{
|
||||
/**
|
||||
* @var array<string, array<int, string>>
|
||||
*/
|
||||
private static array $roleCapabilities = [
|
||||
WorkspaceRole::Owner->value => [
|
||||
Capabilities::WORKSPACE_VIEW,
|
||||
Capabilities::WORKSPACE_MANAGE,
|
||||
Capabilities::WORKSPACE_ARCHIVE,
|
||||
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
|
||||
Capabilities::WORKSPACE_MEMBERSHIP_MANAGE,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD,
|
||||
],
|
||||
|
||||
WorkspaceRole::Manager->value => [
|
||||
Capabilities::WORKSPACE_VIEW,
|
||||
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
|
||||
Capabilities::WORKSPACE_MEMBERSHIP_MANAGE,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD,
|
||||
],
|
||||
|
||||
WorkspaceRole::Operator->value => [
|
||||
Capabilities::WORKSPACE_VIEW,
|
||||
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
|
||||
],
|
||||
|
||||
WorkspaceRole::Readonly->value => [
|
||||
Capabilities::WORKSPACE_VIEW,
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string>
|
||||
*/
|
||||
public static function getCapabilities(WorkspaceRole|string $role): array
|
||||
{
|
||||
$roleValue = $role instanceof WorkspaceRole ? $role->value : $role;
|
||||
|
||||
return self::$roleCapabilities[$roleValue] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string>
|
||||
*/
|
||||
public static function rolesWithCapability(string $capability): array
|
||||
{
|
||||
$roles = [];
|
||||
|
||||
foreach (self::$roleCapabilities as $role => $capabilities) {
|
||||
if (in_array($capability, $capabilities, true)) {
|
||||
$roles[] = $role;
|
||||
}
|
||||
}
|
||||
|
||||
return $roles;
|
||||
}
|
||||
|
||||
public static function hasCapability(WorkspaceRole|string $role, string $capability): bool
|
||||
{
|
||||
return in_array($capability, self::getCapabilities($role), true);
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,6 @@
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Audit\AuditContextSanitizer;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
class AuditLogger
|
||||
@ -23,10 +22,6 @@ public function log(
|
||||
$metadata = $context['metadata'] ?? [];
|
||||
unset($context['metadata']);
|
||||
|
||||
$metadata = is_array($metadata) ? $metadata : [];
|
||||
|
||||
$sanitizedMetadata = AuditContextSanitizer::sanitize($metadata + $context);
|
||||
|
||||
return AuditLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'actor_id' => $actorId,
|
||||
@ -36,7 +31,7 @@ public function log(
|
||||
'resource_type' => $resourceType,
|
||||
'resource_id' => $resourceId,
|
||||
'status' => $status,
|
||||
'metadata' => $sanitizedMetadata,
|
||||
'metadata' => $metadata + $context,
|
||||
'recorded_at' => CarbonImmutable::now(),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -1,57 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Verification;
|
||||
|
||||
use App\Jobs\ProviderConnectionHealthCheckJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Providers\ProviderOperationStartGate;
|
||||
use App\Services\Providers\ProviderOperationStartResult;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
final class StartVerification
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProviderOperationStartGate $providers,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Start (or dedupe) a provider-connection verification run.
|
||||
*
|
||||
* @param array<string, mixed> $extraContext
|
||||
*/
|
||||
public function providerConnectionCheck(
|
||||
Tenant $tenant,
|
||||
ProviderConnection $connection,
|
||||
User $initiator,
|
||||
array $extraContext = [],
|
||||
): ProviderOperationStartResult {
|
||||
if (! $initiator->canAccessTenant($tenant)) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
Gate::forUser($initiator)->authorize(Capabilities::PROVIDER_RUN, $tenant);
|
||||
|
||||
return $this->providers->start(
|
||||
tenant: $tenant,
|
||||
connection: $connection,
|
||||
operationType: 'provider.connection.check',
|
||||
dispatcher: function (OperationRun $run) use ($tenant, $initiator, $connection): void {
|
||||
ProviderConnectionHealthCheckJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $initiator->getKey(),
|
||||
providerConnectionId: (int) $connection->getKey(),
|
||||
operationRun: $run,
|
||||
);
|
||||
},
|
||||
initiator: $initiator,
|
||||
extraContext: $extraContext,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -6,12 +6,6 @@
|
||||
|
||||
enum AuditActionId: string
|
||||
{
|
||||
case WorkspaceMembershipAdd = 'workspace_membership.add';
|
||||
case WorkspaceMembershipRoleChange = 'workspace_membership.role_change';
|
||||
case WorkspaceMembershipRemove = 'workspace_membership.remove';
|
||||
case WorkspaceMembershipLastOwnerBlocked = 'workspace_membership.last_owner_blocked';
|
||||
case WorkspaceMembershipBreakGlassAssignOwner = 'workspace_membership.break_glass.assign_owner';
|
||||
|
||||
case TenantMembershipAdd = 'tenant_membership.add';
|
||||
case TenantMembershipRoleChange = 'tenant_membership.role_change';
|
||||
case TenantMembershipRemove = 'tenant_membership.remove';
|
||||
@ -19,14 +13,4 @@ enum AuditActionId: string
|
||||
|
||||
// Not part of the v1 contract, but used in codebase.
|
||||
case TenantMembershipBootstrapRecover = 'tenant_membership.bootstrap_recover';
|
||||
|
||||
// Diagnostics / repair actions.
|
||||
case TenantMembershipDuplicatesMerged = 'tenant_membership.duplicates_merged';
|
||||
|
||||
// Managed tenant onboarding wizard.
|
||||
case ManagedTenantOnboardingStart = 'managed_tenant_onboarding.start';
|
||||
case ManagedTenantOnboardingResume = 'managed_tenant_onboarding.resume';
|
||||
case ManagedTenantOnboardingVerificationStart = 'managed_tenant_onboarding.verification_start';
|
||||
|
||||
case VerificationCompleted = 'verification.completed';
|
||||
}
|
||||
|
||||
@ -1,66 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Audit;
|
||||
|
||||
final class AuditContextSanitizer
|
||||
{
|
||||
private const REDACTED = '[REDACTED]';
|
||||
|
||||
public static function sanitize(mixed $value): mixed
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$sanitized = [];
|
||||
|
||||
foreach ($value as $key => $item) {
|
||||
if (is_string($key) && self::shouldRedactKey($key)) {
|
||||
$sanitized[$key] = self::REDACTED;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$sanitized[$key] = self::sanitize($item);
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
return self::sanitizeString($value);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function shouldRedactKey(string $key): bool
|
||||
{
|
||||
$key = strtolower(trim($key));
|
||||
|
||||
return str_contains($key, 'token')
|
||||
|| str_contains($key, 'secret')
|
||||
|| str_contains($key, 'password')
|
||||
|| str_contains($key, 'authorization')
|
||||
|| str_contains($key, 'private_key')
|
||||
|| str_contains($key, 'client_secret');
|
||||
}
|
||||
|
||||
private static function sanitizeString(string $value): string
|
||||
{
|
||||
$candidate = trim($value);
|
||||
|
||||
if ($candidate === '') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (preg_match('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', $candidate)) {
|
||||
return self::REDACTED;
|
||||
}
|
||||
|
||||
if (preg_match('/\b[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\b/', $candidate)) {
|
||||
return self::REDACTED;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@ -15,21 +15,6 @@ class Capabilities
|
||||
*/
|
||||
private static ?array $all = null;
|
||||
|
||||
// Workspaces
|
||||
public const WORKSPACE_VIEW = 'workspace.view';
|
||||
|
||||
public const WORKSPACE_MANAGE = 'workspace.manage';
|
||||
|
||||
public const WORKSPACE_ARCHIVE = 'workspace.archive';
|
||||
|
||||
// Workspace memberships
|
||||
public const WORKSPACE_MEMBERSHIP_VIEW = 'workspace_membership.view';
|
||||
|
||||
public const WORKSPACE_MEMBERSHIP_MANAGE = 'workspace_membership.manage';
|
||||
|
||||
// Managed tenant onboarding
|
||||
public const WORKSPACE_MANAGED_TENANT_ONBOARD = 'workspace_managed_tenant.onboard';
|
||||
|
||||
// Tenants
|
||||
public const TENANT_VIEW = 'tenant.view';
|
||||
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Auth;
|
||||
|
||||
enum WorkspaceRole: string
|
||||
{
|
||||
case Owner = 'owner';
|
||||
case Manager = 'manager';
|
||||
case Operator = 'operator';
|
||||
case Readonly = 'readonly';
|
||||
}
|
||||
@ -36,9 +36,6 @@ final class BadgeCatalog
|
||||
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,
|
||||
BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class,
|
||||
BadgeDomain::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::class,
|
||||
BadgeDomain::VerificationCheckStatus->value => Domains\VerificationCheckStatusBadge::class,
|
||||
BadgeDomain::VerificationCheckSeverity->value => Domains\VerificationCheckSeverityBadge::class,
|
||||
BadgeDomain::VerificationReportOverall->value => Domains\VerificationReportOverallBadge::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -28,7 +28,4 @@ enum BadgeDomain: string
|
||||
case RestoreResultStatus = 'restore_result_status';
|
||||
case ProviderConnectionStatus = 'provider_connection.status';
|
||||
case ProviderConnectionHealth = 'provider_connection.health';
|
||||
case VerificationCheckStatus = 'verification_check_status';
|
||||
case VerificationCheckSeverity = 'verification_check_severity';
|
||||
case VerificationReportOverall = 'verification_report_overall';
|
||||
}
|
||||
|
||||
@ -13,7 +13,6 @@ public function spec(mixed $value): BadgeSpec
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'pending' => new BadgeSpec('Pending', 'warning', 'heroicon-m-clock'),
|
||||
'active' => new BadgeSpec('Active', 'success', 'heroicon-m-check-circle'),
|
||||
'inactive' => new BadgeSpec('Inactive', 'gray', 'heroicon-m-minus-circle'),
|
||||
'archived' => new BadgeSpec('Archived', 'gray', 'heroicon-m-minus-circle'),
|
||||
|
||||
@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Verification\VerificationCheckSeverity;
|
||||
|
||||
final class VerificationCheckSeverityBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
VerificationCheckSeverity::Info->value => new BadgeSpec('Info', 'gray', 'heroicon-m-information-circle'),
|
||||
VerificationCheckSeverity::Low->value => new BadgeSpec('Low', 'info', 'heroicon-m-arrow-down'),
|
||||
VerificationCheckSeverity::Medium->value => new BadgeSpec('Medium', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
VerificationCheckSeverity::High->value => new BadgeSpec('High', 'danger', 'heroicon-m-exclamation-triangle'),
|
||||
VerificationCheckSeverity::Critical->value => new BadgeSpec('Critical', 'danger', 'heroicon-m-x-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Verification\VerificationCheckStatus;
|
||||
|
||||
final class VerificationCheckStatusBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
VerificationCheckStatus::Pass->value => new BadgeSpec('Pass', 'success', 'heroicon-m-check-circle'),
|
||||
VerificationCheckStatus::Fail->value => new BadgeSpec('Fail', 'danger', 'heroicon-m-x-circle'),
|
||||
VerificationCheckStatus::Warn->value => new BadgeSpec('Warn', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
VerificationCheckStatus::Skip->value => new BadgeSpec('Skipped', 'gray', 'heroicon-m-minus-circle'),
|
||||
VerificationCheckStatus::Running->value => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Verification\VerificationReportOverall;
|
||||
|
||||
final class VerificationReportOverallBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
VerificationReportOverall::Ready->value => new BadgeSpec('Ready', 'success', 'heroicon-m-check-circle'),
|
||||
VerificationReportOverall::NeedsAttention->value => new BadgeSpec('Needs attention', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
VerificationReportOverall::Blocked->value => new BadgeSpec('Blocked', 'danger', 'heroicon-m-x-circle'),
|
||||
VerificationReportOverall::Running->value => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,189 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Middleware;
|
||||
|
||||
use App\Filament\Pages\ChooseWorkspace;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Closure;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Models\Contracts\HasTenants;
|
||||
use Filament\Navigation\NavigationBuilder;
|
||||
use Filament\Navigation\NavigationItem;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureFilamentTenantSelected
|
||||
{
|
||||
/**
|
||||
* @param Closure(Request): Response $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$panel = Filament::getCurrentOrDefaultPanel();
|
||||
|
||||
$path = '/'.ltrim($request->path(), '/');
|
||||
|
||||
if ($request->route()?->hasParameter('tenant')) {
|
||||
$user = $request->user();
|
||||
|
||||
if ($user === null) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (! $user instanceof HasTenants) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $panel->hasTenancy()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$tenantParameter = $request->route()->parameter('tenant');
|
||||
$tenant = $panel->getTenant($tenantParameter);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$workspaceContext = app(WorkspaceContext::class);
|
||||
$workspaceId = $workspaceContext->currentWorkspaceId($request);
|
||||
|
||||
if ($workspaceId === null) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ((int) $tenant->workspace_id !== (int) $workspaceId) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user instanceof User || ! $workspaceContext->isMember($user, $workspace)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
$this->configureNavigationForRequest($panel);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (
|
||||
str_starts_with($path, '/admin/w/')
|
||||
|| str_starts_with($path, '/admin/workspaces')
|
||||
|| in_array($path, ['/admin/choose-workspace', '/admin/choose-tenant', '/admin/no-access'], true)
|
||||
) {
|
||||
$this->configureNavigationForRequest($panel);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (filled(Filament::getTenant())) {
|
||||
$this->configureNavigationForRequest($panel);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
$this->configureNavigationForRequest($panel);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$tenant = null;
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId($request);
|
||||
|
||||
if ($workspaceId !== null) {
|
||||
$tenant = $user->tenants()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('status', 'active')
|
||||
->first();
|
||||
|
||||
if (! $tenant) {
|
||||
$tenant = $user->tenants()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $tenant) {
|
||||
$tenant = $user->tenants()
|
||||
->withTrashed()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
|
||||
if (! $tenant) {
|
||||
try {
|
||||
$tenant = Tenant::current();
|
||||
} catch (\RuntimeException) {
|
||||
$tenant = null;
|
||||
}
|
||||
|
||||
if ($tenant instanceof Tenant && ! app(CapabilityResolver::class)->isMember($user, $tenant)) {
|
||||
$tenant = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $tenant) {
|
||||
$tenant = $user->tenants()
|
||||
->where('status', 'active')
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $tenant) {
|
||||
$tenant = $user->tenants()->first();
|
||||
}
|
||||
|
||||
if (! $tenant) {
|
||||
$tenant = $user->tenants()->withTrashed()->first();
|
||||
}
|
||||
|
||||
if ($tenant) {
|
||||
Filament::setTenant($tenant, true);
|
||||
}
|
||||
|
||||
$this->configureNavigationForRequest($panel);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function configureNavigationForRequest(\Filament\Panel $panel): void
|
||||
{
|
||||
if (! $panel->hasTenancy()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (filled(Filament::getTenant())) {
|
||||
$panel->navigation(true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$panel->navigation(function (): NavigationBuilder {
|
||||
return app(NavigationBuilder::class)
|
||||
->item(
|
||||
NavigationItem::make('Workspaces')
|
||||
->url(fn (): string => ChooseWorkspace::getUrl())
|
||||
->icon('heroicon-o-squares-2x2')
|
||||
->group('Settings')
|
||||
->sort(10),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -8,7 +8,6 @@
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\UiTooltips as AuthUiTooltips;
|
||||
use Closure;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkAction;
|
||||
@ -283,7 +282,7 @@ private function applyDisabledState(): void
|
||||
return;
|
||||
}
|
||||
|
||||
$tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission();
|
||||
$tooltip = $this->customTooltip ?? UiTooltips::INSUFFICIENT_PERMISSION;
|
||||
|
||||
$this->action->disabled(function (?Model $record = null) {
|
||||
$context = $this->resolveContextWithRecord($record);
|
||||
|
||||
@ -30,14 +30,4 @@ final class UiTooltips
|
||||
* Modal description for destructive action confirmation.
|
||||
*/
|
||||
public const DESTRUCTIVE_CONFIRM_DESCRIPTION = 'This action cannot be undone.';
|
||||
|
||||
/**
|
||||
* Tooltip for actions that are unavailable because the tenant is archived.
|
||||
*/
|
||||
public const TENANT_ARCHIVED = 'This tenant is archived.';
|
||||
|
||||
/**
|
||||
* Tooltip for actions that are unavailable because a tenant must always have an owner.
|
||||
*/
|
||||
public const TENANT_OWNER_REQUIRED = 'This tenant must have at least one Owner.';
|
||||
}
|
||||
|
||||
@ -1,45 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Rbac;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
||||
/**
|
||||
* DTO representing the access context for a workspace-scoped UI action.
|
||||
*/
|
||||
final readonly class WorkspaceAccessContext
|
||||
{
|
||||
public function __construct(
|
||||
public ?User $user,
|
||||
public ?Workspace $workspace,
|
||||
public bool $isMember,
|
||||
public bool $hasCapability,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Non-members should receive 404 (deny-as-not-found).
|
||||
*/
|
||||
public function shouldDenyAsNotFound(): bool
|
||||
{
|
||||
return ! $this->isMember;
|
||||
}
|
||||
|
||||
/**
|
||||
* Members without capability should receive 403 (forbidden).
|
||||
*/
|
||||
public function shouldDenyAsForbidden(): bool
|
||||
{
|
||||
return $this->isMember && ! $this->hasCapability;
|
||||
}
|
||||
|
||||
/**
|
||||
* User is authorized to perform the action.
|
||||
*/
|
||||
public function isAuthorized(): bool
|
||||
{
|
||||
return $this->isMember && $this->hasCapability;
|
||||
}
|
||||
}
|
||||
@ -1,230 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Rbac;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\UiTooltips as AuthUiTooltips;
|
||||
use Closure;
|
||||
use Filament\Actions\Action;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Central workspace-scoped RBAC UI Enforcement Helper for Filament Actions.
|
||||
*
|
||||
* Mirrors the tenant-scoped UiEnforcement semantics, but uses WorkspaceMembership
|
||||
* + WorkspaceCapabilityResolver.
|
||||
*
|
||||
* Rules:
|
||||
* - Non-member → hidden UI + 404 server-side
|
||||
* - Member without capability → visible-but-disabled + tooltip + 403 server-side
|
||||
* - Member with capability → enabled
|
||||
*/
|
||||
final class WorkspaceUiEnforcement
|
||||
{
|
||||
private Action $action;
|
||||
|
||||
private bool $requireMembership = true;
|
||||
|
||||
private ?string $capability = null;
|
||||
|
||||
private bool $isDestructive = false;
|
||||
|
||||
private ?string $customTooltip = null;
|
||||
|
||||
private Model|Closure|null $record = null;
|
||||
|
||||
private function __construct(Action $action)
|
||||
{
|
||||
$this->action = $action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create enforcement for a table action.
|
||||
*
|
||||
* @param Action $action The Filament action to wrap
|
||||
* @param Model|Closure $record The owner record or a closure that returns it
|
||||
*/
|
||||
public static function forTableAction(Action $action, Model|Closure $record): self
|
||||
{
|
||||
$instance = new self($action);
|
||||
$instance->record = $record;
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
public function requireMembership(bool $require = true): self
|
||||
{
|
||||
$this->requireMembership = $require;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException If capability is not in the canonical registry
|
||||
*/
|
||||
public function requireCapability(string $capability): self
|
||||
{
|
||||
if (! Capabilities::isKnown($capability)) {
|
||||
throw new \InvalidArgumentException(
|
||||
"Unknown capability: {$capability}. Use constants from ".Capabilities::class
|
||||
);
|
||||
}
|
||||
|
||||
$this->capability = $capability;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function destructive(): self
|
||||
{
|
||||
$this->isDestructive = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function tooltip(string $message): self
|
||||
{
|
||||
$this->customTooltip = $message;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function apply(): Action
|
||||
{
|
||||
$this->applyVisibility();
|
||||
$this->applyDisabledState();
|
||||
$this->applyDestructiveConfirmation();
|
||||
$this->applyServerSideGuard();
|
||||
|
||||
return $this->action;
|
||||
}
|
||||
|
||||
private function applyVisibility(): void
|
||||
{
|
||||
if (! $this->requireMembership) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->action->visible(function (?Model $record = null): bool {
|
||||
$context = $this->resolveContextWithRecord($record);
|
||||
|
||||
return $context->isMember;
|
||||
});
|
||||
}
|
||||
|
||||
private function applyDisabledState(): void
|
||||
{
|
||||
if ($this->capability === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission();
|
||||
|
||||
$this->action->disabled(function (?Model $record = null): bool {
|
||||
$context = $this->resolveContextWithRecord($record);
|
||||
|
||||
if (! $context->isMember) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! $context->hasCapability;
|
||||
});
|
||||
|
||||
$this->action->tooltip(function (?Model $record = null) use ($tooltip): ?string {
|
||||
$context = $this->resolveContextWithRecord($record);
|
||||
|
||||
if ($context->isMember && ! $context->hasCapability) {
|
||||
return $tooltip;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private function applyDestructiveConfirmation(): void
|
||||
{
|
||||
if (! $this->isDestructive) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->action->requiresConfirmation();
|
||||
$this->action->modalHeading(UiTooltips::DESTRUCTIVE_CONFIRM_TITLE);
|
||||
$this->action->modalDescription(UiTooltips::DESTRUCTIVE_CONFIRM_DESCRIPTION);
|
||||
}
|
||||
|
||||
private function applyServerSideGuard(): void
|
||||
{
|
||||
$this->action->before(function (?Model $record = null): void {
|
||||
$context = $this->resolveContextWithRecord($record);
|
||||
|
||||
if ($context->shouldDenyAsNotFound()) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($context->shouldDenyAsForbidden()) {
|
||||
abort(403);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function resolveContextWithRecord(?Model $record = null): WorkspaceAccessContext
|
||||
{
|
||||
$user = auth()->user();
|
||||
$workspace = $this->resolveWorkspaceWithRecord($record);
|
||||
|
||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||
return new WorkspaceAccessContext(
|
||||
user: null,
|
||||
workspace: null,
|
||||
isMember: false,
|
||||
hasCapability: false,
|
||||
);
|
||||
}
|
||||
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
$isMember = $resolver->isMember($user, $workspace);
|
||||
|
||||
$hasCapability = true;
|
||||
if ($this->capability !== null && $isMember) {
|
||||
$hasCapability = $resolver->can($user, $workspace, $this->capability);
|
||||
}
|
||||
|
||||
return new WorkspaceAccessContext(
|
||||
user: $user,
|
||||
workspace: $workspace,
|
||||
isMember: $isMember,
|
||||
hasCapability: $hasCapability,
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveWorkspaceWithRecord(?Model $record = null): ?Workspace
|
||||
{
|
||||
if ($record instanceof Workspace) {
|
||||
return $record;
|
||||
}
|
||||
|
||||
if ($this->record !== null) {
|
||||
try {
|
||||
$resolved = $this->record instanceof Closure
|
||||
? ($this->record)()
|
||||
: $this->record;
|
||||
|
||||
if ($resolved instanceof Workspace) {
|
||||
return $resolved;
|
||||
}
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Verification;
|
||||
|
||||
enum VerificationCheckSeverity: string
|
||||
{
|
||||
case Info = 'info';
|
||||
case Low = 'low';
|
||||
case Medium = 'medium';
|
||||
case High = 'high';
|
||||
case Critical = 'critical';
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_map(static fn (self $case): string => $case->value, self::cases());
|
||||
}
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Verification;
|
||||
|
||||
enum VerificationCheckStatus: string
|
||||
{
|
||||
case Pass = 'pass';
|
||||
case Fail = 'fail';
|
||||
case Warn = 'warn';
|
||||
case Skip = 'skip';
|
||||
case Running = 'running';
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_map(static fn (self $case): string => $case->value, self::cases());
|
||||
}
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Verification;
|
||||
|
||||
enum VerificationReportOverall: string
|
||||
{
|
||||
case Ready = 'ready';
|
||||
case NeedsAttention = 'needs_attention';
|
||||
case Blocked = 'blocked';
|
||||
case Running = 'running';
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_map(static fn (self $case): string => $case->value, self::cases());
|
||||
}
|
||||
}
|
||||
@ -1,358 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Verification;
|
||||
|
||||
final class VerificationReportSanitizer
|
||||
{
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const FORBIDDEN_KEY_SUBSTRINGS = [
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
'client_secret',
|
||||
'authorization',
|
||||
'password',
|
||||
'cookie',
|
||||
'set-cookie',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function sanitizeReport(array $report): array
|
||||
{
|
||||
$sanitized = [];
|
||||
|
||||
$schemaVersion = self::sanitizeShortString($report['schema_version'] ?? null, fallback: null);
|
||||
if ($schemaVersion !== null) {
|
||||
$sanitized['schema_version'] = $schemaVersion;
|
||||
}
|
||||
|
||||
$flow = self::sanitizeShortString($report['flow'] ?? null, fallback: null);
|
||||
if ($flow !== null) {
|
||||
$sanitized['flow'] = $flow;
|
||||
}
|
||||
|
||||
$generatedAt = self::sanitizeShortString($report['generated_at'] ?? null, fallback: null);
|
||||
if ($generatedAt !== null) {
|
||||
$sanitized['generated_at'] = $generatedAt;
|
||||
}
|
||||
|
||||
if (is_array($report['identity'] ?? null)) {
|
||||
$identity = self::sanitizeIdentity((array) $report['identity']);
|
||||
|
||||
if ($identity !== []) {
|
||||
$sanitized['identity'] = $identity;
|
||||
}
|
||||
}
|
||||
|
||||
$summary = is_array($report['summary'] ?? null) ? (array) $report['summary'] : [];
|
||||
$summary = self::sanitizeSummary($summary);
|
||||
|
||||
if ($summary !== null) {
|
||||
$sanitized['summary'] = $summary;
|
||||
}
|
||||
|
||||
$checks = is_array($report['checks'] ?? null) ? (array) $report['checks'] : [];
|
||||
$checks = self::sanitizeChecks($checks);
|
||||
|
||||
if ($checks !== null) {
|
||||
$sanitized['checks'] = $checks;
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $identity
|
||||
* @return array<string, int|string>
|
||||
*/
|
||||
private static function sanitizeIdentity(array $identity): array
|
||||
{
|
||||
$sanitized = [];
|
||||
|
||||
foreach ($identity as $key => $value) {
|
||||
if (! is_string($key) || trim($key) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (self::containsForbiddenKeySubstring($key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_int($value)) {
|
||||
$sanitized[$key] = $value;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = self::sanitizeValueString($value);
|
||||
|
||||
if ($value !== null) {
|
||||
$sanitized[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $summary
|
||||
* @return array{overall: string, counts: array{total: int, pass: int, fail: int, warn: int, skip: int, running: int}}|null
|
||||
*/
|
||||
private static function sanitizeSummary(array $summary): ?array
|
||||
{
|
||||
$overall = $summary['overall'] ?? null;
|
||||
|
||||
if (! is_string($overall) || ! in_array($overall, VerificationReportOverall::values(), true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$counts = is_array($summary['counts'] ?? null) ? (array) $summary['counts'] : [];
|
||||
|
||||
foreach (['total', 'pass', 'fail', 'warn', 'skip', 'running'] as $key) {
|
||||
if (! is_int($counts[$key] ?? null) || $counts[$key] < 0) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'overall' => $overall,
|
||||
'counts' => [
|
||||
'total' => $counts['total'],
|
||||
'pass' => $counts['pass'],
|
||||
'fail' => $counts['fail'],
|
||||
'warn' => $counts['warn'],
|
||||
'skip' => $counts['skip'],
|
||||
'running' => $counts['running'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $checks
|
||||
* @return array<int, array<string, mixed>>|null
|
||||
*/
|
||||
private static function sanitizeChecks(array $checks): ?array
|
||||
{
|
||||
if ($checks === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$sanitized = [];
|
||||
|
||||
foreach ($checks as $check) {
|
||||
if (! is_array($check)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = self::sanitizeShortString($check['key'] ?? null, fallback: null);
|
||||
$title = self::sanitizeShortString($check['title'] ?? null, fallback: null);
|
||||
$reasonCode = self::sanitizeShortString($check['reason_code'] ?? null, fallback: null);
|
||||
|
||||
if ($key === null || $title === null || $reasonCode === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$status = $check['status'] ?? null;
|
||||
if (! is_string($status) || ! in_array($status, VerificationCheckStatus::values(), true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$severity = $check['severity'] ?? null;
|
||||
if (! is_string($severity) || ! in_array($severity, VerificationCheckSeverity::values(), true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$messageRaw = $check['message'] ?? null;
|
||||
if (! is_string($messageRaw) || trim($messageRaw) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$blocking = is_bool($check['blocking'] ?? null) ? (bool) $check['blocking'] : false;
|
||||
|
||||
$sanitized[] = [
|
||||
'key' => $key,
|
||||
'title' => $title,
|
||||
'status' => $status,
|
||||
'severity' => $severity,
|
||||
'blocking' => $blocking,
|
||||
'reason_code' => $reasonCode,
|
||||
'message' => self::sanitizeMessage($messageRaw),
|
||||
'evidence' => self::sanitizeEvidence(is_array($check['evidence'] ?? null) ? (array) $check['evidence'] : []),
|
||||
'next_steps' => self::sanitizeNextSteps(is_array($check['next_steps'] ?? null) ? (array) $check['next_steps'] : []),
|
||||
];
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $evidence
|
||||
* @return array<int, array{kind: string, value: int|string}>
|
||||
*/
|
||||
private static function sanitizeEvidence(array $evidence): array
|
||||
{
|
||||
$sanitized = [];
|
||||
|
||||
foreach ($evidence as $pointer) {
|
||||
if (! is_array($pointer)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$kind = $pointer['kind'] ?? null;
|
||||
if (! is_string($kind) || trim($kind) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (self::containsForbiddenKeySubstring($kind)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $pointer['value'] ?? null;
|
||||
|
||||
if (is_int($value)) {
|
||||
$sanitized[] = ['kind' => trim($kind), 'value' => $value];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sanitizedValue = self::sanitizeValueString($value);
|
||||
|
||||
if ($sanitizedValue === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sanitized[] = ['kind' => trim($kind), 'value' => $sanitizedValue];
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $nextSteps
|
||||
* @return array<int, array{label: string, url: string}>
|
||||
*/
|
||||
private static function sanitizeNextSteps(array $nextSteps): array
|
||||
{
|
||||
$sanitized = [];
|
||||
|
||||
foreach ($nextSteps as $step) {
|
||||
if (! is_array($step)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = self::sanitizeShortString($step['label'] ?? null, fallback: null);
|
||||
$url = self::sanitizeShortString($step['url'] ?? null, fallback: null);
|
||||
|
||||
if ($label === null || $url === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sanitized[] = [
|
||||
'label' => $label,
|
||||
'url' => $url,
|
||||
];
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
private static function sanitizeMessage(mixed $message): string
|
||||
{
|
||||
if (! is_string($message)) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
$message = trim(str_replace(["\r", "\n"], ' ', $message));
|
||||
|
||||
$message = preg_replace('/\bAuthorization\s*:\s*[^\s]+(?:\s+[^\s]+)?/i', '[REDACTED_AUTH]', $message) ?? $message;
|
||||
$message = preg_replace('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', '[REDACTED_AUTH]', $message) ?? $message;
|
||||
|
||||
$message = preg_replace('/\b(access_token|refresh_token|client_secret|password)\b\s*[:=]\s*[^\s,;]+/i', '[REDACTED_SECRET]', $message) ?? $message;
|
||||
$message = preg_replace('/"(access_token|refresh_token|client_secret|password)"\s*:\s*"[^"]*"/i', '"[REDACTED]":"[REDACTED]"', $message) ?? $message;
|
||||
|
||||
$message = preg_replace('/\b[A-Za-z0-9\-\._~\+\/]{64,}\b/', '[REDACTED]', $message) ?? $message;
|
||||
|
||||
$message = str_ireplace(
|
||||
['client_secret', 'access_token', 'refresh_token', 'authorization', 'bearer '],
|
||||
'[REDACTED]',
|
||||
$message,
|
||||
);
|
||||
|
||||
$message = trim($message);
|
||||
|
||||
return $message === '' ? '—' : substr($message, 0, 240);
|
||||
}
|
||||
|
||||
private static function sanitizeShortString(mixed $value, ?string $fallback): ?string
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
if ($value === '') {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
if (self::containsForbiddenKeySubstring($value)) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
return substr($value, 0, 200);
|
||||
}
|
||||
|
||||
private static function sanitizeValueString(string $value): ?string
|
||||
{
|
||||
$value = trim($value);
|
||||
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', $value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (strlen($value) > 512) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/\b[A-Za-z0-9\-\._~\+\/]{128,}\b/', $value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$lower = strtolower($value);
|
||||
foreach (self::FORBIDDEN_KEY_SUBSTRINGS as $needle) {
|
||||
if (str_contains($lower, $needle)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function containsForbiddenKeySubstring(string $value): bool
|
||||
{
|
||||
$lower = strtolower($value);
|
||||
|
||||
foreach (self::FORBIDDEN_KEY_SUBSTRINGS as $needle) {
|
||||
if (str_contains($lower, $needle)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -1,235 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Verification;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
final class VerificationReportSchema
|
||||
{
|
||||
public const string CURRENT_SCHEMA_VERSION = '1.0.0';
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function normalizeReport(mixed $report): ?array
|
||||
{
|
||||
if (! is_array($report)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! self::isValidReport($report)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $report
|
||||
*/
|
||||
public static function isValidReport(array $report): bool
|
||||
{
|
||||
$schemaVersion = self::schemaVersion($report);
|
||||
if ($schemaVersion === null || ! self::isSupportedSchemaVersion($schemaVersion)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! self::isNonEmptyString($report['flow'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! self::isIsoDateTimeString($report['generated_at'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (array_key_exists('identity', $report) && ! is_array($report['identity'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$summary = $report['summary'] ?? null;
|
||||
if (! is_array($summary)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$overall = $summary['overall'] ?? null;
|
||||
if (! is_string($overall) || ! in_array($overall, VerificationReportOverall::values(), true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$counts = $summary['counts'] ?? null;
|
||||
if (! is_array($counts)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (['total', 'pass', 'fail', 'warn', 'skip', 'running'] as $key) {
|
||||
if (! self::isNonNegativeInt($counts[$key] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$checks = $report['checks'] ?? null;
|
||||
if (! is_array($checks)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($checks as $check) {
|
||||
if (! is_array($check) || ! self::isValidCheckResult($check)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $report
|
||||
*/
|
||||
public static function schemaVersion(array $report): ?string
|
||||
{
|
||||
$candidate = $report['schema_version'] ?? null;
|
||||
|
||||
if (! is_string($candidate)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$candidate = trim($candidate);
|
||||
|
||||
if ($candidate === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! preg_match('/^\d+\.\d+\.\d+$/', $candidate)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
public static function isSupportedSchemaVersion(string $schemaVersion): bool
|
||||
{
|
||||
$parts = explode('.', $schemaVersion, 3);
|
||||
|
||||
if (count($parts) !== 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$major = (int) $parts[0];
|
||||
|
||||
return $major === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $check
|
||||
*/
|
||||
private static function isValidCheckResult(array $check): bool
|
||||
{
|
||||
if (! self::isNonEmptyString($check['key'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! self::isNonEmptyString($check['title'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$status = $check['status'] ?? null;
|
||||
if (! is_string($status) || ! in_array($status, VerificationCheckStatus::values(), true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$severity = $check['severity'] ?? null;
|
||||
if (! is_string($severity) || ! in_array($severity, VerificationCheckSeverity::values(), true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! is_bool($check['blocking'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! self::isNonEmptyString($check['reason_code'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! self::isNonEmptyString($check['message'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$evidence = $check['evidence'] ?? null;
|
||||
if (! is_array($evidence)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($evidence as $pointer) {
|
||||
if (! is_array($pointer) || ! self::isValidEvidencePointer($pointer)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$nextSteps = $check['next_steps'] ?? null;
|
||||
if (! is_array($nextSteps)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($nextSteps as $step) {
|
||||
if (! is_array($step) || ! self::isValidNextStep($step)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $pointer
|
||||
*/
|
||||
private static function isValidEvidencePointer(array $pointer): bool
|
||||
{
|
||||
if (! self::isNonEmptyString($pointer['kind'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$value = $pointer['value'] ?? null;
|
||||
|
||||
return is_int($value) || self::isNonEmptyString($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $step
|
||||
*/
|
||||
private static function isValidNextStep(array $step): bool
|
||||
{
|
||||
if (! self::isNonEmptyString($step['label'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! self::isNonEmptyString($step['url'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function isNonEmptyString(mixed $value): bool
|
||||
{
|
||||
return is_string($value) && trim($value) !== '';
|
||||
}
|
||||
|
||||
private static function isNonNegativeInt(mixed $value): bool
|
||||
{
|
||||
return is_int($value) && $value >= 0;
|
||||
}
|
||||
|
||||
private static function isIsoDateTimeString(mixed $value): bool
|
||||
{
|
||||
if (! self::isNonEmptyString($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
new DateTimeImmutable((string) $value);
|
||||
|
||||
return true;
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,343 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Verification;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
|
||||
final class VerificationReportWriter
|
||||
{
|
||||
/**
|
||||
* Baseline reason code taxonomy (v1).
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const array BASELINE_REASON_CODES = [
|
||||
'ok',
|
||||
'not_applicable',
|
||||
'missing_configuration',
|
||||
'permission_denied',
|
||||
'authentication_failed',
|
||||
'throttled',
|
||||
'dependency_unreachable',
|
||||
'invalid_state',
|
||||
'unknown_error',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $checks
|
||||
* @param array<string, mixed> $identity
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function write(OperationRun $run, array $checks, array $identity = []): array
|
||||
{
|
||||
$flow = is_string($run->type) && trim($run->type) !== '' ? (string) $run->type : 'unknown';
|
||||
|
||||
$report = self::build($flow, $checks, $identity);
|
||||
$report = VerificationReportSanitizer::sanitizeReport($report);
|
||||
|
||||
if (! VerificationReportSchema::isValidReport($report)) {
|
||||
$report = VerificationReportSanitizer::sanitizeReport(self::buildFallbackReport($flow));
|
||||
}
|
||||
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$context['verification_report'] = $report;
|
||||
|
||||
$run->update(['context' => $context]);
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $checks
|
||||
* @param array<string, mixed> $identity
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function build(string $flow, array $checks, array $identity = []): array
|
||||
{
|
||||
$flow = trim($flow);
|
||||
$flow = $flow !== '' ? $flow : 'unknown';
|
||||
|
||||
$normalizedChecks = [];
|
||||
|
||||
foreach ($checks as $check) {
|
||||
if (! is_array($check)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedChecks[] = self::normalizeCheckResult($check);
|
||||
}
|
||||
|
||||
$counts = self::deriveCounts($normalizedChecks);
|
||||
|
||||
$report = [
|
||||
'schema_version' => VerificationReportSchema::CURRENT_SCHEMA_VERSION,
|
||||
'flow' => $flow,
|
||||
'generated_at' => now()->toISOString(),
|
||||
'summary' => [
|
||||
'overall' => self::deriveOverall($normalizedChecks, $counts),
|
||||
'counts' => $counts,
|
||||
],
|
||||
'checks' => $normalizedChecks,
|
||||
];
|
||||
|
||||
if ($identity !== []) {
|
||||
$report['identity'] = $identity;
|
||||
}
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function buildFallbackReport(string $flow): array
|
||||
{
|
||||
return [
|
||||
'schema_version' => VerificationReportSchema::CURRENT_SCHEMA_VERSION,
|
||||
'flow' => $flow !== '' ? $flow : 'unknown',
|
||||
'generated_at' => now()->toISOString(),
|
||||
'summary' => [
|
||||
'overall' => VerificationReportOverall::NeedsAttention->value,
|
||||
'counts' => [
|
||||
'total' => 0,
|
||||
'pass' => 0,
|
||||
'fail' => 0,
|
||||
'warn' => 0,
|
||||
'skip' => 0,
|
||||
'running' => 0,
|
||||
],
|
||||
],
|
||||
'checks' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $check
|
||||
* @return array{
|
||||
* key: string,
|
||||
* title: string,
|
||||
* status: string,
|
||||
* severity: string,
|
||||
* blocking: bool,
|
||||
* reason_code: string,
|
||||
* message: string,
|
||||
* evidence: array<int, array{kind: string, value: int|string}>,
|
||||
* next_steps: array<int, array{label: string, url: string}>
|
||||
* }
|
||||
*/
|
||||
private static function normalizeCheckResult(array $check): array
|
||||
{
|
||||
$key = self::normalizeNonEmptyString($check['key'] ?? null, fallback: 'unknown_check');
|
||||
$title = self::normalizeNonEmptyString($check['title'] ?? null, fallback: 'Check');
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'title' => $title,
|
||||
'status' => self::normalizeCheckStatus($check['status'] ?? null),
|
||||
'severity' => self::normalizeCheckSeverity($check['severity'] ?? null),
|
||||
'blocking' => is_bool($check['blocking'] ?? null) ? (bool) $check['blocking'] : false,
|
||||
'reason_code' => self::normalizeReasonCode($check['reason_code'] ?? null),
|
||||
'message' => self::normalizeNonEmptyString($check['message'] ?? null, fallback: '—'),
|
||||
'evidence' => self::normalizeEvidence($check['evidence'] ?? null),
|
||||
'next_steps' => self::normalizeNextSteps($check['next_steps'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
private static function normalizeCheckStatus(mixed $status): string
|
||||
{
|
||||
if (! is_string($status)) {
|
||||
return VerificationCheckStatus::Fail->value;
|
||||
}
|
||||
|
||||
$status = strtolower(trim($status));
|
||||
|
||||
return in_array($status, VerificationCheckStatus::values(), true)
|
||||
? $status
|
||||
: VerificationCheckStatus::Fail->value;
|
||||
}
|
||||
|
||||
private static function normalizeCheckSeverity(mixed $severity): string
|
||||
{
|
||||
if (! is_string($severity)) {
|
||||
return VerificationCheckSeverity::Info->value;
|
||||
}
|
||||
|
||||
$severity = strtolower(trim($severity));
|
||||
|
||||
return in_array($severity, VerificationCheckSeverity::values(), true)
|
||||
? $severity
|
||||
: VerificationCheckSeverity::Info->value;
|
||||
}
|
||||
|
||||
private static function normalizeReasonCode(mixed $reasonCode): string
|
||||
{
|
||||
if (! is_string($reasonCode)) {
|
||||
return 'unknown_error';
|
||||
}
|
||||
|
||||
$reasonCode = strtolower(trim($reasonCode));
|
||||
|
||||
if ($reasonCode === '') {
|
||||
return 'unknown_error';
|
||||
}
|
||||
|
||||
if (str_starts_with($reasonCode, 'ext.')) {
|
||||
return $reasonCode;
|
||||
}
|
||||
|
||||
$reasonCode = match ($reasonCode) {
|
||||
'graph_throttled' => 'throttled',
|
||||
'graph_timeout', 'provider_outage' => 'dependency_unreachable',
|
||||
'provider_auth_failed' => 'authentication_failed',
|
||||
'validation_error', 'conflict_detected' => 'invalid_state',
|
||||
'unknown' => 'unknown_error',
|
||||
default => $reasonCode,
|
||||
};
|
||||
|
||||
return in_array($reasonCode, self::BASELINE_REASON_CODES, true) ? $reasonCode : 'unknown_error';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{kind: string, value: int|string}>
|
||||
*/
|
||||
private static function normalizeEvidence(mixed $evidence): array
|
||||
{
|
||||
if (! is_array($evidence)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
|
||||
foreach ($evidence as $pointer) {
|
||||
if (! is_array($pointer)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$kind = self::normalizeNonEmptyString($pointer['kind'] ?? null, fallback: null);
|
||||
$value = $pointer['value'] ?? null;
|
||||
|
||||
if ($kind === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! is_int($value) && ! is_string($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_string($value) && trim($value) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = [
|
||||
'kind' => $kind,
|
||||
'value' => is_int($value) ? $value : trim($value),
|
||||
];
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{label: string, url: string}>
|
||||
*/
|
||||
private static function normalizeNextSteps(mixed $steps): array
|
||||
{
|
||||
if (! is_array($steps)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
|
||||
foreach ($steps as $step) {
|
||||
if (! is_array($step)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = self::normalizeNonEmptyString($step['label'] ?? null, fallback: null);
|
||||
$url = self::normalizeNonEmptyString($step['url'] ?? null, fallback: null);
|
||||
|
||||
if ($label === null || $url === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = [
|
||||
'label' => $label,
|
||||
'url' => $url,
|
||||
];
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{status: string, blocking: bool}> $checks
|
||||
* @return array{total: int, pass: int, fail: int, warn: int, skip: int, running: int}
|
||||
*/
|
||||
private static function deriveCounts(array $checks): array
|
||||
{
|
||||
$counts = [
|
||||
'total' => count($checks),
|
||||
'pass' => 0,
|
||||
'fail' => 0,
|
||||
'warn' => 0,
|
||||
'skip' => 0,
|
||||
'running' => 0,
|
||||
];
|
||||
|
||||
foreach ($checks as $check) {
|
||||
$status = $check['status'] ?? null;
|
||||
|
||||
if (! is_string($status) || ! array_key_exists($status, $counts)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$counts[$status] += 1;
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{status: string, blocking: bool}> $checks
|
||||
* @param array{total: int, pass: int, fail: int, warn: int, skip: int, running: int} $counts
|
||||
*/
|
||||
private static function deriveOverall(array $checks, array $counts): string
|
||||
{
|
||||
if (($counts['running'] ?? 0) > 0) {
|
||||
return VerificationReportOverall::Running->value;
|
||||
}
|
||||
|
||||
if (($counts['total'] ?? 0) === 0) {
|
||||
return VerificationReportOverall::NeedsAttention->value;
|
||||
}
|
||||
|
||||
foreach ($checks as $check) {
|
||||
if (($check['status'] ?? null) === VerificationCheckStatus::Fail->value && ($check['blocking'] ?? false) === true) {
|
||||
return VerificationReportOverall::Blocked->value;
|
||||
}
|
||||
}
|
||||
|
||||
if (($counts['fail'] ?? 0) > 0 || ($counts['warn'] ?? 0) > 0) {
|
||||
return VerificationReportOverall::NeedsAttention->value;
|
||||
}
|
||||
|
||||
return VerificationReportOverall::Ready->value;
|
||||
}
|
||||
|
||||
private static function normalizeNonEmptyString(mixed $value, ?string $fallback): ?string
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
if ($value === '') {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@ -1,129 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Workspaces;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class WorkspaceContext
|
||||
{
|
||||
public const SESSION_KEY = 'current_workspace_id';
|
||||
|
||||
public function __construct(private WorkspaceResolver $resolver) {}
|
||||
|
||||
public function currentWorkspaceId(?Request $request = null): ?int
|
||||
{
|
||||
$session = ($request && $request->hasSession()) ? $request->session() : session();
|
||||
|
||||
$id = $session->get(self::SESSION_KEY);
|
||||
|
||||
return is_int($id) ? $id : (is_numeric($id) ? (int) $id : null);
|
||||
}
|
||||
|
||||
public function currentWorkspace(?Request $request = null): ?Workspace
|
||||
{
|
||||
$id = $this->currentWorkspaceId($request);
|
||||
|
||||
if (! $id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($id)->first();
|
||||
|
||||
if (! $workspace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $this->isWorkspaceSelectable($workspace)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $workspace;
|
||||
}
|
||||
|
||||
public function setCurrentWorkspace(Workspace $workspace, ?User $user = null, ?Request $request = null): void
|
||||
{
|
||||
$session = ($request && $request->hasSession()) ? $request->session() : session();
|
||||
$session->put(self::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
if ($user !== null) {
|
||||
$user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save();
|
||||
}
|
||||
}
|
||||
|
||||
public function clearCurrentWorkspace(?User $user = null, ?Request $request = null): void
|
||||
{
|
||||
$session = ($request && $request->hasSession()) ? $request->session() : session();
|
||||
$session->forget(self::SESSION_KEY);
|
||||
|
||||
if ($user !== null && $user->last_workspace_id !== null) {
|
||||
$user->forceFill(['last_workspace_id' => null])->save();
|
||||
}
|
||||
}
|
||||
|
||||
public function resolveInitialWorkspaceFor(User $user, ?Request $request = null): ?Workspace
|
||||
{
|
||||
$session = ($request && $request->hasSession()) ? $request->session() : session();
|
||||
|
||||
$currentId = $this->currentWorkspaceId($request);
|
||||
|
||||
if ($currentId) {
|
||||
$current = Workspace::query()->whereKey($currentId)->first();
|
||||
|
||||
if (! $current instanceof Workspace || ! $this->isWorkspaceSelectable($current) || ! $this->isMember($user, $current)) {
|
||||
$session->forget(self::SESSION_KEY);
|
||||
|
||||
if ((int) $user->last_workspace_id === (int) $currentId) {
|
||||
$user->forceFill(['last_workspace_id' => null])->save();
|
||||
}
|
||||
} else {
|
||||
return $current;
|
||||
}
|
||||
}
|
||||
|
||||
if ($user->last_workspace_id !== null) {
|
||||
$workspace = Workspace::query()->whereKey($user->last_workspace_id)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace || ! $this->isWorkspaceSelectable($workspace) || ! $this->isMember($user, $workspace)) {
|
||||
$user->forceFill(['last_workspace_id' => null])->save();
|
||||
}
|
||||
}
|
||||
|
||||
$memberships = WorkspaceMembership::query()
|
||||
->where('user_id', $user->getKey())
|
||||
->with('workspace')
|
||||
->get();
|
||||
|
||||
$selectableWorkspaces = $memberships
|
||||
->map(fn (WorkspaceMembership $membership) => $membership->workspace)
|
||||
->filter(fn (?Workspace $workspace) => $workspace instanceof Workspace && $this->isWorkspaceSelectable($workspace))
|
||||
->values();
|
||||
|
||||
if ($selectableWorkspaces->count() === 1) {
|
||||
/** @var Workspace $workspace */
|
||||
$workspace = $selectableWorkspaces->first();
|
||||
|
||||
$session->put(self::SESSION_KEY, (int) $workspace->getKey());
|
||||
$user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save();
|
||||
|
||||
return $workspace;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function isMember(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return WorkspaceMembership::query()
|
||||
->where('user_id', $user->getKey())
|
||||
->where('workspace_id', $workspace->getKey())
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function isWorkspaceSelectable(Workspace $workspace): bool
|
||||
{
|
||||
return empty($workspace->archived_at);
|
||||
}
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Workspaces;
|
||||
|
||||
use App\Models\Workspace;
|
||||
|
||||
final class WorkspaceResolver
|
||||
{
|
||||
public function resolve(string $value): ?Workspace
|
||||
{
|
||||
$workspace = Workspace::query()
|
||||
->where('slug', $value)
|
||||
->first();
|
||||
|
||||
if ($workspace !== null) {
|
||||
return $workspace;
|
||||
}
|
||||
|
||||
if (! ctype_digit($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Workspace::query()->whereKey((int) $value)->first();
|
||||
}
|
||||
}
|
||||
@ -14,17 +14,12 @@
|
||||
$middleware->alias([
|
||||
'ensure-correct-guard' => \App\Http\Middleware\EnsureCorrectGuard::class,
|
||||
'ensure-platform-capability' => \App\Http\Middleware\EnsurePlatformCapability::class,
|
||||
'ensure-workspace-member' => \App\Http\Middleware\EnsureWorkspaceMember::class,
|
||||
'ensure-workspace-selected' => \App\Http\Middleware\EnsureWorkspaceSelected::class,
|
||||
'ensure-filament-tenant-selected' => \App\Support\Middleware\EnsureFilamentTenantSelected::class,
|
||||
]);
|
||||
|
||||
$middleware->prependToPriorityList(
|
||||
\Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class,
|
||||
\App\Http\Middleware\EnsureCorrectGuard::class,
|
||||
);
|
||||
|
||||
$middleware->redirectGuestsTo('/admin/login');
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Workspace>
|
||||
*/
|
||||
class WorkspaceFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$name = $this->faker->company();
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'slug' => Str::slug($name).'-'.$this->faker->unique()->randomNumber(5),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\WorkspaceMembership>
|
||||
*/
|
||||
class WorkspaceMembershipFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'workspace_id' => \App\Models\Workspace::factory(),
|
||||
'user_id' => \App\Models\User::factory(),
|
||||
'role' => 'operator',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('workspaces', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('slug')->nullable()->unique();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('name');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('workspaces');
|
||||
}
|
||||
};
|
||||
@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('workspace_memberships', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->enum('role', ['owner', 'manager', 'operator', 'readonly']);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['workspace_id', 'user_id']);
|
||||
$table->index(['workspace_id', 'role']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('workspace_memberships');
|
||||
}
|
||||
};
|
||||
@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->foreignId('last_workspace_id')
|
||||
->nullable()
|
||||
->after('remember_token')
|
||||
->constrained('workspaces')
|
||||
->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('last_workspace_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,47 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$driver = DB::getDriverName();
|
||||
|
||||
Schema::table('tenants', function (Blueprint $table) use ($driver): void {
|
||||
$column = $table->foreignId('workspace_id')->nullable();
|
||||
|
||||
if ($driver !== 'sqlite') {
|
||||
$column
|
||||
->after('id')
|
||||
->constrained('workspaces')
|
||||
->nullOnDelete();
|
||||
}
|
||||
|
||||
$table->index('workspace_id');
|
||||
});
|
||||
|
||||
if ($driver === 'sqlite') {
|
||||
// SQLite table rebuilds can drop/flatten the partial index defined in
|
||||
// 2025_12_11_192942_add_is_current_to_tenants.php. Recreate it here.
|
||||
DB::statement('DROP INDEX IF EXISTS tenants_current_unique');
|
||||
DB::statement('CREATE UNIQUE INDEX tenants_current_unique ON tenants (is_current) WHERE is_current = 1 AND deleted_at IS NULL');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('tenants', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('workspace_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,164 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$driver = Schema::getConnection()->getDriverName();
|
||||
|
||||
if ($driver === 'sqlite') {
|
||||
Schema::disableForeignKeyConstraints();
|
||||
|
||||
Schema::rename('audit_logs', 'audit_logs_old');
|
||||
|
||||
foreach ([
|
||||
'audit_logs_tenant_id_action_index',
|
||||
'audit_logs_tenant_id_resource_type_index',
|
||||
'audit_logs_recorded_at_index',
|
||||
] as $indexName) {
|
||||
DB::statement("DROP INDEX IF EXISTS {$indexName}");
|
||||
}
|
||||
|
||||
Schema::create('audit_logs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->nullable()->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->unsignedBigInteger('actor_id')->nullable();
|
||||
$table->string('actor_email')->nullable();
|
||||
$table->string('actor_name')->nullable();
|
||||
$table->string('action');
|
||||
$table->string('resource_type')->nullable();
|
||||
$table->string('resource_id')->nullable();
|
||||
$table->string('status')->default('success');
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamp('recorded_at')->useCurrent();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['tenant_id', 'action']);
|
||||
$table->index(['tenant_id', 'resource_type']);
|
||||
$table->index(['workspace_id', 'action']);
|
||||
$table->index(['workspace_id', 'resource_type']);
|
||||
$table->index('recorded_at');
|
||||
});
|
||||
|
||||
DB::table('audit_logs_old')->orderBy('id')->chunkById(500, function ($rows): void {
|
||||
foreach ($rows as $row) {
|
||||
DB::table('audit_logs')->insert([
|
||||
'id' => $row->id,
|
||||
'tenant_id' => $row->tenant_id,
|
||||
'workspace_id' => null,
|
||||
'actor_id' => $row->actor_id,
|
||||
'actor_email' => $row->actor_email,
|
||||
'actor_name' => $row->actor_name,
|
||||
'action' => $row->action,
|
||||
'resource_type' => $row->resource_type,
|
||||
'resource_id' => $row->resource_id,
|
||||
'status' => $row->status,
|
||||
'metadata' => $row->metadata,
|
||||
'recorded_at' => $row->recorded_at,
|
||||
'created_at' => $row->created_at,
|
||||
'updated_at' => $row->updated_at,
|
||||
]);
|
||||
}
|
||||
}, 'id');
|
||||
|
||||
Schema::drop('audit_logs_old');
|
||||
Schema::enableForeignKeyConstraints();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
DB::statement('ALTER TABLE audit_logs ALTER COLUMN tenant_id DROP NOT NULL');
|
||||
|
||||
Schema::table('audit_logs', function (Blueprint $table) {
|
||||
$table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete()->after('tenant_id');
|
||||
|
||||
$table->index(['workspace_id', 'action']);
|
||||
$table->index(['workspace_id', 'resource_type']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
$driver = Schema::getConnection()->getDriverName();
|
||||
|
||||
if ($driver === 'sqlite') {
|
||||
Schema::disableForeignKeyConstraints();
|
||||
|
||||
Schema::rename('audit_logs', 'audit_logs_new');
|
||||
|
||||
foreach ([
|
||||
'audit_logs_tenant_id_action_index',
|
||||
'audit_logs_tenant_id_resource_type_index',
|
||||
'audit_logs_recorded_at_index',
|
||||
'audit_logs_workspace_id_action_index',
|
||||
'audit_logs_workspace_id_resource_type_index',
|
||||
] as $indexName) {
|
||||
DB::statement("DROP INDEX IF EXISTS {$indexName}");
|
||||
}
|
||||
|
||||
Schema::create('audit_logs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
$table->unsignedBigInteger('actor_id')->nullable();
|
||||
$table->string('actor_email')->nullable();
|
||||
$table->string('actor_name')->nullable();
|
||||
$table->string('action');
|
||||
$table->string('resource_type')->nullable();
|
||||
$table->string('resource_id')->nullable();
|
||||
$table->string('status')->default('success');
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamp('recorded_at')->useCurrent();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['tenant_id', 'action']);
|
||||
$table->index(['tenant_id', 'resource_type']);
|
||||
$table->index('recorded_at');
|
||||
});
|
||||
|
||||
DB::table('audit_logs_new')->whereNotNull('tenant_id')->orderBy('id')->chunkById(500, function ($rows): void {
|
||||
foreach ($rows as $row) {
|
||||
DB::table('audit_logs')->insert([
|
||||
'id' => $row->id,
|
||||
'tenant_id' => $row->tenant_id,
|
||||
'actor_id' => $row->actor_id,
|
||||
'actor_email' => $row->actor_email,
|
||||
'actor_name' => $row->actor_name,
|
||||
'action' => $row->action,
|
||||
'resource_type' => $row->resource_type,
|
||||
'resource_id' => $row->resource_id,
|
||||
'status' => $row->status,
|
||||
'metadata' => $row->metadata,
|
||||
'recorded_at' => $row->recorded_at,
|
||||
'created_at' => $row->created_at,
|
||||
'updated_at' => $row->updated_at,
|
||||
]);
|
||||
}
|
||||
}, 'id');
|
||||
|
||||
Schema::drop('audit_logs_new');
|
||||
Schema::enableForeignKeyConstraints();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('audit_logs', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('workspace_id');
|
||||
$table->dropIndex(['workspace_id', 'action']);
|
||||
$table->dropIndex(['workspace_id', 'resource_type']);
|
||||
});
|
||||
|
||||
DB::statement('ALTER TABLE audit_logs ALTER COLUMN tenant_id SET NOT NULL');
|
||||
}
|
||||
};
|
||||
@ -1,142 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('workspaces')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = now();
|
||||
|
||||
$defaultWorkspaceId = DB::table('workspaces')
|
||||
->where('slug', 'default')
|
||||
->value('id');
|
||||
|
||||
if (! $defaultWorkspaceId) {
|
||||
$defaultWorkspaceId = DB::table('workspaces')->insertGetId([
|
||||
'name' => 'Default Workspace',
|
||||
'slug' => 'default',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
if (Schema::hasTable('tenants') && Schema::hasColumn('tenants', 'workspace_id')) {
|
||||
DB::table('tenants')
|
||||
->whereNull('workspace_id')
|
||||
->update([
|
||||
'workspace_id' => $defaultWorkspaceId,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('workspace_memberships')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$roleRankToRole = [
|
||||
4 => 'owner',
|
||||
3 => 'manager',
|
||||
2 => 'operator',
|
||||
1 => 'readonly',
|
||||
];
|
||||
|
||||
$userRoleRanks = collect();
|
||||
|
||||
if (Schema::hasTable('tenant_memberships')) {
|
||||
$userRoleRanks = DB::table('tenant_memberships')
|
||||
->select([
|
||||
'user_id',
|
||||
DB::raw("MAX(CASE role WHEN 'owner' THEN 4 WHEN 'manager' THEN 3 WHEN 'operator' THEN 2 WHEN 'readonly' THEN 1 ELSE 0 END) AS role_rank"),
|
||||
])
|
||||
->groupBy('user_id')
|
||||
->get();
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$userIds = [];
|
||||
|
||||
foreach ($userRoleRanks as $row) {
|
||||
$role = $roleRankToRole[(int) $row->role_rank] ?? null;
|
||||
|
||||
if (! $role) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'workspace_id' => $defaultWorkspaceId,
|
||||
'user_id' => $row->user_id,
|
||||
'role' => $role,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
$userIds[] = $row->user_id;
|
||||
}
|
||||
|
||||
if (empty($rows) && Schema::hasTable('users')) {
|
||||
$firstUserId = DB::table('users')->orderBy('id')->value('id');
|
||||
|
||||
if ($firstUserId) {
|
||||
$rows[] = [
|
||||
'workspace_id' => $defaultWorkspaceId,
|
||||
'user_id' => $firstUserId,
|
||||
'role' => 'owner',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
$userIds[] = $firstUserId;
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($rows)) {
|
||||
foreach (array_chunk($rows, 500) as $chunk) {
|
||||
DB::table('workspace_memberships')->insertOrIgnore($chunk);
|
||||
}
|
||||
}
|
||||
|
||||
$ownerCount = DB::table('workspace_memberships')
|
||||
->where('workspace_id', $defaultWorkspaceId)
|
||||
->where('role', 'owner')
|
||||
->count();
|
||||
|
||||
if ($ownerCount === 0) {
|
||||
$firstMembershipId = DB::table('workspace_memberships')
|
||||
->where('workspace_id', $defaultWorkspaceId)
|
||||
->orderBy('id')
|
||||
->value('id');
|
||||
|
||||
if ($firstMembershipId) {
|
||||
DB::table('workspace_memberships')
|
||||
->where('id', $firstMembershipId)
|
||||
->update([
|
||||
'role' => 'owner',
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (Schema::hasTable('users') && ! empty($userIds) && Schema::hasColumn('users', 'last_workspace_id')) {
|
||||
DB::table('users')
|
||||
->whereIn('id', array_unique($userIds))
|
||||
->whereNull('last_workspace_id')
|
||||
->update([
|
||||
'last_workspace_id' => $defaultWorkspaceId,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void {}
|
||||
};
|
||||
@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('workspaces', function (Blueprint $table) {
|
||||
$table->timestamp('archived_at')->nullable()->after('slug');
|
||||
$table->index('archived_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('workspaces', function (Blueprint $table) {
|
||||
$table->dropIndex(['archived_at']);
|
||||
$table->dropColumn('archived_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,43 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasTable('managed_tenant_onboarding_sessions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::create('managed_tenant_onboarding_sessions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
|
||||
$table->string('current_step')->nullable();
|
||||
$table->json('state')->nullable();
|
||||
|
||||
$table->foreignId('started_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->foreignId('updated_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamp('completed_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['workspace_id', 'tenant_id']);
|
||||
$table->index(['tenant_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('managed_tenant_onboarding_sessions');
|
||||
}
|
||||
};
|
||||
@ -1,128 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$driver = DB::getDriverName();
|
||||
|
||||
if ($driver === 'sqlite') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('tenants', 'workspace_id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function (): void {
|
||||
$tenantIds = DB::table('tenants')->whereNull('workspace_id')->pluck('id');
|
||||
|
||||
foreach ($tenantIds as $tenantId) {
|
||||
$workspaceId = DB::table('tenant_memberships')
|
||||
->join('workspace_memberships', 'workspace_memberships.user_id', '=', 'tenant_memberships.user_id')
|
||||
->where('tenant_memberships.tenant_id', $tenantId)
|
||||
->orderByRaw("CASE tenant_memberships.role WHEN 'owner' THEN 0 WHEN 'manager' THEN 1 WHEN 'operator' THEN 2 ELSE 3 END")
|
||||
->value('workspace_memberships.workspace_id');
|
||||
|
||||
if ($workspaceId !== null) {
|
||||
DB::table('tenants')
|
||||
->where('id', $tenantId)
|
||||
->update(['workspace_id' => (int) $workspaceId]);
|
||||
}
|
||||
}
|
||||
|
||||
$remaining = (int) DB::table('tenants')->whereNull('workspace_id')->count();
|
||||
|
||||
if ($remaining === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$legacyWorkspaceId = DB::table('workspaces')->insertGetId([
|
||||
'name' => 'Legacy Workspace',
|
||||
'slug' => 'legacy',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$users = DB::table('tenant_memberships')
|
||||
->join('tenants', 'tenants.id', '=', 'tenant_memberships.tenant_id')
|
||||
->whereNull('tenants.workspace_id')
|
||||
->select([
|
||||
'tenant_memberships.user_id',
|
||||
DB::raw("MIN(CASE tenant_memberships.role WHEN 'owner' THEN 0 WHEN 'manager' THEN 1 WHEN 'operator' THEN 2 ELSE 3 END) AS role_rank"),
|
||||
])
|
||||
->groupBy('tenant_memberships.user_id')
|
||||
->get();
|
||||
|
||||
$roleFromRank = static fn (int $rank): string => match ($rank) {
|
||||
0 => 'owner',
|
||||
1 => 'manager',
|
||||
2 => 'operator',
|
||||
default => 'readonly',
|
||||
};
|
||||
|
||||
$membershipRows = [];
|
||||
|
||||
foreach ($users as $user) {
|
||||
$membershipRows[] = [
|
||||
'workspace_id' => (int) $legacyWorkspaceId,
|
||||
'user_id' => (int) $user->user_id,
|
||||
'role' => $roleFromRank((int) $user->role_rank),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
if ($membershipRows !== []) {
|
||||
DB::table('workspace_memberships')->insertOrIgnore($membershipRows);
|
||||
}
|
||||
|
||||
DB::table('tenants')
|
||||
->whereNull('workspace_id')
|
||||
->update(['workspace_id' => (int) $legacyWorkspaceId]);
|
||||
});
|
||||
|
||||
if ($driver === 'pgsql') {
|
||||
DB::statement('ALTER TABLE tenants ALTER COLUMN workspace_id SET NOT NULL');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($driver === 'mysql') {
|
||||
DB::statement('ALTER TABLE tenants MODIFY workspace_id BIGINT UNSIGNED NOT NULL');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
$driver = DB::getDriverName();
|
||||
|
||||
if ($driver === 'sqlite') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('tenants', 'workspace_id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($driver === 'pgsql') {
|
||||
DB::statement('ALTER TABLE tenants ALTER COLUMN workspace_id DROP NOT NULL');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($driver === 'mysql') {
|
||||
DB::statement('ALTER TABLE tenants MODIFY workspace_id BIGINT UNSIGNED NULL');
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -1,40 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasTable('managed_tenant_onboarding_sessions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::create('managed_tenant_onboarding_sessions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
|
||||
$table->string('current_step')->nullable();
|
||||
$table->json('state')->nullable();
|
||||
|
||||
$table->foreignId('started_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->foreignId('updated_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
|
||||
$table->timestamp('completed_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['workspace_id', 'tenant_id']);
|
||||
$table->index(['tenant_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('managed_tenant_onboarding_sessions');
|
||||
}
|
||||
};
|
||||
@ -1,178 +0,0 @@
|
||||
@php
|
||||
$report = isset($getState) ? $getState() : ($report ?? null);
|
||||
$report = is_array($report) ? $report : null;
|
||||
|
||||
$summary = $report['summary'] ?? null;
|
||||
$summary = is_array($summary) ? $summary : null;
|
||||
|
||||
$counts = $summary['counts'] ?? null;
|
||||
$counts = is_array($counts) ? $counts : [];
|
||||
|
||||
$checks = $report['checks'] ?? null;
|
||||
$checks = is_array($checks) ? $checks : [];
|
||||
@endphp
|
||||
|
||||
<div class="space-y-4">
|
||||
@if ($report === null || $summary === null)
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
|
||||
<div class="font-medium text-gray-900 dark:text-white">
|
||||
Verification report unavailable
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
This run doesn’t have a report yet. If it’s still running, refresh in a moment. If it already completed, start verification again.
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
@php
|
||||
$overallSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationReportOverall,
|
||||
$summary['overall'] ?? null,
|
||||
);
|
||||
@endphp
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon">
|
||||
{{ $overallSpec->label }}
|
||||
</x-filament::badge>
|
||||
|
||||
<x-filament::badge color="gray">
|
||||
{{ (int) ($counts['total'] ?? 0) }} total
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="success">
|
||||
{{ (int) ($counts['pass'] ?? 0) }} pass
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="danger">
|
||||
{{ (int) ($counts['fail'] ?? 0) }} fail
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="warning">
|
||||
{{ (int) ($counts['warn'] ?? 0) }} warn
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="gray">
|
||||
{{ (int) ($counts['skip'] ?? 0) }} skip
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="info">
|
||||
{{ (int) ($counts['running'] ?? 0) }} running
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
@if ($checks === [])
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
|
||||
No checks found in this report. Start verification again to generate a fresh report.
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@foreach ($checks as $check)
|
||||
@php
|
||||
$check = is_array($check) ? $check : [];
|
||||
|
||||
$title = $check['title'] ?? 'Check';
|
||||
$title = is_string($title) && trim($title) !== '' ? $title : 'Check';
|
||||
|
||||
$message = $check['message'] ?? null;
|
||||
$message = is_string($message) && trim($message) !== '' ? $message : null;
|
||||
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
|
||||
$check['status'] ?? null,
|
||||
);
|
||||
|
||||
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
|
||||
$check['severity'] ?? null,
|
||||
);
|
||||
|
||||
$evidence = $check['evidence'] ?? [];
|
||||
$evidence = is_array($evidence) ? $evidence : [];
|
||||
|
||||
$nextSteps = $check['next_steps'] ?? [];
|
||||
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
|
||||
@endphp
|
||||
|
||||
<details class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<summary class="flex cursor-pointer items-start justify-between gap-4">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
</div>
|
||||
@if ($message)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
|
||||
{{ $severitySpec->label }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
@if ($evidence !== [] || $nextSteps !== [])
|
||||
<div class="mt-4 space-y-4">
|
||||
@if ($evidence !== [])
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Evidence
|
||||
</div>
|
||||
<ul class="mt-2 space-y-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
@foreach ($evidence as $pointer)
|
||||
@php
|
||||
$pointer = is_array($pointer) ? $pointer : [];
|
||||
$kind = $pointer['kind'] ?? null;
|
||||
$value = $pointer['value'] ?? null;
|
||||
@endphp
|
||||
|
||||
@if (is_string($kind) && $kind !== '' && (is_string($value) || is_int($value)))
|
||||
<li>
|
||||
<span class="font-medium">{{ $kind }}:</span>
|
||||
<span>{{ is_int($value) ? $value : $value }}</span>
|
||||
</li>
|
||||
@endif
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($nextSteps !== [])
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Next steps
|
||||
</div>
|
||||
<ul class="mt-2 space-y-1 text-sm">
|
||||
@foreach ($nextSteps as $step)
|
||||
@php
|
||||
$step = is_array($step) ? $step : [];
|
||||
$label = $step['label'] ?? null;
|
||||
$url = $step['url'] ?? null;
|
||||
$isExternal = is_string($url) && (str_starts_with($url, 'http://') || str_starts_with($url, 'https://'));
|
||||
@endphp
|
||||
|
||||
@if (is_string($label) && $label !== '' && is_string($url) && $url !== '')
|
||||
<li>
|
||||
<a
|
||||
href="{{ $url }}"
|
||||
class="text-primary-600 hover:underline dark:text-primary-400"
|
||||
@if ($isExternal)
|
||||
target="_blank" rel="noreferrer"
|
||||
@endif
|
||||
>
|
||||
{{ $label }}
|
||||
</a>
|
||||
</li>
|
||||
@endif
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</details>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
@ -1,60 +1,37 @@
|
||||
<x-filament-panels::page>
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Select a tenant to continue.
|
||||
</div>
|
||||
|
||||
@php
|
||||
$tenants = $this->getTenants();
|
||||
@endphp
|
||||
|
||||
@if ($tenants->isEmpty())
|
||||
<div class="rounded-md border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">No tenants are available for your account.</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
Switch workspaces, or contact an administrator.
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
|
||||
<x-filament::button
|
||||
type="button"
|
||||
color="gray"
|
||||
tag="a"
|
||||
href="{{ route('filament.admin.pages.choose-workspace') }}"
|
||||
>
|
||||
Change workspace
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@foreach ($tenants as $tenant)
|
||||
<div
|
||||
wire:key="tenant-{{ $tenant->id }}"
|
||||
x-data
|
||||
@click="if ($event.target.closest('button,a,input,select,textarea')) return; $refs.form.submit();"
|
||||
class="cursor-pointer rounded-lg border border-gray-200 p-4 dark:border-gray-800"
|
||||
>
|
||||
<form x-ref="form" method="POST" action="{{ route('admin.select-tenant') }}" class="flex flex-col gap-3">
|
||||
@csrf
|
||||
<input type="hidden" name="tenant_id" value="{{ (int) $tenant->id }}" />
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $tenant->name }}
|
||||
</div>
|
||||
|
||||
<x-filament::button
|
||||
type="submit"
|
||||
color="primary"
|
||||
class="w-full"
|
||||
>
|
||||
Continue
|
||||
</x-filament::button>
|
||||
</form>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Select a tenant to continue.
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</x-filament-panels::page>
|
||||
|
||||
@php
|
||||
$tenants = $this->getTenants();
|
||||
@endphp
|
||||
|
||||
@if ($tenants->isEmpty())
|
||||
<div class="rounded-md border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
|
||||
No tenants are available for your account.
|
||||
</div>
|
||||
@else
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@foreach ($tenants as $tenant)
|
||||
<div wire:key="tenant-{{ $tenant->id }}" class="rounded-lg border border-gray-200 p-4 dark:border-gray-800">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $tenant->name }}
|
||||
</div>
|
||||
|
||||
<x-filament::button
|
||||
type="button"
|
||||
color="primary"
|
||||
wire:click="selectTenant({{ (int) $tenant->id }})"
|
||||
>
|
||||
Continue
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
@ -1,70 +0,0 @@
|
||||
<x-filament-panels::page>
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Select a workspace to continue.
|
||||
</div>
|
||||
|
||||
@php
|
||||
$workspaces = $this->getWorkspaces();
|
||||
|
||||
$user = auth()->user();
|
||||
$recommendedWorkspaceId = $user instanceof \App\Models\User ? (int) ($user->last_workspace_id ?? 0) : 0;
|
||||
|
||||
if ($recommendedWorkspaceId > 0) {
|
||||
[$recommended, $other] = $workspaces->partition(fn ($workspace) => (int) $workspace->id === $recommendedWorkspaceId);
|
||||
$workspaces = $recommended->concat($other)->values();
|
||||
}
|
||||
@endphp
|
||||
|
||||
@if ($workspaces->isEmpty())
|
||||
<div class="rounded-md border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
|
||||
No active workspaces are available for your account.
|
||||
You can create one using the button above.
|
||||
</div>
|
||||
@else
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@foreach ($workspaces as $workspace)
|
||||
@php
|
||||
$isRecommended = $recommendedWorkspaceId > 0 && (int) $workspace->id === $recommendedWorkspaceId;
|
||||
@endphp
|
||||
|
||||
<div
|
||||
wire:key="workspace-{{ $workspace->id }}"
|
||||
x-data
|
||||
@click="if ($event.target.closest('button,a,input,select,textarea')) return; $refs.form.submit();"
|
||||
class="cursor-pointer rounded-lg border p-4 dark:border-gray-800 {{ $isRecommended ? 'border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/30' : 'border-gray-200' }}"
|
||||
>
|
||||
<form x-ref="form" method="POST" action="{{ route('admin.switch-workspace') }}" class="flex flex-col gap-3">
|
||||
@csrf
|
||||
<input type="hidden" name="workspace_id" value="{{ (int) $workspace->id }}" />
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $workspace->name }}
|
||||
</div>
|
||||
|
||||
@if ($isRecommended)
|
||||
<div>
|
||||
<x-filament::badge color="warning" size="sm">
|
||||
Last used
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<x-filament::button
|
||||
type="submit"
|
||||
color="primary"
|
||||
class="w-full"
|
||||
>
|
||||
Continue
|
||||
</x-filament::button>
|
||||
</form>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</x-filament-panels::page>
|
||||
@ -1,13 +1,11 @@
|
||||
<x-filament-panels::page>
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
You don’t have access to any tenants yet.
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Ask an administrator to add you to a tenant, then sign in again.
|
||||
</div>
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
You don’t have access to any tenants yet.
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</x-filament-panels::page>
|
||||
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Ask an administrator to add you to a tenant, then sign in again.
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
@ -1,31 +0,0 @@
|
||||
<x-filament-panels::page>
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
|
||||
<h2 class="text-base font-semibold text-gray-950 dark:text-white">Tenant diagnostics</h2>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Identify common tenant configuration issues and apply safe repairs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if ($missingOwner)
|
||||
<div class="rounded-xl border border-amber-200 bg-amber-50 p-4 text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/40 dark:text-amber-100">
|
||||
<div class="font-semibold">Missing owner</div>
|
||||
<div class="mt-1 text-sm">This tenant currently has no Owner members.</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($hasDuplicateMembershipsForCurrentUser)
|
||||
<div class="rounded-xl border border-amber-200 bg-amber-50 p-4 text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/40 dark:text-amber-100">
|
||||
<div class="font-semibold">Duplicate memberships</div>
|
||||
<div class="mt-1 text-sm">This tenant has duplicate membership rows for your user.</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (! $missingOwner && ! $hasDuplicateMembershipsForCurrentUser)
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
|
||||
<div class="font-semibold text-gray-950 dark:text-white">All good</div>
|
||||
<div class="mt-1 text-sm">No known issues detected.</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
@ -1,170 +0,0 @@
|
||||
<x-filament-panels::page>
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Workspace: <span class="font-medium text-gray-900 dark:text-gray-100">{{ $this->workspace->name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
||||
Managed tenant onboarding
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
This wizard will guide you through identifying a managed tenant and verifying access.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($this->managedTenant)
|
||||
<div class="rounded-md border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-200">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">Identified tenant</div>
|
||||
<dl class="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Name</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $this->managedTenant->name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Tenant ID</dt>
|
||||
<dd class="mt-1 font-mono text-sm text-gray-900 dark:text-gray-100">{{ $this->managedTenant->tenant_id }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@php
|
||||
$verificationSucceeded = $this->verificationSucceeded();
|
||||
$hasTenant = (bool) $this->managedTenant;
|
||||
$hasConnection = $hasTenant && is_int($this->selectedProviderConnectionId) && $this->selectedProviderConnectionId > 0;
|
||||
@endphp
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 1 — Identify managed tenant</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Provide tenant ID + display name to start or resume the flow.</div>
|
||||
</div>
|
||||
<div class="text-xs font-medium {{ $hasTenant ? 'text-emerald-700 dark:text-emerald-400' : 'text-gray-500 dark:text-gray-400' }}">
|
||||
{{ $hasTenant ? 'Done' : 'Pending' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
|
||||
<x-filament::button
|
||||
type="button"
|
||||
color="primary"
|
||||
wire:click="mountAction('identifyManagedTenant')"
|
||||
>
|
||||
{{ $hasTenant ? 'Change tenant' : 'Identify tenant' }}
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 2 — Provider connection</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Create or pick the connection used to verify access.</div>
|
||||
</div>
|
||||
<div class="text-xs font-medium {{ $hasConnection ? 'text-emerald-700 dark:text-emerald-400' : 'text-gray-500 dark:text-gray-400' }}">
|
||||
{{ $hasConnection ? 'Selected' : ($hasTenant ? 'Pending' : 'Locked') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($hasTenant)
|
||||
<div class="mt-3 text-sm text-gray-700 dark:text-gray-200">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Selected connection ID</span>
|
||||
<div class="mt-1 font-mono">{{ $this->selectedProviderConnectionId ?? '—' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
|
||||
<x-filament::button
|
||||
type="button"
|
||||
color="gray"
|
||||
wire:click="mountAction('createProviderConnection')"
|
||||
>
|
||||
Create connection
|
||||
</x-filament::button>
|
||||
|
||||
<x-filament::button
|
||||
type="button"
|
||||
color="gray"
|
||||
wire:click="mountAction('selectProviderConnection')"
|
||||
>
|
||||
Select connection
|
||||
</x-filament::button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 3 — Verify access</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Runs a verification operation and records the result.</div>
|
||||
</div>
|
||||
<div class="text-xs font-medium {{ $verificationSucceeded ? 'text-emerald-700 dark:text-emerald-400' : 'text-gray-500 dark:text-gray-400' }}">
|
||||
{{ $verificationSucceeded ? 'Succeeded' : ($hasConnection ? 'Pending' : 'Locked') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
|
||||
<x-filament::button
|
||||
type="button"
|
||||
color="primary"
|
||||
:disabled="! $hasConnection"
|
||||
wire:click="mountAction('startVerification')"
|
||||
>
|
||||
Run verification
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 4 — Bootstrap (optional)</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Start inventory/compliance sync after verification.</div>
|
||||
</div>
|
||||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ $verificationSucceeded ? 'Available' : 'Locked' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
|
||||
<x-filament::button
|
||||
type="button"
|
||||
color="gray"
|
||||
:disabled="! $verificationSucceeded"
|
||||
wire:click="mountAction('startBootstrap')"
|
||||
>
|
||||
Start bootstrap
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 5 — Complete onboarding</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Marks the tenant as active after successful verification.</div>
|
||||
</div>
|
||||
<div class="text-xs font-medium {{ $verificationSucceeded ? 'text-emerald-700 dark:text-emerald-400' : 'text-gray-500 dark:text-gray-400' }}">
|
||||
{{ $verificationSucceeded ? 'Ready' : 'Locked' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
|
||||
<x-filament::button
|
||||
type="button"
|
||||
color="success"
|
||||
:disabled="! $verificationSucceeded"
|
||||
wire:click="mountAction('completeOnboarding')"
|
||||
>
|
||||
Complete onboarding
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</x-filament-panels::page>
|
||||
@ -1,76 +0,0 @@
|
||||
<x-filament-panels::page>
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Workspace: <span class="font-medium text-gray-900 dark:text-gray-100">{{ $this->workspace->name }}</span>
|
||||
</div>
|
||||
|
||||
@php
|
||||
$tenants = $this->getTenants();
|
||||
@endphp
|
||||
|
||||
@if ($tenants->isEmpty())
|
||||
<div class="rounded-md border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">No managed tenants yet.</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
Add a managed tenant to start inventory, drift, backups, and policy management.
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
|
||||
<x-filament::button
|
||||
type="button"
|
||||
color="primary"
|
||||
tag="a"
|
||||
href="{{ route('admin.workspace.managed-tenants.onboarding', ['workspace' => $this->workspace->slug ?? $this->workspace->getKey()]) }}"
|
||||
>
|
||||
Start onboarding
|
||||
</x-filament::button>
|
||||
|
||||
<x-filament::button
|
||||
type="button"
|
||||
color="gray"
|
||||
tag="a"
|
||||
href="{{ route('filament.admin.pages.choose-workspace') }}"
|
||||
>
|
||||
Change workspace
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $tenants->count() }} managed tenant{{ $tenants->count() === 1 ? '' : 's' }}
|
||||
</div>
|
||||
|
||||
<x-filament::button
|
||||
type="button"
|
||||
color="gray"
|
||||
wire:click="goToChooseTenant"
|
||||
>
|
||||
Choose tenant
|
||||
</x-filament::button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@foreach ($tenants as $tenant)
|
||||
<div wire:key="tenant-{{ $tenant->id }}" class="rounded-lg border border-gray-200 p-4 dark:border-gray-800">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $tenant->name }}
|
||||
</div>
|
||||
|
||||
<x-filament::button
|
||||
type="button"
|
||||
color="primary"
|
||||
wire:click="openTenant({{ (int) $tenant->id }})"
|
||||
>
|
||||
Open
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</x-filament-panels::page>
|
||||
@ -1,47 +0,0 @@
|
||||
@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
|
||||
@ -1,13 +0,0 @@
|
||||
<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>
|
||||
@ -1,14 +0,0 @@
|
||||
@php
|
||||
/** @var ?\App\Models\Tenant $tenant */
|
||||
@endphp
|
||||
|
||||
<div>
|
||||
@if ($tenant?->trashed())
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-amber-900 dark:border-amber-800/50 dark:bg-amber-950/30 dark:text-amber-100">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-sm font-semibold">Archived</div>
|
||||
<div class="text-sm">{{ \App\Support\Rbac\UiTooltips::TENANT_ARCHIVED }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
129
routes/web.php
129
routes/web.php
@ -1,21 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Http\Controllers\AdminConsentCallbackController;
|
||||
use App\Http\Controllers\Auth\EntraController;
|
||||
use App\Http\Controllers\RbacDelegatedAuthController;
|
||||
use App\Http\Controllers\SelectTenantController;
|
||||
use App\Http\Controllers\SwitchWorkspaceController;
|
||||
use App\Http\Controllers\TenantOnboardingController;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use App\Support\Workspaces\WorkspaceResolver;
|
||||
use Filament\Http\Middleware\Authenticate as FilamentAuthenticate;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', function () {
|
||||
@ -27,59 +15,6 @@
|
||||
|
||||
Route::get('/admin/consent/start', TenantOnboardingController::class)
|
||||
->name('admin.consent.start');
|
||||
// Panel root override: keep the app's workspace-first flow.
|
||||
// Avoid Filament's tenancy root redirect which otherwise sends users into legacy flows.
|
||||
// when no default tenant can be resolved.
|
||||
Route::middleware([
|
||||
'web',
|
||||
'panel:admin',
|
||||
'ensure-correct-guard:web',
|
||||
DenyNonMemberTenantAccess::class,
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
FilamentAuthenticate::class,
|
||||
'ensure-workspace-selected',
|
||||
])
|
||||
->get('/admin', function (Request $request) {
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId($request);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return redirect()->to('/admin/choose-workspace');
|
||||
}
|
||||
|
||||
if ($workspaceId === null) {
|
||||
return redirect()->to('/admin/choose-workspace');
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return redirect()->to('/admin/choose-workspace');
|
||||
}
|
||||
|
||||
$tenantsQuery = $user->tenants()
|
||||
->where('workspace_id', $workspace->getKey())
|
||||
->where('status', 'active');
|
||||
|
||||
$tenantCount = (int) $tenantsQuery->count();
|
||||
|
||||
if ($tenantCount === 0) {
|
||||
return redirect()->route('admin.workspace.managed-tenants.onboarding', ['workspace' => $workspace->slug ?? $workspace->getKey()]);
|
||||
}
|
||||
|
||||
if ($tenantCount === 1) {
|
||||
$tenant = $tenantsQuery->first();
|
||||
|
||||
if ($tenant !== null) {
|
||||
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->to('/admin/choose-tenant');
|
||||
})
|
||||
->name('admin.home');
|
||||
|
||||
Route::get('/admin/rbac/start', [RbacDelegatedAuthController::class, 'start'])
|
||||
->name('admin.rbac.start');
|
||||
@ -93,67 +28,3 @@
|
||||
Route::get('/auth/entra/callback', [EntraController::class, 'callback'])
|
||||
->middleware('throttle:entra-callback')
|
||||
->name('auth.entra.callback');
|
||||
|
||||
Route::middleware(['web', 'auth', 'ensure-correct-guard:web'])
|
||||
->post('/admin/switch-workspace', SwitchWorkspaceController::class)
|
||||
->name('admin.switch-workspace');
|
||||
|
||||
Route::middleware(['web', 'auth', 'ensure-correct-guard:web', 'ensure-workspace-selected'])
|
||||
->post('/admin/select-tenant', SelectTenantController::class)
|
||||
->name('admin.select-tenant');
|
||||
Route::bind('workspace', function (string $value): Workspace {
|
||||
/** @var WorkspaceResolver $resolver */
|
||||
$resolver = app(WorkspaceResolver::class);
|
||||
|
||||
$workspace = $resolver->resolve($value);
|
||||
|
||||
abort_unless($workspace instanceof Workspace, 404);
|
||||
|
||||
return $workspace;
|
||||
});
|
||||
|
||||
Route::middleware(['web', 'auth', 'ensure-workspace-member'])
|
||||
->prefix('/admin/w/{workspace}')
|
||||
->group(function (): void {
|
||||
Route::get('/', fn () => redirect()->route('admin.workspace.managed-tenants.index', ['workspace' => request()->route('workspace')]))
|
||||
->name('admin.workspace.home');
|
||||
|
||||
Route::get('/ping', fn () => response()->noContent())->name('admin.workspace.ping');
|
||||
});
|
||||
|
||||
Route::middleware([
|
||||
'web',
|
||||
'panel:admin',
|
||||
'ensure-correct-guard:web',
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
FilamentAuthenticate::class,
|
||||
'ensure-workspace-member',
|
||||
])
|
||||
->get('/admin/w/{workspace}/managed-tenants/onboarding', \App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class)
|
||||
->name('admin.workspace.managed-tenants.onboarding');
|
||||
|
||||
Route::middleware([
|
||||
'web',
|
||||
'panel:admin',
|
||||
'ensure-correct-guard:web',
|
||||
DenyNonMemberTenantAccess::class,
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
FilamentAuthenticate::class,
|
||||
'ensure-workspace-member',
|
||||
'ensure-filament-tenant-selected',
|
||||
])
|
||||
->get('/admin/w/{workspace}/managed-tenants', \App\Filament\Pages\Workspaces\ManagedTenantsLanding::class)
|
||||
->name('admin.workspace.managed-tenants.index');
|
||||
|
||||
if (app()->runningUnitTests()) {
|
||||
Route::middleware(['web', 'auth', 'ensure-workspace-selected'])
|
||||
->get('/admin/_test/workspace-context', function (Request $request) {
|
||||
$workspaceId = app(\App\Support\Workspaces\WorkspaceContext::class)->currentWorkspaceId($request);
|
||||
|
||||
return response()->json([
|
||||
'workspace_id' => $workspaceId,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user