Compare commits
8 Commits
spec/066-r
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 439248ba15 | |||
| b6343d5c3a | |||
| 5f9e6fb04a | |||
| 38d9826f5e | |||
| a989ef1a23 | |||
| 3490fb9e2c | |||
| d1a9989037 | |||
| 7217559e5a |
8
.github/agents/copilot-instructions.md
vendored
8
.github/agents/copilot-instructions.md
vendored
@ -12,6 +12,10 @@ ## Active Technologies
|
|||||||
- PostgreSQL (JSONB for `InventoryItem.meta_jsonb`) (feat/047-inventory-foundations-nodes)
|
- 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)
|
- 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.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)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -31,9 +35,9 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## 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 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 -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
|
|||||||
@ -1056,9 +1056,9 @@ ### Replaced Utilities
|
|||||||
</laravel-boost-guidelines>
|
</laravel-boost-guidelines>
|
||||||
|
|
||||||
## Active Technologies
|
## Active Technologies
|
||||||
- PHP 8.4.15 (Laravel 12) + Filament v4, Livewire v3 (054-unify-runs-suitewide-session-1768601416)
|
- PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4
|
||||||
- PostgreSQL (`operation_runs` + JSONB for summary/failures/context; partial unique index for active-run dedupe) (054-unify-runs-suitewide-session-1768601416)
|
- PostgreSQL (Sail)
|
||||||
- PHP 8.4.15 (Laravel 12) + Filament v5 + Livewire v4 (059-unified-badges)
|
- Tailwind CSS v4
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 054-unify-runs-suitewide-session-1768601416: Added PHP 8.4.15 (Laravel 12) + Filament v4, Livewire v3
|
- 066-rbac-ui-enforcement-helper-v2-session-1769732329: Planned UiEnforcement v2 (spec + plan + design artifacts)
|
||||||
|
|||||||
@ -34,6 +34,6 @@ private function resolveTenant(): Tenant
|
|||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Tenant::current();
|
return Tenant::currentOrFail();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -138,7 +138,7 @@ private function resolveTenants()
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return collect([Tenant::current()]);
|
return collect([Tenant::currentOrFail()]);
|
||||||
} catch (RuntimeException) {
|
} catch (RuntimeException) {
|
||||||
return collect();
|
return collect();
|
||||||
}
|
}
|
||||||
|
|||||||
172
app/Filament/Pages/ChooseWorkspace.php
Normal file
172
app/Filament/Pages/ChooseWorkspace.php
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
<?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,6 +4,13 @@
|
|||||||
|
|
||||||
namespace App\Filament\Pages;
|
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 Filament\Pages\Page;
|
||||||
|
|
||||||
class NoAccess extends Page
|
class NoAccess extends Page
|
||||||
@ -19,4 +26,60 @@ class NoAccess extends Page
|
|||||||
protected static ?string $title = 'No access';
|
protected static ?string $title = 'No access';
|
||||||
|
|
||||||
protected string $view = 'filament.pages.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,9 +4,11 @@
|
|||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant;
|
use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
@ -27,6 +29,20 @@ public static function canView(): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||||
|
|
||||||
|
if ($workspaceId !== null) {
|
||||||
|
$canRegisterInWorkspace = WorkspaceMembership::query()
|
||||||
|
->where('workspace_id', $workspaceId)
|
||||||
|
->where('user_id', $user->getKey())
|
||||||
|
->whereIn('role', ['owner', 'manager'])
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($canRegisterInWorkspace) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$tenantIds = $user->tenants()->withTrashed()->pluck('tenants.id');
|
$tenantIds = $user->tenants()->withTrashed()->pluck('tenants.id');
|
||||||
|
|
||||||
if ($tenantIds->isEmpty()) {
|
if ($tenantIds->isEmpty()) {
|
||||||
@ -95,6 +111,12 @@ protected function handleRegistration(array $data): Model
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||||
|
|
||||||
|
if ($workspaceId !== null) {
|
||||||
|
$data['workspace_id'] = $workspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
$tenant = Tenant::create($data);
|
$tenant = Tenant::create($data);
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|||||||
108
app/Filament/Pages/TenantDiagnostics.php
Normal file
108
app/Filament/Pages/TenantDiagnostics.php
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
<?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();
|
||||||
|
}
|
||||||
|
}
|
||||||
1294
app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
Normal file
1294
app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
Normal file
File diff suppressed because it is too large
Load Diff
79
app/Filament/Pages/Workspaces/ManagedTenantsLanding.php
Normal file
79
app/Filament/Pages/Workspaces/ManagedTenantsLanding.php
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
<?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
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
$tenantId = Tenant::current()->getKey();
|
$tenantId = Tenant::currentOrFail()->getKey();
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
return parent::getEloquentQuery()
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
@ -1054,7 +1054,7 @@ public static function ensurePolicyTypes(array $data): array
|
|||||||
|
|
||||||
public static function assignTenant(array $data): array
|
public static function assignTenant(array $data): array
|
||||||
{
|
{
|
||||||
$data['tenant_id'] = Tenant::current()->getKey();
|
$data['tenant_id'] = Tenant::currentOrFail()->getKey();
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,7 +21,7 @@ class BackupScheduleRunsRelationManager extends RelationManager
|
|||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::current()->getKey())->with('backupSet'))
|
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey())->with('backupSet'))
|
||||||
->defaultSort('scheduled_for', 'desc')
|
->defaultSort('scheduled_for', 'desc')
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('scheduled_for')
|
Tables\Columns\TextColumn::make('scheduled_for')
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
namespace App\Filament\Resources\BackupSetResource\Pages;
|
namespace App\Filament\Resources\BackupSetResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\BackupSetResource;
|
use App\Filament\Resources\BackupSetResource;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\UiEnforcement;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
@ -13,9 +15,7 @@ class ListBackupSets extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\CreateAction::make()
|
UiEnforcement::for(Capabilities::TENANT_SYNC)->apply(Actions\CreateAction::make()),
|
||||||
->disabled(fn (): bool => ! BackupSetResource::canCreate())
|
|
||||||
->tooltip(fn (): ?string => BackupSetResource::canCreate() ? null : 'You do not have permission to create backup sets.'),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource\Pages;
|
use App\Filament\Resources\OperationRunResource\Pages;
|
||||||
|
use App\Filament\Support\VerificationReportViewer;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
@ -136,12 +137,35 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->visible(fn (OperationRun $record): bool => ! empty($record->failure_summary))
|
->visible(fn (OperationRun $record): bool => ! empty($record->failure_summary))
|
||||||
->columnSpanFull(),
|
->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')
|
Section::make('Context')
|
||||||
->schema([
|
->schema([
|
||||||
ViewEntry::make('context')
|
ViewEntry::make('context')
|
||||||
->label('')
|
->label('')
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
->view('filament.infolists.entries.snapshot-json')
|
||||||
->state(fn (OperationRun $record): array => $record->context ?? [])
|
->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;
|
||||||
|
})
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
])
|
])
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
|||||||
@ -894,7 +894,7 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
$tenantId = Tenant::current()->getKey();
|
$tenantId = Tenant::currentOrFail()->getKey();
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
return parent::getEloquentQuery()
|
||||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||||
|
|||||||
@ -815,7 +815,7 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
$tenantId = Tenant::current()->getKey();
|
$tenantId = Tenant::currentOrFail()->getKey();
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
return parent::getEloquentQuery()
|
||||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
||||||
use App\Filament\Resources\ProviderConnectionResource\Pages;
|
use App\Filament\Resources\ProviderConnectionResource\Pages;
|
||||||
use App\Jobs\ProviderComplianceSnapshotJob;
|
use App\Jobs\ProviderComplianceSnapshotJob;
|
||||||
use App\Jobs\ProviderConnectionHealthCheckJob;
|
|
||||||
use App\Jobs\ProviderInventorySyncJob;
|
use App\Jobs\ProviderInventorySyncJob;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
@ -15,6 +14,7 @@
|
|||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Providers\CredentialManager;
|
use App\Services\Providers\CredentialManager;
|
||||||
use App\Services\Providers\ProviderOperationStartGate;
|
use App\Services\Providers\ProviderOperationStartGate;
|
||||||
|
use App\Services\Verification\StartVerification;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
@ -175,29 +175,22 @@ public static function table(Table $table): Table
|
|||||||
->icon('heroicon-o-check-badge')
|
->icon('heroicon-o-check-badge')
|
||||||
->color('success')
|
->color('success')
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
->action(function (ProviderConnection $record, StartVerification $verification): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return;
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$initiator = $user;
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
$result = $gate->start(
|
$result = $verification->providerConnectionCheck(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
connection: $record,
|
connection: $record,
|
||||||
operationType: 'provider.connection.check',
|
initiator: $user,
|
||||||
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') {
|
if ($result->status === 'scope_busy') {
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\ProviderConnectionResource;
|
use App\Filament\Resources\ProviderConnectionResource;
|
||||||
use App\Jobs\ProviderComplianceSnapshotJob;
|
use App\Jobs\ProviderComplianceSnapshotJob;
|
||||||
use App\Jobs\ProviderConnectionHealthCheckJob;
|
|
||||||
use App\Jobs\ProviderInventorySyncJob;
|
use App\Jobs\ProviderInventorySyncJob;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
@ -14,6 +13,7 @@
|
|||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Providers\CredentialManager;
|
use App\Services\Providers\CredentialManager;
|
||||||
use App\Services\Providers\ProviderOperationStartGate;
|
use App\Services\Providers\ProviderOperationStartGate;
|
||||||
|
use App\Services\Verification\StartVerification;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
@ -163,11 +163,11 @@ protected function getHeaderActions(): array
|
|||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
return $tenant instanceof Tenant
|
||||||
&& $user instanceof User
|
&& $user instanceof User
|
||||||
&& $user->canAccessTenant($tenant)
|
&& $user->canAccessTenant($tenant)
|
||||||
&& $record->status !== 'disabled';
|
&& $record->status !== 'disabled';
|
||||||
})
|
})
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
->action(function (ProviderConnection $record, StartVerification $verification): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -185,18 +185,9 @@ protected function getHeaderActions(): array
|
|||||||
|
|
||||||
$initiator = $user;
|
$initiator = $user;
|
||||||
|
|
||||||
$result = $gate->start(
|
$result = $verification->providerConnectionCheck(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
connection: $record,
|
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,
|
initiator: $initiator,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -242,9 +233,8 @@ protected function getHeaderActions(): array
|
|||||||
->send();
|
->send();
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
|
||||||
->tooltip('You do not have permission to run provider operations.')
|
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||||
->apply(),
|
->apply(),
|
||||||
|
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
|
|||||||
@ -87,7 +87,7 @@ public static function form(Schema $schema): Schema
|
|||||||
Forms\Components\Select::make('backup_set_id')
|
Forms\Components\Select::make('backup_set_id')
|
||||||
->label('Backup set')
|
->label('Backup set')
|
||||||
->options(function () {
|
->options(function () {
|
||||||
$tenantId = Tenant::current()->getKey();
|
$tenantId = Tenant::currentOrFail()->getKey();
|
||||||
|
|
||||||
return BackupSet::query()
|
return BackupSet::query()
|
||||||
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
|
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
|
||||||
@ -219,7 +219,7 @@ public static function getWizardSteps(): array
|
|||||||
Forms\Components\Select::make('backup_set_id')
|
Forms\Components\Select::make('backup_set_id')
|
||||||
->label('Backup set')
|
->label('Backup set')
|
||||||
->options(function () {
|
->options(function () {
|
||||||
$tenantId = Tenant::current()->getKey();
|
$tenantId = Tenant::currentOrFail()->getKey();
|
||||||
|
|
||||||
return BackupSet::query()
|
return BackupSet::query()
|
||||||
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
|
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
namespace App\Filament\Resources\RestoreRunResource\Pages;
|
namespace App\Filament\Resources\RestoreRunResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\UiEnforcement;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
@ -13,9 +15,7 @@ class ListRestoreRuns extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\CreateAction::make()
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)->apply(Actions\CreateAction::make()),
|
||||||
->disabled(fn (): bool => ! RestoreRunResource::canCreate())
|
|
||||||
->tooltip(fn (): ?string => RestoreRunResource::canCreate() ? null : 'You do not have permission to create restore runs.'),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
use App\Jobs\SyncPoliciesJob;
|
use App\Jobs\SyncPoliciesJob;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Auth\RoleCapabilityMap;
|
use App\Services\Auth\RoleCapabilityMap;
|
||||||
use App\Services\Directory\EntraGroupLabelResolver;
|
use App\Services\Directory\EntraGroupLabelResolver;
|
||||||
@ -21,6 +22,7 @@
|
|||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Operations\BulkSelectionIdentity;
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\UiTooltips;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Badges\TagBadgeDomain;
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
@ -28,6 +30,8 @@
|
|||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
@ -68,7 +72,21 @@ public static function canCreate(): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return static::userCanManageAnyTenant($user);
|
if (static::userCanManageAnyTenant($user)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||||
|
|
||||||
|
if ($workspaceId === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return WorkspaceMembership::query()
|
||||||
|
->where('workspace_id', $workspaceId)
|
||||||
|
->where('user_id', $user->getKey())
|
||||||
|
->whereIn('role', ['owner', 'manager'])
|
||||||
|
->exists();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canEdit(Model $record): bool
|
public static function canEdit(Model $record): bool
|
||||||
@ -177,8 +195,15 @@ public static function getEloquentQuery(): Builder
|
|||||||
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
if ($workspaceId === null) {
|
||||||
|
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
||||||
|
}
|
||||||
|
|
||||||
$tenantIds = $user->tenants()
|
$tenantIds = $user->tenants()
|
||||||
->withTrashed()
|
->withTrashed()
|
||||||
|
->where('workspace_id', $workspaceId)
|
||||||
->pluck('tenants.id');
|
->pluck('tenants.id');
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
return parent::getEloquentQuery()
|
||||||
@ -262,95 +287,57 @@ public static function table(Table $table): Table
|
|||||||
->label('View')
|
->label('View')
|
||||||
->icon('heroicon-o-eye')
|
->icon('heroicon-o-eye')
|
||||||
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record], tenant: $record)),
|
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record], tenant: $record)),
|
||||||
Actions\Action::make('syncTenant')
|
UiEnforcement::forAction(
|
||||||
->label('Sync')
|
Actions\Action::make('syncTenant')
|
||||||
->icon('heroicon-o-arrow-path')
|
->label('Sync')
|
||||||
->color('warning')
|
->icon('heroicon-o-arrow-path')
|
||||||
->requiresConfirmation()
|
->color('warning')
|
||||||
->visible(function (Tenant $record): bool {
|
->requiresConfirmation()
|
||||||
if (! $record->isActive()) {
|
->visible(function (Tenant $record): bool {
|
||||||
return false;
|
if (! $record->isActive()) {
|
||||||
}
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $user->canAccessTenant($record);
|
return $user->canAccessTenant($record);
|
||||||
})
|
})
|
||||||
->disabled(function (Tenant $record): bool {
|
->action(function (Tenant $record, AuditLogger $auditLogger, \Filament\Tables\Contracts\HasTable $livewire): void {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User) {
|
||||||
return true;
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
if (! $user->canAccessTenant($record)) {
|
||||||
$resolver = app(CapabilityResolver::class);
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
return ! $resolver->can($user, $record, Capabilities::TENANT_SYNC);
|
/** @var CapabilityResolver $resolver */
|
||||||
})
|
$resolver = app(CapabilityResolver::class);
|
||||||
->tooltip(function (Tenant $record): ?string {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
if (! $resolver->can($user, $record, Capabilities::TENANT_SYNC)) {
|
||||||
return null;
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
/** @var OperationRunService $opService */
|
||||||
$resolver = app(CapabilityResolver::class);
|
$opService = app(OperationRunService::class);
|
||||||
|
|
||||||
return $resolver->can($user, $record, Capabilities::TENANT_SYNC)
|
$supportedTypes = config('tenantpilot.supported_policy_types', []);
|
||||||
? null
|
$typeNames = array_map(
|
||||||
: 'You do not have permission to sync this tenant.';
|
static fn (array $typeConfig): string => (string) $typeConfig['type'],
|
||||||
})
|
$supportedTypes,
|
||||||
->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(
|
$opRun = $opService->ensureRun(
|
||||||
tenant: $record,
|
tenant: $record,
|
||||||
@ -358,295 +345,262 @@ public static function table(Table $table): Table
|
|||||||
inputs: $inputs,
|
inputs: $inputs,
|
||||||
initiator: auth()->user()
|
initiator: auth()->user()
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
if (! $opRun->wasRecentlyCreated && $opService->isStaleQueuedRun($opRun)) {
|
||||||
Notification::make()
|
$opService->failStaleQueuedRun(
|
||||||
->title('Policy sync already active')
|
$opRun,
|
||||||
->body('This operation is already queued or running.')
|
message: 'Run was queued but never started (likely a previous dispatch error). Re-queuing.'
|
||||||
->warning()
|
);
|
||||||
|
|
||||||
|
$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)
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('View Run')
|
->label('View Run')
|
||||||
->url(OperationRunLinks::view($opRun, $record)),
|
->url(OperationRunLinks::view($opRun, $record)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
|
})
|
||||||
return;
|
)
|
||||||
}
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
$opService->dispatchOrFail($opRun, function () use ($record, $supportedTypes, $opRun): void {
|
->apply(),
|
||||||
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')
|
Actions\Action::make('openTenant')
|
||||||
->label('Open')
|
->label('Open')
|
||||||
->icon('heroicon-o-arrow-right')
|
->icon('heroicon-o-arrow-right')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', tenant: $record))
|
->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', tenant: $record))
|
||||||
->visible(fn (Tenant $record) => $record->isActive()),
|
->visible(fn (Tenant $record) => $record->isActive()),
|
||||||
Actions\Action::make('edit')
|
UiEnforcement::forAction(
|
||||||
->label('Edit')
|
Actions\Action::make('edit')
|
||||||
->icon('heroicon-o-pencil-square')
|
->label('Edit')
|
||||||
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record], tenant: $record))
|
->icon('heroicon-o-pencil-square')
|
||||||
->disabled(fn (Tenant $record): bool => ! static::canEdit($record))
|
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record], tenant: $record))
|
||||||
->tooltip(fn (Tenant $record): ?string => static::canEdit($record) ? null : 'You do not have permission to edit this tenant.'),
|
)
|
||||||
Actions\Action::make('restore')
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->label('Restore')
|
->apply(),
|
||||||
->color('success')
|
UiEnforcement::forAction(
|
||||||
->successNotificationTitle('Tenant reactivated')
|
Actions\Action::make('restore')
|
||||||
->requiresConfirmation()
|
->label('Restore')
|
||||||
->visible(fn (Tenant $record): bool => $record->trashed())
|
->color('success')
|
||||||
->disabled(function (Tenant $record): bool {
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
$user = auth()->user();
|
->successNotificationTitle('Tenant reactivated')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (Tenant $record): bool => $record->trashed())
|
||||||
|
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User) {
|
||||||
return true;
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
/** @var CapabilityResolver $resolver */
|
||||||
$resolver = app(CapabilityResolver::class);
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
return ! $resolver->can($user, $record, Capabilities::TENANT_DELETE);
|
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||||
})
|
abort(403);
|
||||||
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
}
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
$record->restore();
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
$auditLogger->log(
|
||||||
$resolver = app(CapabilityResolver::class);
|
tenant: $record,
|
||||||
|
action: 'tenant.restored',
|
||||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
resourceType: 'tenant',
|
||||||
abort(403);
|
resourceId: (string) $record->id,
|
||||||
}
|
status: 'success',
|
||||||
|
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
||||||
$record->restore();
|
);
|
||||||
|
})
|
||||||
$auditLogger->log(
|
)
|
||||||
tenant: $record,
|
->preserveVisibility()
|
||||||
action: 'tenant.restored',
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
resourceType: 'tenant',
|
->apply(),
|
||||||
resourceId: (string) $record->id,
|
UiEnforcement::forAction(
|
||||||
status: 'success',
|
Actions\Action::make('admin_consent')
|
||||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
->label('Admin consent')
|
||||||
);
|
->icon('heroicon-o-clipboard-document')
|
||||||
}),
|
->url(fn (Tenant $record) => static::adminConsentUrl($record))
|
||||||
Actions\Action::make('admin_consent')
|
->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null)
|
||||||
->label('Admin consent')
|
->openUrlInNewTab(),
|
||||||
->icon('heroicon-o-clipboard-document')
|
)
|
||||||
->url(fn (Tenant $record) => static::adminConsentUrl($record))
|
->preserveVisibility()
|
||||||
->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->disabled(function (Tenant $record): bool {
|
->apply(),
|
||||||
$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')
|
Actions\Action::make('open_in_entra')
|
||||||
->label('Open in Entra')
|
->label('Open in Entra')
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
->url(fn (Tenant $record) => static::entraUrl($record))
|
->url(fn (Tenant $record) => static::entraUrl($record))
|
||||||
->visible(fn (Tenant $record) => static::entraUrl($record) !== null)
|
->visible(fn (Tenant $record) => static::entraUrl($record) !== null)
|
||||||
->openUrlInNewTab(),
|
->openUrlInNewTab(),
|
||||||
Actions\Action::make('verify')
|
UiEnforcement::forAction(
|
||||||
->label('Verify configuration')
|
Actions\Action::make('verify')
|
||||||
->icon('heroicon-o-check-badge')
|
->label('Verify configuration')
|
||||||
->color('primary')
|
->icon('heroicon-o-check-badge')
|
||||||
->requiresConfirmation()
|
->color('primary')
|
||||||
->visible(fn (Tenant $record): bool => $record->isActive())
|
->requiresConfirmation()
|
||||||
->disabled(function (Tenant $record): bool {
|
->visible(fn (Tenant $record): bool => $record->isActive())
|
||||||
$user = auth()->user();
|
->action(function (
|
||||||
|
Tenant $record,
|
||||||
|
TenantConfigService $configService,
|
||||||
|
TenantPermissionService $permissionService,
|
||||||
|
RbacHealthService $rbacHealthService,
|
||||||
|
AuditLogger $auditLogger
|
||||||
|
): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User) {
|
||||||
return true;
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
/** @var CapabilityResolver $resolver */
|
||||||
$resolver = app(CapabilityResolver::class);
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
return ! $resolver->can($user, $record, Capabilities::TENANT_MANAGE);
|
if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGE)) {
|
||||||
})
|
abort(403);
|
||||||
->action(function (
|
}
|
||||||
Tenant $record,
|
|
||||||
TenantConfigService $configService,
|
|
||||||
TenantPermissionService $permissionService,
|
|
||||||
RbacHealthService $rbacHealthService,
|
|
||||||
AuditLogger $auditLogger
|
|
||||||
) {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
static::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
|
||||||
abort(403);
|
}),
|
||||||
}
|
)
|
||||||
|
->preserveVisibility()
|
||||||
/** @var CapabilityResolver $resolver */
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
$resolver = app(CapabilityResolver::class);
|
->apply(),
|
||||||
|
|
||||||
if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGE)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
static::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
|
|
||||||
}),
|
|
||||||
static::rbacAction(),
|
static::rbacAction(),
|
||||||
Actions\Action::make('archive')
|
UiEnforcement::forAction(
|
||||||
->label('Deactivate')
|
Actions\Action::make('archive')
|
||||||
->color('danger')
|
->label('Deactivate')
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
->requiresConfirmation()
|
||||||
->disabled(function (Tenant $record): bool {
|
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
||||||
$user = auth()->user();
|
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User) {
|
||||||
return true;
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
/** @var CapabilityResolver $resolver */
|
||||||
$resolver = app(CapabilityResolver::class);
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
return ! $resolver->can($user, $record, Capabilities::TENANT_DELETE);
|
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||||
})
|
abort(403);
|
||||||
->action(function (Tenant $record, AuditLogger $auditLogger) {
|
}
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
$record->delete();
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
$auditLogger->log(
|
||||||
$resolver = app(CapabilityResolver::class);
|
tenant: $record,
|
||||||
|
action: 'tenant.archived',
|
||||||
|
resourceType: 'tenant',
|
||||||
|
resourceId: (string) $record->id,
|
||||||
|
status: 'success',
|
||||||
|
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
||||||
|
);
|
||||||
|
|
||||||
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()
|
Notification::make()
|
||||||
->title('Tenant must be archived first')
|
->title('Tenant deactivated')
|
||||||
->danger()
|
->body('The tenant has been archived and hidden from lists.')
|
||||||
|
->success()
|
||||||
->send();
|
->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;
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
$user = auth()->user();
|
||||||
}
|
|
||||||
|
|
||||||
$auditLogger->log(
|
if (! $user instanceof User) {
|
||||||
tenant: $tenant,
|
abort(403);
|
||||||
action: 'tenant.force_deleted',
|
}
|
||||||
resourceType: 'tenant',
|
|
||||||
resourceId: (string) $tenant->id,
|
|
||||||
status: 'success',
|
|
||||||
context: ['metadata' => ['tenant_id' => $tenant->tenant_id]]
|
|
||||||
);
|
|
||||||
|
|
||||||
$tenant->forceDelete();
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
Notification::make()
|
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||||
->title('Tenant permanently deleted')
|
abort(403);
|
||||||
->success()
|
}
|
||||||
->send();
|
|
||||||
}),
|
$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(),
|
||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
@ -655,27 +609,45 @@ public static function table(Table $table): Table
|
|||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('warning')
|
->color('warning')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(function (): bool {
|
->visible(fn (): bool => auth()->user() instanceof User)
|
||||||
|
->authorize(fn (): bool => auth()->user() instanceof User)
|
||||||
|
->disabled(function (Collection $records): bool {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User) {
|
||||||
return false;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $user->tenants()
|
if ($records->isEmpty()) {
|
||||||
->whereIn('role', RoleCapabilityMap::rolesWithCapability(Capabilities::TENANT_SYNC))
|
return true;
|
||||||
->exists();
|
}
|
||||||
|
|
||||||
|
/** @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));
|
||||||
})
|
})
|
||||||
->authorize(function (): bool {
|
->tooltip(function (Collection $records): ?string {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User) {
|
||||||
return false;
|
return UiTooltips::insufficientPermission();
|
||||||
}
|
}
|
||||||
|
|
||||||
return $user->tenants()
|
if ($records->isEmpty()) {
|
||||||
->whereIn('role', RoleCapabilityMap::rolesWithCapability(Capabilities::TENANT_SYNC))
|
return null;
|
||||||
->exists();
|
}
|
||||||
|
|
||||||
|
/** @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;
|
||||||
})
|
})
|
||||||
->action(function (Collection $records, AuditLogger $auditLogger): void {
|
->action(function (Collection $records, AuditLogger $auditLogger): void {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -982,9 +954,7 @@ public static function rbacAction(): Actions\Action
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$actor = auth()->user();
|
$result = $service->run($record, $data, $user, $token);
|
||||||
|
|
||||||
$result = $service->run($record, $data, $actor, $token);
|
|
||||||
|
|
||||||
Cache::forget($cacheKey);
|
Cache::forget($cacheKey);
|
||||||
|
|
||||||
|
|||||||
@ -4,12 +4,28 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\TenantResource;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
class CreateTenant extends CreateRecord
|
class CreateTenant extends CreateRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = TenantResource::class;
|
protected static string $resource = TenantResource::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function mutateFormDataBeforeCreate(array $data): array
|
||||||
|
{
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||||
|
|
||||||
|
if ($workspaceId !== null) {
|
||||||
|
$data['workspace_id'] = $workspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
protected function afterCreate(): void
|
protected function afterCreate(): void
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|||||||
@ -3,11 +3,14 @@
|
|||||||
namespace App\Filament\Resources\TenantResource\Pages;
|
namespace App\Filament\Resources\TenantResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\TenantResource;
|
||||||
|
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Intune\RbacHealthService;
|
use App\Services\Intune\RbacHealthService;
|
||||||
use App\Services\Intune\TenantConfigService;
|
use App\Services\Intune\TenantConfigService;
|
||||||
use App\Services\Intune\TenantPermissionService;
|
use App\Services\Intune\TenantPermissionService;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
@ -16,11 +19,25 @@ class ViewTenant extends ViewRecord
|
|||||||
{
|
{
|
||||||
protected static string $resource = TenantResource::class;
|
protected static string $resource = TenantResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderWidgets(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
TenantArchivedBanner::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
Actions\EditAction::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\Action::make('admin_consent')
|
Actions\Action::make('admin_consent')
|
||||||
->label('Admin consent')
|
->label('Admin consent')
|
||||||
->icon('heroicon-o-clipboard-document')
|
->icon('heroicon-o-clipboard-document')
|
||||||
@ -48,30 +65,40 @@ protected function getHeaderActions(): array
|
|||||||
TenantResource::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
|
TenantResource::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
|
||||||
}),
|
}),
|
||||||
TenantResource::rbacAction(),
|
TenantResource::rbacAction(),
|
||||||
Actions\Action::make('archive')
|
UiEnforcement::forAction(
|
||||||
->label('Deactivate')
|
Actions\Action::make('archive')
|
||||||
->color('danger')
|
->label('Deactivate')
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
->visible(fn (Tenant $record) => ! $record->trashed())
|
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
||||||
->action(function (Tenant $record, AuditLogger $auditLogger) {
|
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
||||||
$record->delete();
|
$record->delete();
|
||||||
|
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $record,
|
tenant: $record,
|
||||||
action: 'tenant.archived',
|
action: 'tenant.archived',
|
||||||
resourceType: 'tenant',
|
resourceType: 'tenant',
|
||||||
resourceId: (string) $record->id,
|
resourceId: (string) $record->getKey(),
|
||||||
status: 'success',
|
status: 'success',
|
||||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
context: [
|
||||||
);
|
'metadata' => [
|
||||||
|
'internal_tenant_id' => (int) $record->getKey(),
|
||||||
|
'tenant_guid' => (string) $record->tenant_id,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Tenant deactivated')
|
->title('Tenant deactivated')
|
||||||
->body('The tenant has been archived and hidden from lists.')
|
->body('The tenant has been archived and hidden from lists.')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
|
->destructive()
|
||||||
|
->apply(),
|
||||||
])
|
])
|
||||||
->label('Actions')
|
->label('Actions')
|
||||||
->icon('heroicon-o-ellipsis-vertical')
|
->icon('heroicon-o-ellipsis-vertical')
|
||||||
|
|||||||
@ -25,11 +25,22 @@ public function table(Table $table): Table
|
|||||||
return $table
|
return $table
|
||||||
->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
|
->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('user.name')
|
Tables\Columns\TextColumn::make('user.email')
|
||||||
->label(__('User'))
|
->label(__('User'))
|
||||||
->searchable(),
|
->searchable(),
|
||||||
Tables\Columns\TextColumn::make('user.email')
|
Tables\Columns\TextColumn::make('user_domain')
|
||||||
->label(__('Email'))
|
->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'))
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('role')
|
Tables\Columns\TextColumn::make('role')
|
||||||
->badge()
|
->badge()
|
||||||
@ -49,7 +60,13 @@ public function table(Table $table): Table
|
|||||||
->label(__('User'))
|
->label(__('User'))
|
||||||
->required()
|
->required()
|
||||||
->searchable()
|
->searchable()
|
||||||
->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()),
|
->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')
|
Forms\Components\Select::make('role')
|
||||||
->label(__('Role'))
|
->label(__('Role'))
|
||||||
->required()
|
->required()
|
||||||
|
|||||||
35
app/Filament/Resources/Workspaces/Pages/CreateWorkspace.php
Normal file
35
app/Filament/Resources/Workspaces/Pages/CreateWorkspace.php
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<?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());
|
||||||
|
}
|
||||||
|
}
|
||||||
11
app/Filament/Resources/Workspaces/Pages/EditWorkspace.php
Normal file
11
app/Filament/Resources/Workspaces/Pages/EditWorkspace.php
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
19
app/Filament/Resources/Workspaces/Pages/ListWorkspaces.php
Normal file
19
app/Filament/Resources/Workspaces/Pages/ListWorkspaces.php
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Workspaces\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListWorkspaces extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = WorkspaceResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\CreateAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php
Normal file
19
app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Workspaces\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
|
class ViewWorkspace extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = WorkspaceResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\EditAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,221 @@
|
|||||||
|
<?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([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
app/Filament/Resources/Workspaces/WorkspaceResource.php
Normal file
79
app/Filament/Resources/Workspaces/WorkspaceResource.php
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
<?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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/Filament/Support/VerificationReportViewer.php
Normal file
44
app/Filament/Support/VerificationReportViewer.php
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
169
app/Filament/System/Pages/RepairWorkspaceOwners.php
Normal file
169
app/Filament/System/Pages/RepairWorkspaceOwners.php
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\System\Pages;
|
||||||
|
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
|
use App\Services\Auth\BreakGlassSession;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
|
use App\Support\Auth\WorkspaceRole;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
|
||||||
|
class RepairWorkspaceOwners extends Page
|
||||||
|
{
|
||||||
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-wrench-screwdriver';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Repair workspace owners';
|
||||||
|
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = 'Recovery';
|
||||||
|
|
||||||
|
protected string $view = 'filament.system.pages.repair-workspace-owners';
|
||||||
|
|
||||||
|
public static function canAccess(): bool
|
||||||
|
{
|
||||||
|
$user = auth('platform')->user();
|
||||||
|
|
||||||
|
if (! $user instanceof PlatformUser) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->hasCapability(PlatformCapabilities::USE_BREAK_GLASS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action>
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
$breakGlass = app(BreakGlassSession::class);
|
||||||
|
|
||||||
|
return [
|
||||||
|
Action::make('assign_owner')
|
||||||
|
->label('Assign owner (break-glass)')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Assign workspace owner')
|
||||||
|
->modalDescription('This is a recovery action. It is audited and should only be used when the workspace owner set is broken.')
|
||||||
|
->form([
|
||||||
|
Select::make('workspace_id')
|
||||||
|
->label('Workspace')
|
||||||
|
->required()
|
||||||
|
->searchable()
|
||||||
|
->getSearchResultsUsing(function (string $search): array {
|
||||||
|
return Workspace::query()
|
||||||
|
->where('name', 'like', "%{$search}%")
|
||||||
|
->orderBy('name')
|
||||||
|
->limit(25)
|
||||||
|
->pluck('name', 'id')
|
||||||
|
->all();
|
||||||
|
})
|
||||||
|
->getOptionLabelUsing(function ($value): ?string {
|
||||||
|
if (! is_numeric($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Workspace::query()->whereKey((int) $value)->value('name');
|
||||||
|
}),
|
||||||
|
|
||||||
|
Select::make('target_user_id')
|
||||||
|
->label('User')
|
||||||
|
->required()
|
||||||
|
->searchable()
|
||||||
|
->getSearchResultsUsing(function (string $search): array {
|
||||||
|
return User::query()
|
||||||
|
->where('email', 'like', "%{$search}%")
|
||||||
|
->orderBy('email')
|
||||||
|
->limit(25)
|
||||||
|
->pluck('email', 'id')
|
||||||
|
->all();
|
||||||
|
})
|
||||||
|
->getOptionLabelUsing(function ($value): ?string {
|
||||||
|
if (! is_numeric($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return User::query()->whereKey((int) $value)->value('email');
|
||||||
|
}),
|
||||||
|
|
||||||
|
Textarea::make('reason')
|
||||||
|
->label('Reason')
|
||||||
|
->required()
|
||||||
|
->minLength(5)
|
||||||
|
->maxLength(500)
|
||||||
|
->rows(4),
|
||||||
|
])
|
||||||
|
->action(function (array $data, BreakGlassSession $breakGlass, WorkspaceAuditLogger $auditLogger): void {
|
||||||
|
$platformUser = auth('platform')->user();
|
||||||
|
|
||||||
|
if (! $platformUser instanceof PlatformUser) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $platformUser->hasCapability(PlatformCapabilities::USE_BREAK_GLASS)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $breakGlass->isActive()) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = (int) ($data['workspace_id'] ?? 0);
|
||||||
|
$targetUserId = (int) ($data['target_user_id'] ?? 0);
|
||||||
|
$reason = (string) ($data['reason'] ?? '');
|
||||||
|
|
||||||
|
$workspace = Workspace::query()->whereKey($workspaceId)->firstOrFail();
|
||||||
|
$targetUser = User::query()->whereKey($targetUserId)->firstOrFail();
|
||||||
|
|
||||||
|
$membership = WorkspaceMembership::query()->firstOrNew([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $targetUser->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$fromRole = $membership->exists ? (string) $membership->role : null;
|
||||||
|
|
||||||
|
$membership->forceFill([
|
||||||
|
'role' => WorkspaceRole::Owner->value,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$auditLogger->log(
|
||||||
|
workspace: $workspace,
|
||||||
|
action: AuditActionId::WorkspaceMembershipBreakGlassAssignOwner->value,
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'actor_user_id' => (int) $platformUser->getKey(),
|
||||||
|
'target_user_id' => (int) $targetUser->getKey(),
|
||||||
|
'attempted_role' => WorkspaceRole::Owner->value,
|
||||||
|
'from_role' => $fromRole,
|
||||||
|
'reason' => trim($reason),
|
||||||
|
'source' => 'break_glass',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actor: null,
|
||||||
|
status: 'success',
|
||||||
|
resourceType: 'workspace',
|
||||||
|
resourceId: (string) $workspace->getKey(),
|
||||||
|
actorId: (int) $platformUser->getKey(),
|
||||||
|
actorEmail: $platformUser->email,
|
||||||
|
actorName: $platformUser->name,
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Owner assigned')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
->disabled(fn (): bool => ! $breakGlass->isActive()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Filament/Widgets/Tenant/TenantArchivedBanner.php
Normal file
28
app/Filament/Widgets/Tenant/TenantArchivedBanner.php
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<?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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
72
app/Http/Controllers/SelectTenantController.php
Normal file
72
app/Http/Controllers/SelectTenantController.php
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Filament\Pages\TenantDashboard;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserTenantPreference;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
final class SelectTenantController
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId($request);
|
||||||
|
|
||||||
|
if ($workspaceId === null) {
|
||||||
|
return redirect()->to('/admin/choose-workspace');
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'tenant_id' => ['required', 'integer'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant = Tenant::query()
|
||||||
|
->where('status', 'active')
|
||||||
|
->where('workspace_id', $workspaceId)
|
||||||
|
->whereKey($validated['tenant_id'])
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->persistLastTenant($user, $tenant);
|
||||||
|
|
||||||
|
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function persistLastTenant(User $user, Tenant $tenant): void
|
||||||
|
{
|
||||||
|
if (Schema::hasColumn('users', 'last_tenant_id')) {
|
||||||
|
$user->forceFill(['last_tenant_id' => $tenant->getKey()])->save();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Schema::hasTable('user_tenant_preferences')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
UserTenantPreference::query()->updateOrCreate(
|
||||||
|
['user_id' => $user->getKey(), 'tenant_id' => $tenant->getKey()],
|
||||||
|
['last_used_at' => now()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
app/Http/Controllers/SwitchWorkspaceController.php
Normal file
67
app/Http/Controllers/SwitchWorkspaceController.php
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
<?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());
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Http/Middleware/EnsureWorkspaceMember.php
Normal file
51
app/Http/Middleware/EnsureWorkspaceMember.php
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
app/Http/Middleware/EnsureWorkspaceSelected.php
Normal file
67
app/Http/Middleware/EnsureWorkspaceSelected.php
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
<?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,11 +7,14 @@
|
|||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Providers\Contracts\HealthResult;
|
use App\Services\Providers\Contracts\HealthResult;
|
||||||
use App\Services\Providers\MicrosoftProviderHealthCheck;
|
use App\Services\Providers\MicrosoftProviderHealthCheck;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\Verification\VerificationReportWriter;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
@ -83,17 +86,64 @@ public function handle(
|
|||||||
|
|
||||||
$this->updateRunTargetScope($this->operationRun, $connection, $entraTenantName);
|
$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) {
|
if ($result->healthy) {
|
||||||
$runs->updateRun(
|
$run = $runs->updateRun(
|
||||||
$this->operationRun,
|
$this->operationRun,
|
||||||
status: OperationRunStatus::Completed->value,
|
status: OperationRunStatus::Completed->value,
|
||||||
outcome: OperationRunOutcome::Succeeded->value,
|
outcome: OperationRunOutcome::Succeeded->value,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->logVerificationCompletion($tenant, $user, $run, $report);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$runs->updateRun(
|
$run = $runs->updateRun(
|
||||||
$this->operationRun,
|
$this->operationRun,
|
||||||
status: OperationRunStatus::Completed->value,
|
status: OperationRunStatus::Completed->value,
|
||||||
outcome: OperationRunOutcome::Failed->value,
|
outcome: OperationRunOutcome::Failed->value,
|
||||||
@ -103,6 +153,8 @@ public function handle(
|
|||||||
'message' => $result->message ?? 'Health check failed.',
|
'message' => $result->message ?? 'Health check failed.',
|
||||||
]],
|
]],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->logVerificationCompletion($tenant, $user, $run, $report);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveEntraTenantName(ProviderConnection $connection, HealthResult $result): ?string
|
private function resolveEntraTenantName(ProviderConnection $connection, HealthResult $result): ?string
|
||||||
@ -145,4 +197,34 @@ private function applyHealthResult(ProviderConnection $connection, HealthResult
|
|||||||
'last_error_message' => $result->healthy ? null : $result->message,
|
'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
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
$backupSet = BackupSet::query()->find($this->backupSetId);
|
$backupSet = BackupSet::query()->find($this->backupSetId);
|
||||||
$tenantId = $backupSet?->tenant_id ?? Tenant::current()->getKey();
|
$tenantId = $backupSet?->tenant_id ?? Tenant::currentOrFail()->getKey();
|
||||||
$existingPolicyIds = $backupSet
|
$existingPolicyIds = $backupSet
|
||||||
? $backupSet->items()->pluck('policy_id')->filter()->all()
|
? $backupSet->items()->pluck('policy_id')->filter()->all()
|
||||||
: [];
|
: [];
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||||
@ -116,7 +117,7 @@ public function makeCurrent(): void
|
|||||||
$this->forceFill(['is_current' => true]);
|
$this->forceFill(['is_current' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function current(): self
|
public static function current(): ?self
|
||||||
{
|
{
|
||||||
$filamentTenant = Filament::getTenant();
|
$filamentTenant = Filament::getTenant();
|
||||||
|
|
||||||
@ -145,6 +146,13 @@ public static function current(): self
|
|||||||
->where('is_current', true)
|
->where('is_current', true)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function currentOrFail(): self
|
||||||
|
{
|
||||||
|
$tenant = static::current();
|
||||||
|
|
||||||
if (! $tenant) {
|
if (! $tenant) {
|
||||||
throw new RuntimeException('No current tenant selected.');
|
throw new RuntimeException('No current tenant selected.');
|
||||||
}
|
}
|
||||||
@ -152,11 +160,29 @@ public static function current(): self
|
|||||||
return $tenant;
|
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
|
public function memberships(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(TenantMembership::class);
|
return $this->hasMany(TenantMembership::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function workspace(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Workspace::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function roleMappings(): HasMany
|
public function roleMappings(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(TenantRoleMapping::class);
|
return $this->hasMany(TenantRoleMapping::class);
|
||||||
|
|||||||
54
app/Models/TenantOnboardingSession.php
Normal file
54
app/Models/TenantOnboardingSession.php
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<?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,6 +3,7 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Models\Contracts\FilamentUser;
|
use Filament\Models\Contracts\FilamentUser;
|
||||||
use Filament\Models\Contracts\HasDefaultTenant;
|
use Filament\Models\Contracts\HasDefaultTenant;
|
||||||
use Filament\Models\Contracts\HasTenants;
|
use Filament\Models\Contracts\HasTenants;
|
||||||
@ -130,8 +131,8 @@ public function canAccessTenant(Model $tenant): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->tenants()
|
return $this->tenantMemberships()
|
||||||
->whereKey($tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->exists();
|
->exists();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,7 +142,10 @@ public function getTenants(Panel $panel): array|Collection
|
|||||||
return collect();
|
return collect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||||
|
|
||||||
return $this->tenants()
|
return $this->tenants()
|
||||||
|
->when($workspaceId !== null, fn ($query) => $query->where('tenants.workspace_id', $workspaceId))
|
||||||
->where('status', 'active')
|
->where('status', 'active')
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get();
|
->get();
|
||||||
@ -153,6 +157,8 @@ public function getDefaultTenant(Panel $panel): ?Model
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||||
|
|
||||||
$tenantId = null;
|
$tenantId = null;
|
||||||
|
|
||||||
if ($this->tenantPreferencesTableExists()) {
|
if ($this->tenantPreferencesTableExists()) {
|
||||||
@ -164,6 +170,7 @@ public function getDefaultTenant(Panel $panel): ?Model
|
|||||||
|
|
||||||
if ($tenantId !== null) {
|
if ($tenantId !== null) {
|
||||||
$tenant = $this->tenants()
|
$tenant = $this->tenants()
|
||||||
|
->when($workspaceId !== null, fn ($query) => $query->where('tenants.workspace_id', $workspaceId))
|
||||||
->where('status', 'active')
|
->where('status', 'active')
|
||||||
->whereKey($tenantId)
|
->whereKey($tenantId)
|
||||||
->first();
|
->first();
|
||||||
@ -174,6 +181,7 @@ public function getDefaultTenant(Panel $panel): ?Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
return $this->tenants()
|
return $this->tenants()
|
||||||
|
->when($workspaceId !== null, fn ($query) => $query->where('tenants.workspace_id', $workspaceId))
|
||||||
->where('status', 'active')
|
->where('status', 'active')
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->first();
|
->first();
|
||||||
|
|||||||
43
app/Models/Workspace.php
Normal file
43
app/Models/Workspace.php
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Models/WorkspaceMembership.php
Normal file
31
app/Models/WorkspaceMembership.php
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
108
app/Policies/WorkspaceMembershipPolicy.php
Normal file
108
app/Policies/WorkspaceMembershipPolicy.php
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
74
app/Policies/WorkspacePolicy.php
Normal file
74
app/Policies/WorkspacePolicy.php
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<?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,8 +6,10 @@
|
|||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
use App\Policies\ProviderConnectionPolicy;
|
use App\Policies\ProviderConnectionPolicy;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||||
@ -23,15 +25,36 @@ public function boot(): void
|
|||||||
{
|
{
|
||||||
$this->registerPolicies();
|
$this->registerPolicies();
|
||||||
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
$tenantResolver = app(CapabilityResolver::class);
|
||||||
|
$workspaceResolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
$defineTenantCapability = function (string $capability) use ($resolver): void {
|
$defineTenantCapability = function (string $capability) use ($tenantResolver): void {
|
||||||
Gate::define($capability, function (User $user, Tenant $tenant) use ($resolver, $capability): bool {
|
Gate::define($capability, function (User $user, ?Tenant $tenant = null) use ($tenantResolver, $capability): bool {
|
||||||
return $resolver->can($user, $tenant, $capability);
|
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);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (Capabilities::all() as $capability) {
|
foreach (Capabilities::all() as $capability) {
|
||||||
|
if (str_starts_with($capability, 'workspace')) {
|
||||||
|
$defineWorkspaceCapability($capability);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$defineTenantCapability($capability);
|
$defineTenantCapability($capability);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,9 +4,10 @@
|
|||||||
|
|
||||||
use App\Filament\Pages\Auth\Login;
|
use App\Filament\Pages\Auth\Login;
|
||||||
use App\Filament\Pages\ChooseTenant;
|
use App\Filament\Pages\ChooseTenant;
|
||||||
|
use App\Filament\Pages\ChooseWorkspace;
|
||||||
use App\Filament\Pages\NoAccess;
|
use App\Filament\Pages\NoAccess;
|
||||||
use App\Filament\Pages\Tenancy\RegisterTenant;
|
|
||||||
use App\Filament\Pages\TenantDashboard;
|
use App\Filament\Pages\TenantDashboard;
|
||||||
|
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
@ -14,6 +15,7 @@
|
|||||||
use Filament\Http\Middleware\AuthenticateSession;
|
use Filament\Http\Middleware\AuthenticateSession;
|
||||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||||
|
use Filament\Navigation\NavigationItem;
|
||||||
use Filament\Panel;
|
use Filament\Panel;
|
||||||
use Filament\PanelProvider;
|
use Filament\PanelProvider;
|
||||||
use Filament\Support\Colors\Color;
|
use Filament\Support\Colors\Color;
|
||||||
@ -37,21 +39,36 @@ public function panel(Panel $panel): Panel
|
|||||||
->path('admin')
|
->path('admin')
|
||||||
->login(Login::class)
|
->login(Login::class)
|
||||||
->authenticatedRoutes(function (Panel $panel): void {
|
->authenticatedRoutes(function (Panel $panel): void {
|
||||||
|
ChooseWorkspace::registerRoutes($panel);
|
||||||
ChooseTenant::registerRoutes($panel);
|
ChooseTenant::registerRoutes($panel);
|
||||||
NoAccess::registerRoutes($panel);
|
NoAccess::registerRoutes($panel);
|
||||||
|
|
||||||
|
WorkspaceResource::registerRoutes($panel);
|
||||||
})
|
})
|
||||||
->tenant(Tenant::class, slugAttribute: 'external_id')
|
->tenant(Tenant::class, slugAttribute: 'external_id')
|
||||||
->tenantRoutePrefix('t')
|
->tenantRoutePrefix('t')
|
||||||
->tenantMenu(fn (): bool => filled(Filament::getTenant()))
|
->tenantMenu(fn (): bool => filled(Filament::getTenant()))
|
||||||
->searchableTenantMenu()
|
->searchableTenantMenu()
|
||||||
->tenantRegistration(RegisterTenant::class)
|
|
||||||
->colors([
|
->colors([
|
||||||
'primary' => Color::Amber,
|
'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(
|
->renderHook(
|
||||||
PanelsRenderHook::HEAD_END,
|
PanelsRenderHook::HEAD_END,
|
||||||
fn () => view('filament.partials.livewire-intercept-shim')->render()
|
fn () => view('filament.partials.livewire-intercept-shim')->render()
|
||||||
)
|
)
|
||||||
|
->renderHook(
|
||||||
|
PanelsRenderHook::USER_MENU_PROFILE_AFTER,
|
||||||
|
fn () => view('filament.partials.workspace-switcher')->render()
|
||||||
|
)
|
||||||
->renderHook(
|
->renderHook(
|
||||||
PanelsRenderHook::BODY_END,
|
PanelsRenderHook::BODY_END,
|
||||||
fn () => (bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true)
|
fn () => (bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true)
|
||||||
@ -79,6 +96,8 @@ public function panel(Panel $panel): Panel
|
|||||||
VerifyCsrfToken::class,
|
VerifyCsrfToken::class,
|
||||||
SubstituteBindings::class,
|
SubstituteBindings::class,
|
||||||
'ensure-correct-guard:web',
|
'ensure-correct-guard:web',
|
||||||
|
'ensure-workspace-selected',
|
||||||
|
'ensure-filament-tenant-selected',
|
||||||
DenyNonMemberTenantAccess::class,
|
DenyNonMemberTenantAccess::class,
|
||||||
DisableBladeIconComponents::class,
|
DisableBladeIconComponents::class,
|
||||||
DispatchServingFilamentEvent::class,
|
DispatchServingFilamentEvent::class,
|
||||||
|
|||||||
48
app/Services/Audit/WorkspaceAuditLogger.php
Normal file
48
app/Services/Audit/WorkspaceAuditLogger.php
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<?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,39 +4,27 @@
|
|||||||
|
|
||||||
namespace App\Services\Auth;
|
namespace App\Services\Auth;
|
||||||
|
|
||||||
use App\Filament\Pages\TenantDashboard;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Collection;
|
use App\Models\WorkspaceMembership;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
class PostLoginRedirectResolver
|
class PostLoginRedirectResolver
|
||||||
{
|
{
|
||||||
public function resolve(User $user): string
|
public function resolve(User $user): string
|
||||||
{
|
{
|
||||||
$tenants = $this->getActiveTenants($user);
|
$membershipQuery = WorkspaceMembership::query()->where('user_id', $user->getKey());
|
||||||
|
|
||||||
if ($tenants->isEmpty()) {
|
$hasAnyActiveMembership = Schema::hasColumn('workspaces', 'archived_at')
|
||||||
|
? $membershipQuery
|
||||||
|
->join('workspaces', 'workspace_memberships.workspace_id', '=', 'workspaces.id')
|
||||||
|
->whereNull('workspaces.archived_at')
|
||||||
|
->exists()
|
||||||
|
: $membershipQuery->exists();
|
||||||
|
|
||||||
|
if (! $hasAnyActiveMembership) {
|
||||||
return '/admin/no-access';
|
return '/admin/no-access';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($tenants->count() === 1) {
|
return '/admin';
|
||||||
/** @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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
114
app/Services/Auth/TenantDiagnosticsService.php
Normal file
114
app/Services/Auth/TenantDiagnosticsService.php
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
100
app/Services/Auth/WorkspaceCapabilityResolver.php
Normal file
100
app/Services/Auth/WorkspaceCapabilityResolver.php
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
<?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];
|
||||||
|
}
|
||||||
|
}
|
||||||
303
app/Services/Auth/WorkspaceMembershipManager.php
Normal file
303
app/Services/Auth/WorkspaceMembershipManager.php
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
<?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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
app/Services/Auth/WorkspaceRoleCapabilityMap.php
Normal file
76
app/Services/Auth/WorkspaceRoleCapabilityMap.php
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
<?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,6 +4,7 @@
|
|||||||
|
|
||||||
use App\Models\AuditLog;
|
use App\Models\AuditLog;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Audit\AuditContextSanitizer;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
|
|
||||||
class AuditLogger
|
class AuditLogger
|
||||||
@ -22,6 +23,10 @@ public function log(
|
|||||||
$metadata = $context['metadata'] ?? [];
|
$metadata = $context['metadata'] ?? [];
|
||||||
unset($context['metadata']);
|
unset($context['metadata']);
|
||||||
|
|
||||||
|
$metadata = is_array($metadata) ? $metadata : [];
|
||||||
|
|
||||||
|
$sanitizedMetadata = AuditContextSanitizer::sanitize($metadata + $context);
|
||||||
|
|
||||||
return AuditLog::create([
|
return AuditLog::create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'actor_id' => $actorId,
|
'actor_id' => $actorId,
|
||||||
@ -31,7 +36,7 @@ public function log(
|
|||||||
'resource_type' => $resourceType,
|
'resource_type' => $resourceType,
|
||||||
'resource_id' => $resourceId,
|
'resource_id' => $resourceId,
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
'metadata' => $metadata + $context,
|
'metadata' => $sanitizedMetadata,
|
||||||
'recorded_at' => CarbonImmutable::now(),
|
'recorded_at' => CarbonImmutable::now(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
57
app/Services/Verification/StartVerification.php
Normal file
57
app/Services/Verification/StartVerification.php
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<?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,6 +6,12 @@
|
|||||||
|
|
||||||
enum AuditActionId: string
|
enum AuditActionId: string
|
||||||
{
|
{
|
||||||
|
case WorkspaceMembershipAdd = 'workspace_membership.add';
|
||||||
|
case WorkspaceMembershipRoleChange = 'workspace_membership.role_change';
|
||||||
|
case WorkspaceMembershipRemove = 'workspace_membership.remove';
|
||||||
|
case WorkspaceMembershipLastOwnerBlocked = 'workspace_membership.last_owner_blocked';
|
||||||
|
case WorkspaceMembershipBreakGlassAssignOwner = 'workspace_membership.break_glass.assign_owner';
|
||||||
|
|
||||||
case TenantMembershipAdd = 'tenant_membership.add';
|
case TenantMembershipAdd = 'tenant_membership.add';
|
||||||
case TenantMembershipRoleChange = 'tenant_membership.role_change';
|
case TenantMembershipRoleChange = 'tenant_membership.role_change';
|
||||||
case TenantMembershipRemove = 'tenant_membership.remove';
|
case TenantMembershipRemove = 'tenant_membership.remove';
|
||||||
@ -13,4 +19,14 @@ enum AuditActionId: string
|
|||||||
|
|
||||||
// Not part of the v1 contract, but used in codebase.
|
// Not part of the v1 contract, but used in codebase.
|
||||||
case TenantMembershipBootstrapRecover = 'tenant_membership.bootstrap_recover';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
66
app/Support/Audit/AuditContextSanitizer.php
Normal file
66
app/Support/Audit/AuditContextSanitizer.php
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
<?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,6 +15,21 @@ class Capabilities
|
|||||||
*/
|
*/
|
||||||
private static ?array $all = null;
|
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
|
// Tenants
|
||||||
public const TENANT_VIEW = 'tenant.view';
|
public const TENANT_VIEW = 'tenant.view';
|
||||||
|
|
||||||
|
|||||||
526
app/Support/Auth/UiEnforcement.php
Normal file
526
app/Support/Auth/UiEnforcement.php
Normal file
@ -0,0 +1,526 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Auth;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\RoleCapabilityMap;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use LogicException;
|
||||||
|
|
||||||
|
class UiEnforcement
|
||||||
|
{
|
||||||
|
private const TENANT_RESOLVER_FILAMENT = 'filament';
|
||||||
|
|
||||||
|
private const TENANT_RESOLVER_RECORD = 'record';
|
||||||
|
|
||||||
|
private const TENANT_RESOLVER_CUSTOM = 'custom';
|
||||||
|
|
||||||
|
private const BULK_PREFLIGHT_CAPABILITY = 'capability';
|
||||||
|
|
||||||
|
private const BULK_PREFLIGHT_TENANT_MEMBERSHIP = 'tenant_membership';
|
||||||
|
|
||||||
|
private const BULK_PREFLIGHT_CUSTOM = 'custom';
|
||||||
|
|
||||||
|
private bool $preserveVisibility = false;
|
||||||
|
|
||||||
|
private ?\Closure $businessVisible = null;
|
||||||
|
|
||||||
|
private ?\Closure $businessHidden = null;
|
||||||
|
|
||||||
|
private string $tenantResolverMode = self::TENANT_RESOLVER_FILAMENT;
|
||||||
|
|
||||||
|
private ?\Closure $customTenantResolver = null;
|
||||||
|
|
||||||
|
private string $bulkPreflightMode = self::BULK_PREFLIGHT_CAPABILITY;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var \Closure(Collection<int, Model>): bool|null
|
||||||
|
*/
|
||||||
|
private ?\Closure $bulkPreflight = null;
|
||||||
|
|
||||||
|
public function __construct(private string $capability)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function for(string $capability): self
|
||||||
|
{
|
||||||
|
return new self($capability);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function preserveVisibility(): self
|
||||||
|
{
|
||||||
|
if ($this->tenantResolverMode !== self::TENANT_RESOLVER_FILAMENT) {
|
||||||
|
throw new LogicException('preserveVisibility() is allowed only for tenant-scoped (tenantFromFilament) surfaces.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->preserveVisibility = true;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function andVisibleWhen(callable $businessVisible): self
|
||||||
|
{
|
||||||
|
$this->businessVisible = \Closure::fromCallable($businessVisible);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function andHiddenWhen(callable $businessHidden): self
|
||||||
|
{
|
||||||
|
$this->businessHidden = \Closure::fromCallable($businessHidden);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tenantFromFilament(): self
|
||||||
|
{
|
||||||
|
$this->tenantResolverMode = self::TENANT_RESOLVER_FILAMENT;
|
||||||
|
$this->customTenantResolver = null;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tenantFromRecord(): self
|
||||||
|
{
|
||||||
|
if ($this->preserveVisibility) {
|
||||||
|
throw new LogicException('preserveVisibility() is forbidden for record-scoped surfaces.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->tenantResolverMode = self::TENANT_RESOLVER_RECORD;
|
||||||
|
$this->customTenantResolver = null;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tenantFrom(callable $resolver): self
|
||||||
|
{
|
||||||
|
if ($this->preserveVisibility) {
|
||||||
|
throw new LogicException('preserveVisibility() is forbidden for record-scoped surfaces.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->tenantResolverMode = self::TENANT_RESOLVER_CUSTOM;
|
||||||
|
$this->customTenantResolver = \Closure::fromCallable($resolver);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom bulk authorization preflight for selection.
|
||||||
|
*
|
||||||
|
* Signature: fn (Collection<int, Model> $records): bool
|
||||||
|
*/
|
||||||
|
public function preflightSelection(callable $preflight): self
|
||||||
|
{
|
||||||
|
$this->bulkPreflightMode = self::BULK_PREFLIGHT_CUSTOM;
|
||||||
|
$this->bulkPreflight = \Closure::fromCallable($preflight);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function preflightByTenantMembership(): self
|
||||||
|
{
|
||||||
|
$this->bulkPreflightMode = self::BULK_PREFLIGHT_TENANT_MEMBERSHIP;
|
||||||
|
$this->bulkPreflight = null;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function preflightByCapability(): self
|
||||||
|
{
|
||||||
|
$this->bulkPreflightMode = self::BULK_PREFLIGHT_CAPABILITY;
|
||||||
|
$this->bulkPreflight = null;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function apply(Action $action): Action
|
||||||
|
{
|
||||||
|
$this->assertMixedVisibilityConfigIsValid();
|
||||||
|
|
||||||
|
if (! $this->preserveVisibility) {
|
||||||
|
$this->applyVisibility($action);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action->isBulk()) {
|
||||||
|
$action->disabled(function () use ($action): bool {
|
||||||
|
/** @var Collection<int, Model> $records */
|
||||||
|
$records = collect($action->getSelectedRecords());
|
||||||
|
|
||||||
|
return $this->bulkIsDisabled($records);
|
||||||
|
});
|
||||||
|
|
||||||
|
$action->tooltip(function () use ($action): ?string {
|
||||||
|
/** @var Collection<int, Model> $records */
|
||||||
|
$records = collect($action->getSelectedRecords());
|
||||||
|
|
||||||
|
return $this->bulkDisabledTooltip($records);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
$action->disabled(fn (?Model $record = null): bool => $this->isDisabled($record));
|
||||||
|
$action->tooltip(fn (?Model $record = null): ?string => $this->disabledTooltip($record));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $action;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAllowed(?Model $record = null): bool
|
||||||
|
{
|
||||||
|
return ! $this->isDisabled($record);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function authorizeOrAbort(?Model $record = null): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
abort_unless($user instanceof User, 403);
|
||||||
|
|
||||||
|
$tenant = $this->resolveTenant($record);
|
||||||
|
|
||||||
|
if (! ($tenant instanceof Tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
abort_unless($this->isMemberOfTenant($user, $tenant), 404);
|
||||||
|
abort_unless(Gate::forUser($user)->allows($this->capability, $tenant), 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side enforcement for bulk selections.
|
||||||
|
*
|
||||||
|
* - If any selected tenant is not a membership: 404 (deny-as-not-found).
|
||||||
|
* - If all are memberships but any lacks capability: 403.
|
||||||
|
*
|
||||||
|
* @param Collection<int, Model> $records
|
||||||
|
*/
|
||||||
|
public function authorizeBulkSelectionOrAbort(Collection $records): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
abort_unless($user instanceof User, 403);
|
||||||
|
|
||||||
|
$tenantIds = $this->resolveTenantIdsForRecords($records);
|
||||||
|
|
||||||
|
if ($tenantIds === []) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$membershipTenantIds = $this->membershipTenantIds($user, $tenantIds);
|
||||||
|
|
||||||
|
if (count($membershipTenantIds) !== count($tenantIds)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowedTenantIds = $this->capabilityTenantIds($user, $tenantIds);
|
||||||
|
|
||||||
|
if (count($allowedTenantIds) !== count($tenantIds)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public helper for evaluating bulk selection authorization decisions.
|
||||||
|
*
|
||||||
|
* @param Collection<int, Model> $records
|
||||||
|
*/
|
||||||
|
public function bulkSelectionIsAuthorized(User $user, Collection $records): bool
|
||||||
|
{
|
||||||
|
return $this->bulkSelectionIsAuthorizedInternal($user, $records);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyVisibility(Action $action): void
|
||||||
|
{
|
||||||
|
$canApplyMemberVisibility = ! ($action->isBulk() && $this->tenantResolverMode !== self::TENANT_RESOLVER_FILAMENT);
|
||||||
|
|
||||||
|
$businessVisible = $this->businessVisible;
|
||||||
|
$businessHidden = $this->businessHidden;
|
||||||
|
|
||||||
|
if ($businessVisible instanceof \Closure) {
|
||||||
|
$action->visible(function () use ($action, $businessVisible, $canApplyMemberVisibility): bool {
|
||||||
|
if (! (bool) $action->evaluate($businessVisible)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $canApplyMemberVisibility) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record = $action->getRecord();
|
||||||
|
|
||||||
|
return $this->isMember($record instanceof Model ? $record : null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($businessHidden instanceof \Closure) {
|
||||||
|
$action->hidden(function () use ($action, $businessHidden, $canApplyMemberVisibility): bool {
|
||||||
|
if ($canApplyMemberVisibility) {
|
||||||
|
$record = $action->getRecord();
|
||||||
|
|
||||||
|
if (! $this->isMember($record instanceof Model ? $record : null)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bool) $action->evaluate($businessHidden);
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $canApplyMemberVisibility) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! ($businessVisible instanceof \Closure)) {
|
||||||
|
$action->hidden(function () use ($action): bool {
|
||||||
|
$record = $action->getRecord();
|
||||||
|
|
||||||
|
return ! $this->isMember($record instanceof Model ? $record : null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertMixedVisibilityConfigIsValid(): void
|
||||||
|
{
|
||||||
|
if ($this->preserveVisibility && ($this->businessVisible instanceof \Closure || $this->businessHidden instanceof \Closure)) {
|
||||||
|
throw new LogicException('preserveVisibility() cannot be combined with andVisibleWhen()/andHiddenWhen().');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->preserveVisibility && $this->tenantResolverMode !== self::TENANT_RESOLVER_FILAMENT) {
|
||||||
|
throw new LogicException('preserveVisibility() is allowed only for tenant-scoped (tenantFromFilament) surfaces.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isDisabled(?Model $record = null): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! ($user instanceof User)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $this->resolveTenant($record);
|
||||||
|
|
||||||
|
if (! ($tenant instanceof Tenant)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->isMemberOfTenant($user, $tenant)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! Gate::forUser($user)->allows($this->capability, $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function disabledTooltip(?Model $record = null): ?string
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! ($user instanceof User)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $this->resolveTenant($record);
|
||||||
|
|
||||||
|
if (! ($tenant instanceof Tenant)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->isMemberOfTenant($user, $tenant)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Gate::forUser($user)->allows($this->capability, $tenant)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return UiTooltips::insufficientPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bulkIsDisabled(Collection $records): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! ($user instanceof User)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! $this->bulkSelectionIsAuthorizedInternal($user, $records);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bulkDisabledTooltip(Collection $records): ?string
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! ($user instanceof User)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->bulkSelectionIsAuthorizedInternal($user, $records)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return UiTooltips::insufficientPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bulkSelectionIsAuthorizedInternal(User $user, Collection $records): bool
|
||||||
|
{
|
||||||
|
if ($this->bulkPreflightMode === self::BULK_PREFLIGHT_CUSTOM && $this->bulkPreflight instanceof \Closure) {
|
||||||
|
return (bool) ($this->bulkPreflight)($records);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantIds = $this->resolveTenantIdsForRecords($records);
|
||||||
|
|
||||||
|
if ($tenantIds === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($this->bulkPreflightMode) {
|
||||||
|
self::BULK_PREFLIGHT_TENANT_MEMBERSHIP => count($this->membershipTenantIds($user, $tenantIds)) === count($tenantIds),
|
||||||
|
self::BULK_PREFLIGHT_CAPABILITY => count($this->capabilityTenantIds($user, $tenantIds)) === count($tenantIds),
|
||||||
|
default => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, Model> $records
|
||||||
|
* @return array<int>
|
||||||
|
*/
|
||||||
|
private function resolveTenantIdsForRecords(Collection $records): array
|
||||||
|
{
|
||||||
|
if ($this->tenantResolverMode === self::TENANT_RESOLVER_FILAMENT) {
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant ? [(int) $tenant->getKey()] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->tenantResolverMode === self::TENANT_RESOLVER_RECORD) {
|
||||||
|
$ids = $records
|
||||||
|
->filter(fn (Model $record): bool => $record instanceof Tenant)
|
||||||
|
->map(fn (Tenant $tenant): int => (int) $tenant->getKey())
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return array_values(array_unique($ids));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->tenantResolverMode === self::TENANT_RESOLVER_CUSTOM && $this->customTenantResolver instanceof \Closure) {
|
||||||
|
$ids = [];
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
if (! ($record instanceof Model)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolved = ($this->customTenantResolver)($record);
|
||||||
|
|
||||||
|
if ($resolved instanceof Tenant) {
|
||||||
|
$ids[] = (int) $resolved->getKey();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_int($resolved)) {
|
||||||
|
$ids[] = $resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($ids));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isMember(?Model $record = null): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! ($user instanceof User)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $this->resolveTenant($record);
|
||||||
|
|
||||||
|
if (! ($tenant instanceof Tenant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->isMemberOfTenant($user, $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isMemberOfTenant(User $user, Tenant $tenant): bool
|
||||||
|
{
|
||||||
|
return Gate::forUser($user)->allows(Capabilities::TENANT_VIEW, $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveTenant(?Model $record = null): ?Tenant
|
||||||
|
{
|
||||||
|
return match ($this->tenantResolverMode) {
|
||||||
|
self::TENANT_RESOLVER_FILAMENT => Filament::getTenant() instanceof Tenant ? Filament::getTenant() : null,
|
||||||
|
self::TENANT_RESOLVER_RECORD => $record instanceof Tenant ? $record : null,
|
||||||
|
self::TENANT_RESOLVER_CUSTOM => $this->resolveTenantViaCustomResolver($record),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveTenantViaCustomResolver(?Model $record): ?Tenant
|
||||||
|
{
|
||||||
|
if (! ($this->customTenantResolver instanceof \Closure)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! ($record instanceof Model)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolved = ($this->customTenantResolver)($record);
|
||||||
|
|
||||||
|
if ($resolved instanceof Tenant) {
|
||||||
|
return $resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int> $tenantIds
|
||||||
|
* @return array<int>
|
||||||
|
*/
|
||||||
|
private function membershipTenantIds(User $user, array $tenantIds): array
|
||||||
|
{
|
||||||
|
/** @var array<int> $ids */
|
||||||
|
$ids = DB::table('tenant_memberships')
|
||||||
|
->where('user_id', (int) $user->getKey())
|
||||||
|
->whereIn('tenant_id', $tenantIds)
|
||||||
|
->pluck('tenant_id')
|
||||||
|
->map(fn ($id): int => (int) $id)
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return array_values(array_unique($ids));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int> $tenantIds
|
||||||
|
* @return array<int>
|
||||||
|
*/
|
||||||
|
private function capabilityTenantIds(User $user, array $tenantIds): array
|
||||||
|
{
|
||||||
|
$roles = RoleCapabilityMap::rolesWithCapability($this->capability);
|
||||||
|
|
||||||
|
if ($roles === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array<int> $ids */
|
||||||
|
$ids = DB::table('tenant_memberships')
|
||||||
|
->where('user_id', (int) $user->getKey())
|
||||||
|
->whereIn('tenant_id', $tenantIds)
|
||||||
|
->whereIn('role', $roles)
|
||||||
|
->pluck('tenant_id')
|
||||||
|
->map(fn ($id): int => (int) $id)
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return array_values(array_unique($ids));
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/Support/Auth/UiTooltips.php
Normal file
14
app/Support/Auth/UiTooltips.php
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Auth;
|
||||||
|
|
||||||
|
class UiTooltips
|
||||||
|
{
|
||||||
|
public const INSUFFICIENT_PERMISSION_ASK_OWNER = 'Insufficient permission — ask a tenant Owner.';
|
||||||
|
|
||||||
|
public static function insufficientPermission(): string
|
||||||
|
{
|
||||||
|
return self::INSUFFICIENT_PERMISSION_ASK_OWNER;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
11
app/Support/Auth/WorkspaceRole.php
Normal file
11
app/Support/Auth/WorkspaceRole.php
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Auth;
|
||||||
|
|
||||||
|
enum WorkspaceRole: string
|
||||||
|
{
|
||||||
|
case Owner = 'owner';
|
||||||
|
case Manager = 'manager';
|
||||||
|
case Operator = 'operator';
|
||||||
|
case Readonly = 'readonly';
|
||||||
|
}
|
||||||
@ -36,6 +36,9 @@ final class BadgeCatalog
|
|||||||
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,
|
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,
|
||||||
BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class,
|
BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class,
|
||||||
BadgeDomain::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::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,4 +28,7 @@ enum BadgeDomain: string
|
|||||||
case RestoreResultStatus = 'restore_result_status';
|
case RestoreResultStatus = 'restore_result_status';
|
||||||
case ProviderConnectionStatus = 'provider_connection.status';
|
case ProviderConnectionStatus = 'provider_connection.status';
|
||||||
case ProviderConnectionHealth = 'provider_connection.health';
|
case ProviderConnectionHealth = 'provider_connection.health';
|
||||||
|
case VerificationCheckStatus = 'verification_check_status';
|
||||||
|
case VerificationCheckSeverity = 'verification_check_severity';
|
||||||
|
case VerificationReportOverall = 'verification_report_overall';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ public function spec(mixed $value): BadgeSpec
|
|||||||
$state = BadgeCatalog::normalizeState($value);
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
return match ($state) {
|
return match ($state) {
|
||||||
|
'pending' => new BadgeSpec('Pending', 'warning', 'heroicon-m-clock'),
|
||||||
'active' => new BadgeSpec('Active', 'success', 'heroicon-m-check-circle'),
|
'active' => new BadgeSpec('Active', 'success', 'heroicon-m-check-circle'),
|
||||||
'inactive' => new BadgeSpec('Inactive', 'gray', 'heroicon-m-minus-circle'),
|
'inactive' => new BadgeSpec('Inactive', 'gray', 'heroicon-m-minus-circle'),
|
||||||
'archived' => new BadgeSpec('Archived', 'gray', 'heroicon-m-minus-circle'),
|
'archived' => new BadgeSpec('Archived', 'gray', 'heroicon-m-minus-circle'),
|
||||||
|
|||||||
@ -0,0 +1,25 @@
|
|||||||
|
<?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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Support/Badges/Domains/VerificationCheckStatusBadge.php
Normal file
25
app/Support/Badges/Domains/VerificationCheckStatusBadge.php
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<?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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
<?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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
189
app/Support/Middleware/EnsureFilamentTenantSelected.php
Normal file
189
app/Support/Middleware/EnsureFilamentTenantSelected.php
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
<?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,6 +8,7 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\UiTooltips as AuthUiTooltips;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\BulkAction;
|
use Filament\Actions\BulkAction;
|
||||||
@ -282,7 +283,7 @@ private function applyDisabledState(): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tooltip = $this->customTooltip ?? UiTooltips::INSUFFICIENT_PERMISSION;
|
$tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission();
|
||||||
|
|
||||||
$this->action->disabled(function (?Model $record = null) {
|
$this->action->disabled(function (?Model $record = null) {
|
||||||
$context = $this->resolveContextWithRecord($record);
|
$context = $this->resolveContextWithRecord($record);
|
||||||
|
|||||||
@ -19,7 +19,7 @@ final class UiTooltips
|
|||||||
* Tooltip shown when a member lacks the required capability.
|
* Tooltip shown when a member lacks the required capability.
|
||||||
* Intentionally vague to avoid leaking permission structure.
|
* Intentionally vague to avoid leaking permission structure.
|
||||||
*/
|
*/
|
||||||
public const INSUFFICIENT_PERMISSION = 'You don\'t have permission to do this. Ask a tenant admin.';
|
public const INSUFFICIENT_PERMISSION = 'Insufficient permission — ask a tenant Owner.';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Modal heading for destructive action confirmation.
|
* Modal heading for destructive action confirmation.
|
||||||
@ -30,4 +30,14 @@ final class UiTooltips
|
|||||||
* Modal description for destructive action confirmation.
|
* Modal description for destructive action confirmation.
|
||||||
*/
|
*/
|
||||||
public const DESTRUCTIVE_CONFIRM_DESCRIPTION = 'This action cannot be undone.';
|
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.';
|
||||||
}
|
}
|
||||||
|
|||||||
45
app/Support/Rbac/WorkspaceAccessContext.php
Normal file
45
app/Support/Rbac/WorkspaceAccessContext.php
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
230
app/Support/Rbac/WorkspaceUiEnforcement.php
Normal file
230
app/Support/Rbac/WorkspaceUiEnforcement.php
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Support/Verification/VerificationCheckSeverity.php
Normal file
20
app/Support/Verification/VerificationCheckSeverity.php
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<?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());
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Support/Verification/VerificationCheckStatus.php
Normal file
20
app/Support/Verification/VerificationCheckStatus.php
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<?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());
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Support/Verification/VerificationReportOverall.php
Normal file
19
app/Support/Verification/VerificationReportOverall.php
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?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());
|
||||||
|
}
|
||||||
|
}
|
||||||
358
app/Support/Verification/VerificationReportSanitizer.php
Normal file
358
app/Support/Verification/VerificationReportSanitizer.php
Normal file
@ -0,0 +1,358 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
235
app/Support/Verification/VerificationReportSchema.php
Normal file
235
app/Support/Verification/VerificationReportSchema.php
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
343
app/Support/Verification/VerificationReportWriter.php
Normal file
343
app/Support/Verification/VerificationReportWriter.php
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
129
app/Support/Workspaces/WorkspaceContext.php
Normal file
129
app/Support/Workspaces/WorkspaceContext.php
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Support/Workspaces/WorkspaceResolver.php
Normal file
25
app/Support/Workspaces/WorkspaceResolver.php
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<?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,12 +14,17 @@
|
|||||||
$middleware->alias([
|
$middleware->alias([
|
||||||
'ensure-correct-guard' => \App\Http\Middleware\EnsureCorrectGuard::class,
|
'ensure-correct-guard' => \App\Http\Middleware\EnsureCorrectGuard::class,
|
||||||
'ensure-platform-capability' => \App\Http\Middleware\EnsurePlatformCapability::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(
|
$middleware->prependToPriorityList(
|
||||||
\Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class,
|
\Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class,
|
||||||
\App\Http\Middleware\EnsureCorrectGuard::class,
|
\App\Http\Middleware\EnsureCorrectGuard::class,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$middleware->redirectGuestsTo('/admin/login');
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
//
|
//
|
||||||
|
|||||||
27
database/factories/WorkspaceFactory.php
Normal file
27
database/factories/WorkspaceFactory.php
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<?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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
25
database/factories/WorkspaceMembershipFactory.php
Normal file
25
database/factories/WorkspaceMembershipFactory.php
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<?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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
<?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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
<?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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,164 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,142 @@
|
|||||||
|
<?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 {}
|
||||||
|
};
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
<?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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,128 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,178 @@
|
|||||||
|
@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,37 +1,60 @@
|
|||||||
<x-filament::section>
|
<x-filament-panels::page>
|
||||||
<div class="flex flex-col gap-4">
|
<x-filament::section>
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
<div class="flex flex-col gap-4">
|
||||||
Select a tenant to continue.
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
</div>
|
Select a tenant to continue.
|
||||||
|
|
||||||
@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>
|
</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
|
@php
|
||||||
type="button"
|
$tenants = $this->getTenants();
|
||||||
color="primary"
|
@endphp
|
||||||
wire:click="selectTenant({{ (int) $tenant->id }})"
|
|
||||||
>
|
@if ($tenants->isEmpty())
|
||||||
Continue
|
<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">
|
||||||
</x-filament::button>
|
<div class="font-medium text-gray-900 dark:text-gray-100">No tenants are available for your account.</div>
|
||||||
</div>
|
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Switch workspaces, or contact an administrator.
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
|
||||||
</div>
|
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
|
||||||
@endif
|
<x-filament::button
|
||||||
</div>
|
type="button"
|
||||||
</x-filament::section>
|
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
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
</x-filament-panels::page>
|
||||||
|
|||||||
70
resources/views/filament/pages/choose-workspace.blade.php
Normal file
70
resources/views/filament/pages/choose-workspace.blade.php
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
<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,11 +1,13 @@
|
|||||||
<x-filament::section>
|
<x-filament-panels::page>
|
||||||
<div class="flex flex-col gap-3">
|
<x-filament::section>
|
||||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
<div class="flex flex-col gap-3">
|
||||||
You don’t have access to any tenants yet.
|
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
</div>
|
You don’t have access to any tenants yet.
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
Ask an administrator to add you to a tenant, then sign in again.
|
Ask an administrator to add you to a tenant, then sign in again.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</x-filament::section>
|
||||||
</x-filament::section>
|
</x-filament-panels::page>
|
||||||
|
|||||||
31
resources/views/filament/pages/tenant-diagnostics.blade.php
Normal file
31
resources/views/filament/pages/tenant-diagnostics.blade.php
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<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>
|
||||||
@ -0,0 +1,170 @@
|
|||||||
|
<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>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user