action = $action; } /** * Create enforcement for a header/page action. * * @param Action $action The Filament action to wrap */ public static function forAction(Action $action): self { return new self($action); } /** * Create enforcement for a table row action. * * @param Action $action The Filament action to wrap * @param Model|Closure $record The record or a closure that returns the record */ public static function forTableAction(Action $action, Model|Closure $record): self { $instance = new self($action); $instance->record = $record; return $instance; } /** * Create enforcement for a bulk action with all-or-nothing semantics. * * If any selected record fails the capability check for a member, * the action is disabled entirely. * * @param BulkAction $action The Filament bulk action to wrap */ public static function forBulkAction(BulkAction $action): self { $instance = new self($action); $instance->isBulk = true; return $instance; } /** * Require tenant membership for this action. * * @param bool $require Whether membership is required (default: true) */ public function requireMembership(bool $require = true): self { $this->requireMembership = $require; return $this; } /** * Require a specific capability for this action. * * @param string $capability A capability constant from Capabilities class * * @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; } /** * Mark this action as destructive (requires confirmation modal). */ public function destructive(): self { $this->isDestructive = true; return $this; } /** * Override the default tooltip for disabled actions. * * @param string $message Custom tooltip message */ public function tooltip(string $message): self { $this->customTooltip = $message; return $this; } /** * Preserve the action's existing visibility logic. * * Use this when the action already has business-logic visibility * (e.g., `->visible(fn ($record) => $record->trashed())`) that should be kept. * * UiEnforcement will combine the existing visibility condition with tenant * membership visibility, instead of overwriting it. * * @return $this */ public function preserveVisibility(): self { $this->preserveExistingVisibility = true; return $this; } /** * Apply all enforcement rules to the action and return it. * * This sets up: * - UI visibility (hidden for non-members) * - UI disabled state + tooltip (for members without capability) * - Destructive confirmation (if marked) * - Server-side guards (404/403) * * @return Action|BulkAction The configured action */ public function apply(): Action|BulkAction { $this->applyVisibility(); $this->applyDisabledState(); $this->applyDestructiveConfirmation(); $this->applyServerSideGuard(); return $this->action; } /** * Hide action for non-members. * * Skipped if preserveVisibility() was called. */ private function applyVisibility(): void { if (! $this->requireMembership) { return; } $existingVisibility = $this->preserveExistingVisibility ? $this->getExistingVisibilityCondition() : null; $this->action->visible(function (?Model $record = null) use ($existingVisibility) { $context = $this->resolveContextWithRecord($record); if (! $context->isMember) { return false; } if ($existingVisibility === null) { return true; } return $this->evaluateVisibilityCondition($existingVisibility, $record); }); } /** * Attempt to retrieve the existing visibility condition from the action. * * Filament stores this as the protected property `$isVisible` (bool|Closure) * on actions via the CanBeHidden concern. */ private function getExistingVisibilityCondition(): bool|Closure|null { try { $ref = new ReflectionObject($this->action); if (! $ref->hasProperty('isVisible')) { return null; } $property = $ref->getProperty('isVisible'); $property->setAccessible(true); /** @var bool|Closure $value */ $value = $property->getValue($this->action); return $value; } catch (Throwable) { return null; } } /** * Evaluate an existing bool|Closure visibility condition. * * This is a best-effort evaluator for business visibility closures. * If the closure cannot be evaluated safely, we fail closed (return false). */ private function evaluateVisibilityCondition(bool|Closure $condition, ?Model $record): bool { if (is_bool($condition)) { return $condition; } try { $reflection = new \ReflectionFunction($condition); $parameters = $reflection->getParameters(); if ($parameters === []) { return (bool) $condition(); } if ($record === null) { return false; } return (bool) $condition($record); } catch (Throwable) { return false; } } /** * Disable action for members without capability. */ private function applyDisabledState(): void { if ($this->capability === null) { return; } $tooltip = $this->customTooltip ?? UiTooltips::INSUFFICIENT_PERMISSION; $this->action->disabled(function (?Model $record = null) { $context = $this->resolveContextWithRecord($record); // Non-members are hidden, so this only affects members if (! $context->isMember) { return true; } return ! $context->hasCapability; }); // Only show tooltip when actually disabled $this->action->tooltip(function (?Model $record = null) use ($tooltip) { $context = $this->resolveContextWithRecord($record); if ($context->isMember && ! $context->hasCapability) { return $tooltip; } return null; }); } /** * Add confirmation modal for destructive actions. */ 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); } /** * Wrap the action handler with server-side authorization guard. * * This is a defense-in-depth measure. In normal operation, Filament's * isDisabled() check prevents execution. This guard catches edge cases * where the disabled check might be bypassed. */ private function applyServerSideGuard(): void { $this->action->before(function (?Model $record = null): void { $context = $this->resolveContextWithRecord($record); // Non-member → 404 (deny-as-not-found) if ($context->shouldDenyAsNotFound()) { abort(404); } // Member without capability → 403 (forbidden) if ($context->shouldDenyAsForbidden()) { abort(403); } }); } /** * Resolve the current access context with an optional record. */ private function resolveContextWithRecord(?Model $record = null): TenantAccessContext { $user = auth()->user(); // For table actions, resolve the record and use it as tenant if it's a Tenant $tenant = $this->resolveTenantWithRecord($record); if (! $user instanceof User || ! $tenant instanceof Tenant) { return new TenantAccessContext( user: null, tenant: null, isMember: false, hasCapability: false, ); } /** @var CapabilityResolver $resolver */ $resolver = app(CapabilityResolver::class); $isMember = $resolver->isMember($user, $tenant); $hasCapability = true; if ($this->capability !== null && $isMember) { $hasCapability = $resolver->can($user, $tenant, $this->capability); } return new TenantAccessContext( user: $user, tenant: $tenant, isMember: $isMember, hasCapability: $hasCapability, ); } /** * Resolve the tenant for this action with an optional record. * * Priority: * 1. If $record is passed and is a Tenant, use it * 2. If $this->record is set (for forTableAction), resolve it * 3. Fall back to Filament::getTenant() */ private function resolveTenantWithRecord(?Model $record = null): ?Tenant { // If a record is passed directly (from closure parameter), check if it's a Tenant if ($record instanceof Tenant) { return $record; } // If a record is set from forTableAction, try to resolve it if ($this->record !== null) { $resolved = $this->record instanceof Closure ? ($this->record)() : $this->record; if ($resolved instanceof Tenant) { return $resolved; } } // Default: use Filament's current tenant return Filament::getTenant(); } }