Implements Spec 110 Ops‑UX Enforcement and applies the repo‑wide “enterprise” standard for operation start + dedup surfaces. Key points - Start surfaces: only ephemeral queued toast (no DB notifications for started/queued/running). - Dedup paths: canonical “already queued” toast. - Progress refresh: dispatch run-enqueued browser event so the global widget updates immediately. - Completion: exactly-once terminal DB notification on completion (per Ops‑UX contract). Tests & formatting - Full suite: 1738 passed, 8 skipped (8477 assertions). - Pint: `vendor/bin/sail bin pint --dirty --format agent` (pass). Notable change - Removed legacy `RunStatusChangedNotification` (replaced by the terminal-only completion notification policy). Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #134
1190 lines
53 KiB
PHP
1190 lines
53 KiB
PHP
<?php
|
|
|
|
namespace App\Filament\Resources;
|
|
|
|
use App\Filament\Resources\ProviderConnectionResource\Pages;
|
|
use App\Jobs\ProviderComplianceSnapshotJob;
|
|
use App\Jobs\ProviderInventorySyncJob;
|
|
use App\Models\OperationRun;
|
|
use App\Models\ProviderConnection;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\Auth\CapabilityResolver;
|
|
use App\Services\Intune\AuditLogger;
|
|
use App\Services\Providers\CredentialManager;
|
|
use App\Services\Providers\ProviderOperationStartGate;
|
|
use App\Services\Verification\StartVerification;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\Badges\BadgeDomain;
|
|
use App\Support\Badges\BadgeRenderer;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\OpsUx\OperationUxPresenter;
|
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
|
use App\Support\Providers\ProviderReasonCodes;
|
|
use App\Support\Rbac\UiEnforcement;
|
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
use App\Support\Workspaces\WorkspaceContext;
|
|
use BackedEnum;
|
|
use Filament\Actions;
|
|
use Filament\Facades\Filament;
|
|
use Filament\Forms\Components\TextInput;
|
|
use Filament\Forms\Components\Toggle;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Resources\Resource;
|
|
use Filament\Schemas\Components\Section;
|
|
use Filament\Schemas\Schema;
|
|
use Filament\Tables;
|
|
use Filament\Tables\Filters\Filter;
|
|
use Filament\Tables\Filters\SelectFilter;
|
|
use Filament\Tables\Table;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Query\JoinClause;
|
|
use Illuminate\Support\Str;
|
|
use UnitEnum;
|
|
|
|
class ProviderConnectionResource extends Resource
|
|
{
|
|
protected static bool $isDiscovered = false;
|
|
|
|
protected static bool $isScopedToTenant = false;
|
|
|
|
protected static ?string $model = ProviderConnection::class;
|
|
|
|
protected static ?string $slug = 'provider-connections';
|
|
|
|
protected static bool $isGloballySearchable = false;
|
|
|
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-link';
|
|
|
|
protected static string|UnitEnum|null $navigationGroup = 'Settings';
|
|
|
|
protected static ?string $navigationLabel = 'Provider Connections';
|
|
|
|
protected static ?string $recordTitleAttribute = 'display_name';
|
|
|
|
public static function getNavigationParentItem(): ?string
|
|
{
|
|
return 'Integrations';
|
|
}
|
|
|
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
{
|
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions include capability-gated create.')
|
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
|
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Provider connections intentionally omit bulk actions.')
|
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines empty-state guidance and CTA.')
|
|
->exempt(ActionSurfaceSlot::DetailHeader, 'View page has no additional header mutations in this resource.');
|
|
}
|
|
|
|
public static function canCreate(): bool
|
|
{
|
|
$tenant = static::resolveTenantForCreate();
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
/** @var CapabilityResolver $resolver */
|
|
$resolver = app(CapabilityResolver::class);
|
|
|
|
return $resolver->isMember($user, $tenant)
|
|
&& $resolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE);
|
|
}
|
|
|
|
protected static function hasTenantCapability(string $capability): bool
|
|
{
|
|
$tenant = static::resolveScopedTenant();
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
/** @var CapabilityResolver $resolver */
|
|
$resolver = app(CapabilityResolver::class);
|
|
|
|
return $resolver->isMember($user, $tenant)
|
|
&& $resolver->can($user, $tenant, $capability);
|
|
}
|
|
|
|
protected static function resolveScopedTenant(): ?Tenant
|
|
{
|
|
$tenantExternalId = static::resolveRequestedTenantExternalId();
|
|
|
|
if (is_string($tenantExternalId) && $tenantExternalId !== '') {
|
|
return static::resolveTenantByExternalId($tenantExternalId);
|
|
}
|
|
|
|
$routeTenant = request()->route('tenant');
|
|
|
|
if ($routeTenant instanceof Tenant) {
|
|
return $routeTenant;
|
|
}
|
|
|
|
if (is_string($routeTenant) && $routeTenant !== '') {
|
|
return Tenant::query()
|
|
->where('external_id', $routeTenant)
|
|
->first();
|
|
}
|
|
|
|
$recordTenant = static::resolveTenantFromRouteRecord();
|
|
|
|
if ($recordTenant instanceof Tenant) {
|
|
return $recordTenant;
|
|
}
|
|
|
|
$contextTenantExternalId = static::resolveContextTenantExternalId();
|
|
|
|
if (is_string($contextTenantExternalId) && $contextTenantExternalId !== '') {
|
|
return static::resolveTenantByExternalId($contextTenantExternalId);
|
|
}
|
|
|
|
$filamentTenant = Filament::getTenant();
|
|
|
|
return $filamentTenant instanceof Tenant ? $filamentTenant : null;
|
|
}
|
|
|
|
public static function resolveTenantForRecord(?ProviderConnection $record = null): ?Tenant
|
|
{
|
|
if ($record instanceof ProviderConnection) {
|
|
$tenant = $record->tenant;
|
|
|
|
if (! $tenant instanceof Tenant && is_numeric($record->tenant_id)) {
|
|
$tenant = Tenant::query()->whereKey((int) $record->tenant_id)->first();
|
|
}
|
|
|
|
if ($tenant instanceof Tenant) {
|
|
return $tenant;
|
|
}
|
|
}
|
|
|
|
return static::resolveScopedTenant();
|
|
}
|
|
|
|
public static function resolveRequestedTenantExternalId(): ?string
|
|
{
|
|
$queryTenant = request()->query('tenant_id');
|
|
|
|
if (is_string($queryTenant) && $queryTenant !== '') {
|
|
return $queryTenant;
|
|
}
|
|
|
|
return static::resolveTenantExternalIdFromLivewireRequest();
|
|
}
|
|
|
|
public static function resolveContextTenantExternalId(): ?string
|
|
{
|
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
$contextTenantId = app(WorkspaceContext::class)->lastTenantId(request());
|
|
|
|
if ($workspaceId !== null && $contextTenantId !== null) {
|
|
$tenant = Tenant::query()
|
|
->whereKey($contextTenantId)
|
|
->where('workspace_id', (int) $workspaceId)
|
|
->first();
|
|
|
|
if ($tenant instanceof Tenant) {
|
|
return (string) $tenant->external_id;
|
|
}
|
|
}
|
|
|
|
$filamentTenant = Filament::getTenant();
|
|
|
|
if ($filamentTenant instanceof Tenant) {
|
|
return (string) $filamentTenant->external_id;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public static function resolveTenantForCreate(): ?Tenant
|
|
{
|
|
$tenantExternalId = static::resolveRequestedTenantExternalId() ?? static::resolveContextTenantExternalId();
|
|
|
|
if (! is_string($tenantExternalId) || $tenantExternalId === '') {
|
|
return null;
|
|
}
|
|
|
|
$tenant = static::resolveTenantByExternalId($tenantExternalId);
|
|
$user = auth()->user();
|
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
|
|
if (! $tenant instanceof Tenant || ! $user instanceof User || $workspaceId === null) {
|
|
return null;
|
|
}
|
|
|
|
if ((int) $tenant->workspace_id !== (int) $workspaceId) {
|
|
return null;
|
|
}
|
|
|
|
if (! $user->canAccessTenant($tenant)) {
|
|
return null;
|
|
}
|
|
|
|
return $tenant;
|
|
}
|
|
|
|
private static function resolveTenantExternalIdFromLivewireRequest(): ?string
|
|
{
|
|
if (! request()->headers->has('x-livewire') && ! request()->headers->has('x-livewire-navigate')) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$url = \Livewire\Livewire::originalUrl();
|
|
|
|
if (is_string($url) && $url !== '') {
|
|
$externalId = static::extractTenantExternalIdFromUrl($url);
|
|
|
|
if (is_string($externalId) && $externalId !== '') {
|
|
return $externalId;
|
|
}
|
|
}
|
|
} catch (\Throwable) {
|
|
// Ignore and fall back to referer.
|
|
}
|
|
|
|
$referer = request()->headers->get('referer');
|
|
|
|
if (! is_string($referer) || $referer === '') {
|
|
return null;
|
|
}
|
|
|
|
return static::extractTenantExternalIdFromUrl($referer);
|
|
}
|
|
|
|
private static function extractTenantExternalIdFromUrl(string $url): ?string
|
|
{
|
|
$query = parse_url($url, PHP_URL_QUERY);
|
|
|
|
if (is_string($query) && $query !== '') {
|
|
parse_str($query, $queryParams);
|
|
|
|
$tenantExternalId = $queryParams['tenant_id'] ?? null;
|
|
|
|
if (is_string($tenantExternalId) && $tenantExternalId !== '') {
|
|
return $tenantExternalId;
|
|
}
|
|
}
|
|
|
|
$path = parse_url($url, PHP_URL_PATH);
|
|
|
|
if (! is_string($path) || $path === '') {
|
|
$path = $url;
|
|
}
|
|
|
|
if (preg_match('~/(?:admin)/(?:tenants|t)/([0-9a-fA-F-]{36})(?:/|$)~', $path, $matches) !== 1) {
|
|
return null;
|
|
}
|
|
|
|
return (string) $matches[1];
|
|
}
|
|
|
|
private static function resolveTenantByExternalId(?string $externalId): ?Tenant
|
|
{
|
|
if (! is_string($externalId) || $externalId === '') {
|
|
return null;
|
|
}
|
|
|
|
return Tenant::query()
|
|
->where('external_id', $externalId)
|
|
->first();
|
|
}
|
|
|
|
private static function resolveTenantFromRouteRecord(): ?Tenant
|
|
{
|
|
$record = request()->route('record');
|
|
|
|
if ($record instanceof ProviderConnection) {
|
|
return static::resolveTenantForRecord($record);
|
|
}
|
|
|
|
if (! is_numeric($record)) {
|
|
return null;
|
|
}
|
|
|
|
$providerConnection = ProviderConnection::query()
|
|
->with('tenant')
|
|
->whereKey((int) $record)
|
|
->first();
|
|
|
|
if (! $providerConnection instanceof ProviderConnection) {
|
|
return null;
|
|
}
|
|
|
|
return static::resolveTenantForRecord($providerConnection);
|
|
}
|
|
|
|
private static function applyMembershipScope(Builder $query): Builder
|
|
{
|
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
$user = auth()->user();
|
|
|
|
if (! is_int($workspaceId)) {
|
|
$filamentTenant = Filament::getTenant();
|
|
|
|
if ($filamentTenant instanceof Tenant) {
|
|
$workspaceId = (int) $filamentTenant->workspace_id;
|
|
}
|
|
}
|
|
|
|
if (! is_int($workspaceId) || ! $user instanceof User) {
|
|
return $query->whereRaw('1 = 0');
|
|
}
|
|
|
|
return $query
|
|
->where('provider_connections.workspace_id', $workspaceId)
|
|
->whereExists(function ($membershipScope) use ($user, $workspaceId): void {
|
|
$membershipScope
|
|
->selectRaw('1')
|
|
->from('tenants as scoped_tenants')
|
|
->join('tenant_memberships as scoped_memberships', function (JoinClause $join) use ($user): void {
|
|
$join->on('scoped_memberships.tenant_id', '=', 'scoped_tenants.id')
|
|
->where('scoped_memberships.user_id', '=', (int) $user->getKey());
|
|
})
|
|
->whereColumn('scoped_tenants.id', 'provider_connections.tenant_id')
|
|
->where('scoped_tenants.workspace_id', '=', $workspaceId);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private static function tenantFilterOptions(): array
|
|
{
|
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
$user = auth()->user();
|
|
|
|
if (! is_int($workspaceId) || ! $user instanceof User) {
|
|
return [];
|
|
}
|
|
|
|
return Tenant::query()
|
|
->select(['tenants.external_id', 'tenants.name', 'tenants.environment'])
|
|
->join('tenant_memberships as filter_memberships', function (JoinClause $join) use ($user): void {
|
|
$join->on('filter_memberships.tenant_id', '=', 'tenants.id')
|
|
->where('filter_memberships.user_id', '=', (int) $user->getKey());
|
|
})
|
|
->where('tenants.workspace_id', $workspaceId)
|
|
->orderBy('tenants.name')
|
|
->get()
|
|
->mapWithKeys(function (Tenant $tenant): array {
|
|
$environment = strtoupper((string) ($tenant->environment ?? ''));
|
|
$label = $environment !== '' ? "{$tenant->name} ({$environment})" : (string) $tenant->name;
|
|
|
|
return [(string) $tenant->external_id => $label];
|
|
})
|
|
->all();
|
|
}
|
|
|
|
private static function sanitizeErrorMessage(?string $value): ?string
|
|
{
|
|
if (! is_string($value) || trim($value) === '') {
|
|
return null;
|
|
}
|
|
|
|
$normalized = preg_replace('/\s+/', ' ', strip_tags($value));
|
|
$normalized = is_string($normalized) ? trim($normalized) : '';
|
|
|
|
if ($normalized === '') {
|
|
return null;
|
|
}
|
|
|
|
return Str::limit($normalized, 120);
|
|
}
|
|
|
|
public static function form(Schema $schema): Schema
|
|
{
|
|
return $schema
|
|
->schema([
|
|
Section::make('Connection')
|
|
->schema([
|
|
TextInput::make('display_name')
|
|
->label('Display name')
|
|
->required()
|
|
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
|
->maxLength(255),
|
|
TextInput::make('entra_tenant_id')
|
|
->label('Entra tenant ID')
|
|
->required()
|
|
->maxLength(255)
|
|
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
|
->rules(['uuid']),
|
|
Toggle::make('is_default')
|
|
->label('Default connection')
|
|
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
|
->helperText('Exactly one default connection is required per tenant/provider.'),
|
|
])
|
|
->columns(2)
|
|
->columnSpanFull(),
|
|
Section::make('Status')
|
|
->schema([
|
|
TextInput::make('status')
|
|
->label('Status')
|
|
->disabled()
|
|
->dehydrated(false),
|
|
TextInput::make('health_status')
|
|
->label('Health')
|
|
->disabled()
|
|
->dehydrated(false),
|
|
])
|
|
->columns(2)
|
|
->columnSpanFull(),
|
|
]);
|
|
}
|
|
|
|
public static function table(Table $table): Table
|
|
{
|
|
return $table
|
|
->modifyQueryUsing(function (Builder $query): Builder {
|
|
$query->with('tenant');
|
|
|
|
$tenantExternalId = static::resolveRequestedTenantExternalId();
|
|
|
|
if (! is_string($tenantExternalId) || $tenantExternalId === '') {
|
|
return $query;
|
|
}
|
|
|
|
return $query->whereHas('tenant', function (Builder $tenantQuery) use ($tenantExternalId): void {
|
|
$tenantQuery->where('external_id', $tenantExternalId);
|
|
});
|
|
})
|
|
->defaultSort('display_name')
|
|
->recordUrl(fn (ProviderConnection $record): string => static::getUrl('view', ['record' => $record]))
|
|
->columns([
|
|
Tables\Columns\TextColumn::make('tenant.name')
|
|
->label('Tenant')
|
|
->description(function (ProviderConnection $record): ?string {
|
|
$environment = $record->tenant?->environment;
|
|
|
|
if (! is_string($environment) || trim($environment) === '') {
|
|
return null;
|
|
}
|
|
|
|
return strtoupper($environment);
|
|
})
|
|
->url(function (ProviderConnection $record): ?string {
|
|
$tenant = static::resolveTenantForRecord($record);
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
return null;
|
|
}
|
|
|
|
return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin');
|
|
}),
|
|
Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable(),
|
|
Tables\Columns\TextColumn::make('provider')->label('Provider')->toggleable(),
|
|
Tables\Columns\TextColumn::make('entra_tenant_id')->label('Entra tenant ID')->copyable()->toggleable(),
|
|
Tables\Columns\IconColumn::make('is_default')->label('Default')->boolean(),
|
|
Tables\Columns\TextColumn::make('status')
|
|
->label('Status')
|
|
->badge()
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionStatus))
|
|
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionStatus))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionStatus))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionStatus)),
|
|
Tables\Columns\TextColumn::make('health_status')
|
|
->label('Health')
|
|
->badge()
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionHealth))
|
|
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionHealth))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)),
|
|
Tables\Columns\TextColumn::make('last_health_check_at')->label('Last check')->since()->toggleable(),
|
|
Tables\Columns\TextColumn::make('last_error_reason_code')
|
|
->label('Last error reason')
|
|
->toggleable(),
|
|
Tables\Columns\TextColumn::make('last_error_message')
|
|
->label('Last error message')
|
|
->formatStateUsing(fn (?string $state): ?string => static::sanitizeErrorMessage($state))
|
|
->toggleable(),
|
|
])
|
|
->filters([
|
|
SelectFilter::make('tenant')
|
|
->label('Tenant')
|
|
->default(static::resolveScopedTenant()?->external_id)
|
|
->options(static::tenantFilterOptions())
|
|
->query(function (Builder $query, array $data): Builder {
|
|
$value = $data['value'] ?? null;
|
|
|
|
if (! is_string($value) || $value === '') {
|
|
return $query;
|
|
}
|
|
|
|
return $query->whereHas('tenant', function (Builder $tenantQuery) use ($value): void {
|
|
$tenantQuery->where('external_id', $value);
|
|
});
|
|
}),
|
|
SelectFilter::make('provider')
|
|
->label('Provider')
|
|
->options([
|
|
'microsoft' => 'Microsoft',
|
|
])
|
|
->query(function (Builder $query, array $data): Builder {
|
|
$value = $data['value'] ?? null;
|
|
|
|
if (! is_string($value) || $value === '') {
|
|
return $query;
|
|
}
|
|
|
|
return $query->where('provider_connections.provider', $value);
|
|
}),
|
|
SelectFilter::make('status')
|
|
->label('Status')
|
|
->options([
|
|
'connected' => 'Connected',
|
|
'needs_consent' => 'Needs consent',
|
|
'error' => 'Error',
|
|
'disabled' => 'Disabled',
|
|
])
|
|
->query(function (Builder $query, array $data): Builder {
|
|
$value = $data['value'] ?? null;
|
|
|
|
if (! is_string($value) || $value === '') {
|
|
return $query;
|
|
}
|
|
|
|
return $query->where('provider_connections.status', $value);
|
|
}),
|
|
SelectFilter::make('health_status')
|
|
->label('Health')
|
|
->options([
|
|
'ok' => 'OK',
|
|
'degraded' => 'Degraded',
|
|
'down' => 'Down',
|
|
'unknown' => 'Unknown',
|
|
])
|
|
->query(function (Builder $query, array $data): Builder {
|
|
$value = $data['value'] ?? null;
|
|
|
|
if (! is_string($value) || $value === '') {
|
|
return $query;
|
|
}
|
|
|
|
return $query->where('provider_connections.health_status', $value);
|
|
}),
|
|
Filter::make('default_only')
|
|
->label('Default only')
|
|
->query(fn (Builder $query): Builder => $query->where('provider_connections.is_default', true)),
|
|
])
|
|
->actions([
|
|
Actions\ActionGroup::make([
|
|
UiEnforcement::forAction(
|
|
Actions\EditAction::make()
|
|
)
|
|
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
|
->apply(),
|
|
|
|
UiEnforcement::forAction(
|
|
Actions\Action::make('check_connection')
|
|
->label('Check connection')
|
|
->icon('heroicon-o-check-badge')
|
|
->color('success')
|
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
|
->action(function (ProviderConnection $record, StartVerification $verification, \Filament\Tables\Contracts\HasTable $livewire): void {
|
|
$tenant = static::resolveTenantForRecord($record);
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
abort(404);
|
|
}
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
$result = $verification->providerConnectionCheck(
|
|
tenant: $tenant,
|
|
connection: $record,
|
|
initiator: $user,
|
|
);
|
|
|
|
if ($result->status === 'scope_busy') {
|
|
Notification::make()
|
|
->title('Scope busy')
|
|
->body('Another provider operation is already running for this connection.')
|
|
->warning()
|
|
->actions([
|
|
Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
Actions\Action::make('manage_connections')
|
|
->label('Manage Provider Connections')
|
|
->url(static::getUrl('index', tenant: $tenant)),
|
|
])
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
if ($result->status === 'deduped') {
|
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
|
|
|
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
|
->actions([
|
|
Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
Actions\Action::make('manage_connections')
|
|
->label('Manage Provider Connections')
|
|
->url(static::getUrl('index', tenant: $tenant)),
|
|
])
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
if ($result->status === 'blocked') {
|
|
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
|
|
? (string) $result->run->context['reason_code']
|
|
: 'unknown_error';
|
|
|
|
Notification::make()
|
|
->title('Connection check blocked')
|
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
|
->warning()
|
|
->actions([
|
|
Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
Actions\Action::make('manage_connections')
|
|
->label('Manage Provider Connections')
|
|
->url(static::getUrl('index', tenant: $tenant)),
|
|
])
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
|
|
|
OperationUxPresenter::queuedToast((string) $result->run->type)
|
|
->actions([
|
|
Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
])
|
|
->send();
|
|
})
|
|
)
|
|
->preserveVisibility()
|
|
->requireCapability(Capabilities::PROVIDER_RUN)
|
|
->apply(),
|
|
|
|
UiEnforcement::forAction(
|
|
Actions\Action::make('inventory_sync')
|
|
->label('Inventory sync')
|
|
->icon('heroicon-o-arrow-path')
|
|
->color('info')
|
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
|
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate, \Filament\Tables\Contracts\HasTable $livewire): void {
|
|
$tenant = static::resolveTenantForRecord($record);
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
return;
|
|
}
|
|
|
|
$initiator = $user;
|
|
|
|
$result = $gate->start(
|
|
tenant: $tenant,
|
|
connection: $record,
|
|
operationType: 'inventory_sync',
|
|
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
|
|
ProviderInventorySyncJob::dispatch(
|
|
tenantId: (int) $tenant->getKey(),
|
|
userId: (int) $initiator->getKey(),
|
|
providerConnectionId: (int) $record->getKey(),
|
|
operationRun: $operationRun,
|
|
);
|
|
},
|
|
initiator: $initiator,
|
|
);
|
|
|
|
if ($result->status === 'scope_busy') {
|
|
Notification::make()
|
|
->title('Scope is busy')
|
|
->body('Another provider operation is already running for this connection.')
|
|
->danger()
|
|
->actions([
|
|
Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
])
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
if ($result->status === 'deduped') {
|
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
|
|
|
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
|
->actions([
|
|
Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
])
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
if ($result->status === 'blocked') {
|
|
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
|
|
? (string) $result->run->context['reason_code']
|
|
: 'unknown_error';
|
|
|
|
Notification::make()
|
|
->title('Inventory sync blocked')
|
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
|
->warning()
|
|
->actions([
|
|
Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
])
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
|
|
|
OperationUxPresenter::queuedToast((string) $result->run->type)
|
|
->actions([
|
|
Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
])
|
|
->send();
|
|
})
|
|
)
|
|
->preserveVisibility()
|
|
->requireCapability(Capabilities::PROVIDER_RUN)
|
|
->apply(),
|
|
|
|
UiEnforcement::forAction(
|
|
Actions\Action::make('compliance_snapshot')
|
|
->label('Compliance snapshot')
|
|
->icon('heroicon-o-shield-check')
|
|
->color('info')
|
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
|
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate, \Filament\Tables\Contracts\HasTable $livewire): void {
|
|
$tenant = static::resolveTenantForRecord($record);
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
return;
|
|
}
|
|
|
|
$initiator = $user;
|
|
|
|
$result = $gate->start(
|
|
tenant: $tenant,
|
|
connection: $record,
|
|
operationType: 'compliance.snapshot',
|
|
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
|
|
ProviderComplianceSnapshotJob::dispatch(
|
|
tenantId: (int) $tenant->getKey(),
|
|
userId: (int) $initiator->getKey(),
|
|
providerConnectionId: (int) $record->getKey(),
|
|
operationRun: $operationRun,
|
|
);
|
|
},
|
|
initiator: $initiator,
|
|
);
|
|
|
|
if ($result->status === 'scope_busy') {
|
|
Notification::make()
|
|
->title('Scope is busy')
|
|
->body('Another provider operation is already running for this connection.')
|
|
->danger()
|
|
->actions([
|
|
Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
])
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
if ($result->status === 'deduped') {
|
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
|
|
|
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
|
->actions([
|
|
Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
])
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
if ($result->status === 'blocked') {
|
|
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
|
|
? (string) $result->run->context['reason_code']
|
|
: 'unknown_error';
|
|
|
|
Notification::make()
|
|
->title('Compliance snapshot blocked')
|
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
|
->warning()
|
|
->actions([
|
|
Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
])
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
|
|
|
OperationUxPresenter::queuedToast((string) $result->run->type)
|
|
->actions([
|
|
Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
])
|
|
->send();
|
|
})
|
|
)
|
|
->preserveVisibility()
|
|
->requireCapability(Capabilities::PROVIDER_RUN)
|
|
->apply(),
|
|
|
|
UiEnforcement::forAction(
|
|
Actions\Action::make('set_default')
|
|
->label('Set as default')
|
|
->icon('heroicon-o-star')
|
|
->color('primary')
|
|
->requiresConfirmation()
|
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled' && ! $record->is_default)
|
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
|
$tenant = static::resolveTenantForRecord($record);
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
return;
|
|
}
|
|
|
|
$record->makeDefault();
|
|
|
|
$user = auth()->user();
|
|
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
|
$actorEmail = $user instanceof User ? $user->email : null;
|
|
$actorName = $user instanceof User ? $user->name : null;
|
|
|
|
$auditLogger->log(
|
|
tenant: $tenant,
|
|
action: 'provider_connection.default_set',
|
|
context: [
|
|
'metadata' => [
|
|
'provider' => $record->provider,
|
|
'entra_tenant_id' => $record->entra_tenant_id,
|
|
],
|
|
],
|
|
actorId: $actorId,
|
|
actorEmail: $actorEmail,
|
|
actorName: $actorName,
|
|
resourceType: 'provider_connection',
|
|
resourceId: (string) $record->getKey(),
|
|
status: 'success',
|
|
);
|
|
|
|
Notification::make()
|
|
->title('Default connection updated')
|
|
->success()
|
|
->send();
|
|
})
|
|
)
|
|
->preserveVisibility()
|
|
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
|
->apply(),
|
|
|
|
UiEnforcement::forAction(
|
|
Actions\Action::make('update_credentials')
|
|
->label('Update credentials')
|
|
->icon('heroicon-o-key')
|
|
->color('primary')
|
|
->requiresConfirmation()
|
|
->modalDescription('Client secret is stored encrypted and will never be shown again.')
|
|
->form([
|
|
TextInput::make('client_id')
|
|
->label('Client ID')
|
|
->required()
|
|
->maxLength(255),
|
|
TextInput::make('client_secret')
|
|
->label('Client secret')
|
|
->password()
|
|
->required()
|
|
->maxLength(255),
|
|
])
|
|
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
|
|
$tenant = static::resolveTenantForRecord($record);
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
return;
|
|
}
|
|
|
|
$credentials->upsertClientSecretCredential(
|
|
connection: $record,
|
|
clientId: (string) $data['client_id'],
|
|
clientSecret: (string) $data['client_secret'],
|
|
);
|
|
|
|
$user = auth()->user();
|
|
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
|
$actorEmail = $user instanceof User ? $user->email : null;
|
|
$actorName = $user instanceof User ? $user->name : null;
|
|
|
|
$auditLogger->log(
|
|
tenant: $tenant,
|
|
action: 'provider_connection.credentials_updated',
|
|
context: [
|
|
'metadata' => [
|
|
'provider' => $record->provider,
|
|
'entra_tenant_id' => $record->entra_tenant_id,
|
|
'client_id' => (string) $data['client_id'],
|
|
],
|
|
],
|
|
actorId: $actorId,
|
|
actorEmail: $actorEmail,
|
|
actorName: $actorName,
|
|
resourceType: 'provider_connection',
|
|
resourceId: (string) $record->getKey(),
|
|
status: 'success',
|
|
);
|
|
|
|
Notification::make()
|
|
->title('Credentials updated')
|
|
->success()
|
|
->send();
|
|
})
|
|
)
|
|
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
|
->apply(),
|
|
|
|
UiEnforcement::forAction(
|
|
Actions\Action::make('enable_connection')
|
|
->label('Enable connection')
|
|
->icon('heroicon-o-play')
|
|
->color('success')
|
|
->requiresConfirmation()
|
|
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
|
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
|
$tenant = static::resolveTenantForRecord($record);
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
return;
|
|
}
|
|
|
|
$hadCredentials = $record->credential()->exists();
|
|
$previousStatus = (string) $record->status;
|
|
|
|
$status = $hadCredentials ? 'connected' : 'error';
|
|
$errorReasonCode = $hadCredentials ? null : ProviderReasonCodes::ProviderCredentialMissing;
|
|
$errorMessage = $hadCredentials ? null : 'Provider connection credentials are missing.';
|
|
|
|
$record->update([
|
|
'status' => $status,
|
|
'health_status' => 'unknown',
|
|
'last_health_check_at' => null,
|
|
'last_error_reason_code' => $errorReasonCode,
|
|
'last_error_message' => $errorMessage,
|
|
]);
|
|
|
|
$user = auth()->user();
|
|
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
|
$actorEmail = $user instanceof User ? $user->email : null;
|
|
$actorName = $user instanceof User ? $user->name : null;
|
|
|
|
$auditLogger->log(
|
|
tenant: $tenant,
|
|
action: 'provider_connection.enabled',
|
|
context: [
|
|
'metadata' => [
|
|
'provider' => $record->provider,
|
|
'entra_tenant_id' => $record->entra_tenant_id,
|
|
'from_status' => $previousStatus,
|
|
'to_status' => $status,
|
|
'credentials_present' => $hadCredentials,
|
|
],
|
|
],
|
|
actorId: $actorId,
|
|
actorEmail: $actorEmail,
|
|
actorName: $actorName,
|
|
resourceType: 'provider_connection',
|
|
resourceId: (string) $record->getKey(),
|
|
status: 'success',
|
|
);
|
|
|
|
if (! $hadCredentials) {
|
|
Notification::make()
|
|
->title('Connection enabled (credentials missing)')
|
|
->body('Add credentials before running checks or operations.')
|
|
->warning()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
Notification::make()
|
|
->title('Provider connection enabled')
|
|
->success()
|
|
->send();
|
|
})
|
|
)
|
|
->preserveVisibility()
|
|
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
|
->apply(),
|
|
|
|
UiEnforcement::forAction(
|
|
Actions\Action::make('disable_connection')
|
|
->label('Disable connection')
|
|
->icon('heroicon-o-archive-box-x-mark')
|
|
->color('danger')
|
|
->requiresConfirmation()
|
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
|
$tenant = static::resolveTenantForRecord($record);
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
return;
|
|
}
|
|
|
|
$previousStatus = (string) $record->status;
|
|
|
|
$record->update([
|
|
'status' => 'disabled',
|
|
]);
|
|
|
|
$user = auth()->user();
|
|
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
|
$actorEmail = $user instanceof User ? $user->email : null;
|
|
$actorName = $user instanceof User ? $user->name : null;
|
|
|
|
$auditLogger->log(
|
|
tenant: $tenant,
|
|
action: 'provider_connection.disabled',
|
|
context: [
|
|
'metadata' => [
|
|
'provider' => $record->provider,
|
|
'entra_tenant_id' => $record->entra_tenant_id,
|
|
'from_status' => $previousStatus,
|
|
],
|
|
],
|
|
actorId: $actorId,
|
|
actorEmail: $actorEmail,
|
|
actorName: $actorName,
|
|
resourceType: 'provider_connection',
|
|
resourceId: (string) $record->getKey(),
|
|
status: 'success',
|
|
);
|
|
|
|
Notification::make()
|
|
->title('Provider connection disabled')
|
|
->warning()
|
|
->send();
|
|
})
|
|
)
|
|
->preserveVisibility()
|
|
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
|
->apply(),
|
|
])
|
|
->label('More')
|
|
->icon('heroicon-o-ellipsis-vertical')
|
|
->color('gray'),
|
|
])
|
|
->bulkActions([]);
|
|
}
|
|
|
|
public static function getEloquentQuery(): Builder
|
|
{
|
|
$query = parent::getEloquentQuery()
|
|
->with('tenant');
|
|
|
|
return static::applyMembershipScope($query)
|
|
->latest('provider_connections.id');
|
|
}
|
|
|
|
public static function getPages(): array
|
|
{
|
|
return [
|
|
'index' => Pages\ListProviderConnections::route('/'),
|
|
'create' => Pages\CreateProviderConnection::route('/create'),
|
|
'view' => Pages\ViewProviderConnection::route('/{record}'),
|
|
'edit' => Pages\EditProviderConnection::route('/{record}/edit'),
|
|
];
|
|
}
|
|
|
|
private static function normalizeTenantExternalId(mixed $tenant): ?string
|
|
{
|
|
if ($tenant instanceof Tenant) {
|
|
return (string) $tenant->external_id;
|
|
}
|
|
|
|
if (is_string($tenant) && $tenant !== '') {
|
|
return $tenant;
|
|
}
|
|
|
|
if (is_numeric($tenant)) {
|
|
$tenantModel = Tenant::query()->whereKey((int) $tenant)->first();
|
|
|
|
if ($tenantModel instanceof Tenant) {
|
|
return (string) $tenantModel->external_id;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param array<mixed> $parameters
|
|
*/
|
|
public static function getUrl(?string $name = null, array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string
|
|
{
|
|
$panel ??= 'admin';
|
|
$tenantExternalId = null;
|
|
|
|
if (array_key_exists('tenant', $parameters)) {
|
|
$tenantExternalId = static::normalizeTenantExternalId($parameters['tenant']);
|
|
unset($parameters['tenant']);
|
|
}
|
|
|
|
if ($tenantExternalId === null && $tenant instanceof Tenant) {
|
|
$tenantExternalId = (string) $tenant->external_id;
|
|
}
|
|
|
|
if ($tenantExternalId === null) {
|
|
$record = $parameters['record'] ?? null;
|
|
|
|
if ($record instanceof ProviderConnection) {
|
|
$tenantExternalId = static::resolveTenantForRecord($record)?->external_id;
|
|
}
|
|
}
|
|
|
|
if ($tenantExternalId === null) {
|
|
$tenantExternalId = static::resolveScopedTenant()?->external_id;
|
|
}
|
|
|
|
if (! array_key_exists('tenant_id', $parameters) && is_string($tenantExternalId) && $tenantExternalId !== '') {
|
|
$parameters['tenant_id'] = $tenantExternalId;
|
|
}
|
|
|
|
return parent::getUrl($name, $parameters, $isAbsolute, $panel, null, $shouldGuessMissingParameters);
|
|
}
|
|
}
|