427 lines
14 KiB
PHP
427 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\OperateHub;
|
|
|
|
use App\Filament\Pages\TenantDashboard;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Services\Auth\CapabilityResolver;
|
|
use App\Services\Tenants\TenantOperabilityService;
|
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
|
use App\Support\Tenants\TenantPageCategory;
|
|
use App\Support\Workspaces\WorkspaceContext;
|
|
use Filament\Actions\Action;
|
|
use Filament\Facades\Filament;
|
|
use Illuminate\Http\Request;
|
|
|
|
final class OperateHubShell
|
|
{
|
|
private const string REQUEST_ATTRIBUTE = 'tenantpilot.resolved_shell_context';
|
|
|
|
public function __construct(
|
|
private WorkspaceContext $workspaceContext,
|
|
private CapabilityResolver $capabilityResolver,
|
|
private TenantOperabilityService $tenantOperabilityService,
|
|
) {}
|
|
|
|
public function scopeLabel(?Request $request = null): string
|
|
{
|
|
$activeTenant = $this->activeEntitledTenant($request);
|
|
|
|
if ($activeTenant instanceof Tenant) {
|
|
return 'Tenant scope: '.$activeTenant->name;
|
|
}
|
|
|
|
return 'All tenants';
|
|
}
|
|
|
|
/**
|
|
* @return array{label: string, url: string}|null
|
|
*/
|
|
public function returnAffordance(?Request $request = null): ?array
|
|
{
|
|
$activeTenant = $this->activeEntitledTenant($request);
|
|
|
|
if ($activeTenant instanceof Tenant) {
|
|
return [
|
|
'label' => 'Back to '.$activeTenant->name,
|
|
'url' => TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant),
|
|
];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @return array<Action>
|
|
*/
|
|
public function headerActions(
|
|
string $scopeActionName = 'operate_hub_scope',
|
|
string $returnActionName = 'operate_hub_return',
|
|
?Request $request = null,
|
|
): array {
|
|
$actions = [
|
|
Action::make($scopeActionName)
|
|
->label($this->scopeLabel($request))
|
|
->color('gray')
|
|
->disabled(),
|
|
];
|
|
|
|
$returnAffordance = $this->returnAffordance($request);
|
|
|
|
if (is_array($returnAffordance)) {
|
|
$actions[] = Action::make($returnActionName)
|
|
->label($returnAffordance['label'])
|
|
->icon('heroicon-o-arrow-left')
|
|
->color('gray')
|
|
->url($returnAffordance['url']);
|
|
}
|
|
|
|
return $actions;
|
|
}
|
|
|
|
public function activeEntitledTenant(?Request $request = null): ?Tenant
|
|
{
|
|
return $this->resolvedContext($request)->tenant;
|
|
}
|
|
|
|
public function tenantOwnedPanelContext(?Request $request = null): ?Tenant
|
|
{
|
|
return $this->activeEntitledTenant($request);
|
|
}
|
|
|
|
public function resolvedContext(?Request $request = null): ResolvedShellContext
|
|
{
|
|
$request ??= request();
|
|
|
|
if ($request instanceof Request) {
|
|
$cached = $request->attributes->get(self::REQUEST_ATTRIBUTE);
|
|
|
|
if ($cached instanceof ResolvedShellContext) {
|
|
return $cached;
|
|
}
|
|
}
|
|
|
|
$resolved = $this->buildResolvedContext($request);
|
|
|
|
if ($request instanceof Request) {
|
|
$request->attributes->set(self::REQUEST_ATTRIBUTE, $resolved);
|
|
}
|
|
|
|
return $resolved;
|
|
}
|
|
|
|
private function buildResolvedContext(?Request $request = null): ResolvedShellContext
|
|
{
|
|
$pageCategory = $this->pageCategory($request);
|
|
$routeTenantCandidate = $this->resolveRouteTenantCandidate($request, $pageCategory);
|
|
$sessionWorkspace = $this->workspaceContext->currentWorkspace($request);
|
|
$workspace = $this->workspaceContext->currentWorkspaceOrTenantWorkspace($routeTenantCandidate, $request);
|
|
|
|
$workspaceSource = match (true) {
|
|
$workspace instanceof Workspace && $sessionWorkspace instanceof Workspace && $workspace->is($sessionWorkspace) => 'session_workspace',
|
|
$workspace instanceof Workspace && $routeTenantCandidate instanceof Tenant && (int) $routeTenantCandidate->workspace_id === (int) $workspace->getKey() => 'route',
|
|
default => 'none',
|
|
};
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
return new ResolvedShellContext(
|
|
workspace: null,
|
|
tenant: null,
|
|
pageCategory: $pageCategory,
|
|
state: 'missing_workspace',
|
|
displayMode: 'recovery',
|
|
recoveryAction: $pageCategory === TenantPageCategory::WorkspaceChooserException ? 'none' : 'redirect_choose_workspace',
|
|
recoveryDestination: '/admin/choose-workspace',
|
|
recoveryReason: 'missing_workspace',
|
|
);
|
|
}
|
|
|
|
$routeTenant = $this->resolveValidatedRouteTenant($routeTenantCandidate, $workspace, $request, $pageCategory);
|
|
|
|
if ($routeTenant['tenant'] instanceof Tenant) {
|
|
return new ResolvedShellContext(
|
|
workspace: $workspace,
|
|
tenant: $routeTenant['tenant'],
|
|
pageCategory: $pageCategory,
|
|
state: 'tenant_scoped',
|
|
displayMode: 'tenant_scoped',
|
|
workspaceSource: $workspaceSource,
|
|
tenantSource: 'route',
|
|
);
|
|
}
|
|
|
|
$recoveryReason = $routeTenant['reason'];
|
|
|
|
if ($pageCategory === TenantPageCategory::TenantBound && $recoveryReason !== null) {
|
|
return new ResolvedShellContext(
|
|
workspace: $workspace,
|
|
tenant: null,
|
|
pageCategory: $pageCategory,
|
|
state: 'invalid_tenant',
|
|
displayMode: 'recovery',
|
|
workspaceSource: $workspaceSource,
|
|
recoveryAction: 'abort_not_found',
|
|
recoveryReason: $recoveryReason,
|
|
);
|
|
}
|
|
|
|
$queryHintTenant = $this->resolveValidatedQueryHintTenant($request, $workspace, $pageCategory);
|
|
|
|
if ($queryHintTenant['tenant'] instanceof Tenant) {
|
|
return new ResolvedShellContext(
|
|
workspace: $workspace,
|
|
tenant: $queryHintTenant['tenant'],
|
|
pageCategory: $pageCategory,
|
|
state: 'tenant_scoped',
|
|
displayMode: 'tenant_scoped',
|
|
workspaceSource: $workspaceSource,
|
|
tenantSource: 'query_hint',
|
|
);
|
|
}
|
|
|
|
$recoveryReason ??= $queryHintTenant['reason'];
|
|
|
|
$tenant = $this->resolveValidatedFilamentTenant($request, $pageCategory, $workspace);
|
|
|
|
if ($tenant instanceof Tenant) {
|
|
return new ResolvedShellContext(
|
|
workspace: $workspace,
|
|
tenant: $tenant,
|
|
pageCategory: $pageCategory,
|
|
state: 'tenant_scoped',
|
|
displayMode: 'tenant_scoped',
|
|
workspaceSource: $workspaceSource,
|
|
tenantSource: 'filament_tenant',
|
|
);
|
|
}
|
|
|
|
if ($pageCategory->allowsRememberedTenantRestore()) {
|
|
$rememberedTenant = $this->workspaceContext->rememberedTenant($request);
|
|
|
|
if ($rememberedTenant instanceof Tenant) {
|
|
return new ResolvedShellContext(
|
|
workspace: $workspace,
|
|
tenant: $rememberedTenant,
|
|
pageCategory: $pageCategory,
|
|
state: 'tenant_scoped',
|
|
displayMode: 'tenant_scoped',
|
|
workspaceSource: $workspaceSource,
|
|
tenantSource: 'remembered',
|
|
);
|
|
}
|
|
}
|
|
|
|
if ($pageCategory->requiresExplicitTenant()) {
|
|
return new ResolvedShellContext(
|
|
workspace: $workspace,
|
|
tenant: null,
|
|
pageCategory: $pageCategory,
|
|
state: 'missing_tenant',
|
|
displayMode: 'recovery',
|
|
workspaceSource: $workspaceSource,
|
|
recoveryAction: $pageCategory === TenantPageCategory::TenantScopedEvidence
|
|
? 'redirect_evidence_overview'
|
|
: 'abort_not_found',
|
|
recoveryDestination: $pageCategory === TenantPageCategory::TenantScopedEvidence
|
|
? '/admin/evidence/overview'
|
|
: null,
|
|
recoveryReason: $recoveryReason ?? 'missing_tenant',
|
|
);
|
|
}
|
|
|
|
return new ResolvedShellContext(
|
|
workspace: $workspace,
|
|
tenant: null,
|
|
pageCategory: $pageCategory,
|
|
state: 'tenantless_workspace',
|
|
displayMode: 'tenantless',
|
|
workspaceSource: $workspaceSource,
|
|
recoveryReason: $recoveryReason,
|
|
);
|
|
}
|
|
|
|
private function resolveValidatedFilamentTenant(
|
|
?Request $request = null,
|
|
?TenantPageCategory $pageCategory = null,
|
|
?Workspace $workspace = null,
|
|
): ?Tenant {
|
|
$tenant = Filament::getTenant();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
return null;
|
|
}
|
|
|
|
$pageCategory ??= $this->pageCategory($request);
|
|
$workspace ??= $this->workspaceContext->currentWorkspaceOrTenantWorkspace($tenant, $request);
|
|
|
|
if ($workspace instanceof Workspace && $this->tenantValidationReason($tenant, $workspace, $request, $pageCategory) === null) {
|
|
return $tenant;
|
|
}
|
|
|
|
Filament::setTenant(null, true);
|
|
|
|
return null;
|
|
}
|
|
|
|
private function resolveValidatedRouteTenant(
|
|
?Tenant $tenant,
|
|
Workspace $workspace,
|
|
?Request $request = null,
|
|
?TenantPageCategory $pageCategory = null,
|
|
): array {
|
|
$pageCategory ??= $this->pageCategory($request);
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
return ['tenant' => null, 'reason' => null];
|
|
}
|
|
|
|
$reason = $this->tenantValidationReason($tenant, $workspace, $request, $pageCategory);
|
|
|
|
if ($reason !== null) {
|
|
return ['tenant' => null, 'reason' => $reason];
|
|
}
|
|
|
|
return ['tenant' => $tenant, 'reason' => null];
|
|
}
|
|
|
|
private function resolveValidatedQueryHintTenant(
|
|
?Request $request,
|
|
Workspace $workspace,
|
|
TenantPageCategory $pageCategory,
|
|
): array {
|
|
if (! $pageCategory->allowsQueryTenantHints()) {
|
|
return ['tenant' => null, 'reason' => null];
|
|
}
|
|
|
|
$queryTenant = $this->resolveQueryTenantHint($request);
|
|
|
|
if (! $queryTenant instanceof Tenant) {
|
|
return ['tenant' => null, 'reason' => null];
|
|
}
|
|
|
|
$reason = $this->tenantValidationReason($queryTenant, $workspace, $request, $pageCategory);
|
|
|
|
if ($reason !== null) {
|
|
return ['tenant' => null, 'reason' => $reason];
|
|
}
|
|
|
|
return ['tenant' => $queryTenant, 'reason' => null];
|
|
}
|
|
|
|
private function resolveRouteTenantCandidate(?Request $request = null, ?TenantPageCategory $pageCategory = null): ?Tenant
|
|
{
|
|
$route = $request?->route();
|
|
$pageCategory ??= $this->pageCategory($request);
|
|
|
|
if ($route?->hasParameter('tenant')) {
|
|
return $this->resolveTenantIdentifier($route->parameter('tenant'));
|
|
}
|
|
|
|
if (
|
|
$pageCategory !== TenantPageCategory::TenantBound
|
|
|| ! $route?->hasParameter('record')
|
|
|| ! str_starts_with((string) ($route->getName() ?? ''), 'filament.admin.resources.tenants.')
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return $this->resolveTenantIdentifier($route->parameter('record'));
|
|
}
|
|
|
|
private function resolveQueryTenantHint(?Request $request = null): ?Tenant
|
|
{
|
|
$queryTenant = $request?->query('tenant');
|
|
|
|
if (filled($queryTenant)) {
|
|
return $this->resolveTenantIdentifier($queryTenant);
|
|
}
|
|
|
|
$queryTenantId = $request?->query('tenant_id');
|
|
|
|
if (filled($queryTenantId)) {
|
|
return $this->resolveTenantIdentifier($queryTenantId);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function resolveTenantIdentifier(mixed $tenantIdentifier): ?Tenant
|
|
{
|
|
if ($tenantIdentifier instanceof Tenant) {
|
|
return $tenantIdentifier;
|
|
}
|
|
|
|
$tenantIdentifier = trim((string) $tenantIdentifier);
|
|
|
|
if ($tenantIdentifier === '') {
|
|
return null;
|
|
}
|
|
|
|
$tenantKeyColumn = (new Tenant)->getQualifiedKeyName();
|
|
|
|
return Tenant::query()
|
|
->withTrashed()
|
|
->where(static function ($query) use ($tenantIdentifier, $tenantKeyColumn): void {
|
|
$query->where('external_id', $tenantIdentifier);
|
|
|
|
if (ctype_digit($tenantIdentifier)) {
|
|
$query->orWhere($tenantKeyColumn, (int) $tenantIdentifier);
|
|
}
|
|
})
|
|
->first();
|
|
}
|
|
|
|
private function tenantValidationReason(
|
|
Tenant $tenant,
|
|
Workspace $workspace,
|
|
?Request $request = null,
|
|
?TenantPageCategory $pageCategory = null,
|
|
): ?string {
|
|
$pageCategory ??= $this->pageCategory($request);
|
|
|
|
if ((int) $tenant->workspace_id !== (int) $workspace->getKey()) {
|
|
return 'mismatched_workspace';
|
|
}
|
|
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
return 'not_member';
|
|
}
|
|
|
|
if (! $this->capabilityResolver->isMember($user, $tenant)) {
|
|
return 'not_member';
|
|
}
|
|
|
|
$question = $pageCategory === TenantPageCategory::TenantBound
|
|
? TenantOperabilityQuestion::TenantBoundViewability
|
|
: TenantOperabilityQuestion::AdministrativeDiscoverability;
|
|
|
|
$allowed = $this->tenantOperabilityService->outcomeFor(
|
|
tenant: $tenant,
|
|
question: $question,
|
|
actor: $user,
|
|
workspaceId: (int) $workspace->getKey(),
|
|
lane: $pageCategory->lane(),
|
|
selectedTenant: Filament::getTenant() instanceof Tenant ? Filament::getTenant() : null,
|
|
)->allowed;
|
|
|
|
if ($allowed) {
|
|
return null;
|
|
}
|
|
|
|
return $pageCategory === TenantPageCategory::TenantBound
|
|
? 'inaccessible'
|
|
: 'not_operable';
|
|
}
|
|
|
|
private function pageCategory(?Request $request = null): TenantPageCategory
|
|
{
|
|
return TenantPageCategory::fromRequest($request);
|
|
}
|
|
}
|