feat: harden canonical required permissions surface and issues-first UX

This commit is contained in:
Ahmed Darrazi 2026-02-08 23:16:06 +01:00
parent 96760c65e6
commit 43dff0f2f4
4 changed files with 269 additions and 40 deletions

View File

@ -5,7 +5,6 @@
namespace App\Filament\Pages; namespace App\Filament\Pages;
use App\Filament\Resources\ProviderConnectionResource; use App\Filament\Resources\ProviderConnectionResource;
use App\Models\ProviderConnection;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\WorkspaceMembership; use App\Models\WorkspaceMembership;
@ -41,34 +40,28 @@ class TenantRequiredPermissions extends Page
*/ */
public array $viewModel = []; public array $viewModel = [];
public ?Tenant $scopedTenant = null;
public static function canAccess(): bool public static function canAccess(): bool
{ {
$tenant = static::resolveScopedTenant(); return static::hasScopedTenantAccess(static::resolveScopedTenant());
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) {
return false;
}
return WorkspaceMembership::query()
->where('workspace_id', (int) $workspaceId)
->where('user_id', (int) $user->getKey())
->exists();
} }
public function currentTenant(): ?Tenant public function currentTenant(): ?Tenant
{ {
return static::resolveScopedTenant(); return $this->scopedTenant;
} }
public function mount(): void public function mount(): void
{ {
$tenant = static::resolveScopedTenant();
if (! $tenant instanceof Tenant || ! static::hasScopedTenantAccess($tenant)) {
abort(404);
}
$this->scopedTenant = $tenant;
$queryFeatures = request()->query('features', $this->features); $queryFeatures = request()->query('features', $this->features);
$state = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([ $state = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
@ -147,7 +140,7 @@ public function resetFilters(): void
private function refreshViewModel(): void private function refreshViewModel(): void
{ {
$tenant = static::resolveScopedTenant(); $tenant = $this->scopedTenant;
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
$this->viewModel = []; $this->viewModel = [];
@ -174,25 +167,20 @@ private function refreshViewModel(): void
} }
} }
public function reRunVerificationUrl(): ?string public function reRunVerificationUrl(): string
{ {
$tenant = static::resolveScopedTenant(); return route('admin.onboarding');
}
public function manageProviderConnectionUrl(): ?string
{
$tenant = $this->scopedTenant;
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
return null; return null;
} }
$connectionId = ProviderConnection::query() return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
->where('tenant_id', (int) $tenant->getKey())
->orderByDesc('is_default')
->orderByDesc('id')
->value('id');
if (! is_int($connectionId)) {
return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
}
return ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant, 'record' => $connectionId], panel: 'admin');
} }
protected static function resolveScopedTenant(): ?Tenant protected static function resolveScopedTenant(): ?Tenant
@ -209,6 +197,32 @@ protected static function resolveScopedTenant(): ?Tenant
->first(); ->first();
} }
return Tenant::current(); return null;
}
private static function hasScopedTenantAccess(?Tenant $tenant): bool
{
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) {
return false;
}
$isWorkspaceMember = WorkspaceMembership::query()
->where('workspace_id', (int) $workspaceId)
->where('user_id', (int) $user->getKey())
->exists();
if (! $isWorkspaceMember) {
return false;
}
return $user->canAccessTenant($tenant);
} }
} }

View File

@ -5,6 +5,8 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantPermission; use App\Models\TenantPermission;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use DateTimeInterface;
use Illuminate\Support\Carbon;
class TenantPermissionService class TenantPermissionService
{ {
@ -44,6 +46,7 @@ public function getGrantedPermissions(Tenant $tenant): array
* @return array{ * @return array{
* overall_status:string, * overall_status:string,
* permissions:array<int,array{key:string,type:string,description:?string,features:array<int,string>,status:string,details:array<string,mixed>|null}>, * permissions:array<int,array{key:string,type:string,description:?string,features:array<int,string>,status:string,details:array<string,mixed>|null}>,
* last_refreshed_at:?string,
* live_check?: array{attempted:bool,succeeded:bool,http_status:?int,reason_code:?string} * live_check?: array{attempted:bool,succeeded:bool,http_status:?int,reason_code:?string}
* } * }
*/ */
@ -210,6 +213,7 @@ public function compare(
$payload = [ $payload = [
'overall_status' => $overall, 'overall_status' => $overall,
'permissions' => $results, 'permissions' => $results,
'last_refreshed_at' => $this->lastRefreshedAtIso($tenant),
]; ];
if ($liveCheckMeta['attempted'] === true) { if ($liveCheckMeta['attempted'] === true) {
@ -389,4 +393,25 @@ private function fetchLivePermissions(Tenant $tenant, ?array $graphOptions = nul
]; ];
} }
} }
private function lastRefreshedAtIso(Tenant $tenant): ?string
{
$lastCheckedAt = TenantPermission::query()
->where('tenant_id', (int) $tenant->getKey())
->max('last_checked_at');
if ($lastCheckedAt instanceof DateTimeInterface) {
return Carbon::instance($lastCheckedAt)->toIso8601String();
}
if (is_string($lastCheckedAt) && $lastCheckedAt !== '') {
try {
return Carbon::parse($lastCheckedAt)->toIso8601String();
} catch (\Throwable) {
return null;
}
}
return null;
}
} }

View File

@ -4,6 +4,8 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Verification\VerificationReportOverall; use App\Support\Verification\VerificationReportOverall;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon;
class TenantRequiredPermissionsViewModelBuilder class TenantRequiredPermissionsViewModelBuilder
{ {
@ -16,7 +18,8 @@ class TenantRequiredPermissionsViewModelBuilder
* overview: array{ * overview: array{
* overall: string, * overall: string,
* counts: array{missing_application:int,missing_delegated:int,present:int,error:int}, * counts: array{missing_application:int,missing_delegated:int,present:int,error:int},
* feature_impacts: array<int, FeatureImpact> * feature_impacts: array<int, FeatureImpact>,
* freshness: array{last_refreshed_at:?string,is_stale:bool}
* }, * },
* permissions: array<int, TenantPermissionRow>, * permissions: array<int, TenantPermissionRow>,
* filters: FilterState, * filters: FilterState,
@ -48,6 +51,7 @@ public function build(Tenant $tenant, array $filters = []): array
$state = self::normalizeFilterState($filters); $state = self::normalizeFilterState($filters);
$filteredPermissions = self::applyFilterState($allPermissions, $state); $filteredPermissions = self::applyFilterState($allPermissions, $state);
$freshness = self::deriveFreshness(self::parseLastRefreshedAt($comparison['last_refreshed_at'] ?? null));
return [ return [
'tenant' => [ 'tenant' => [
@ -56,9 +60,10 @@ public function build(Tenant $tenant, array $filters = []): array
'name' => (string) $tenant->name, 'name' => (string) $tenant->name,
], ],
'overview' => [ 'overview' => [
'overall' => self::deriveOverallStatus($allPermissions), 'overall' => self::deriveOverallStatus($allPermissions, (bool) ($freshness['is_stale'] ?? true)),
'counts' => self::deriveCounts($allPermissions), 'counts' => self::deriveCounts($allPermissions),
'feature_impacts' => self::deriveFeatureImpacts($allPermissions), 'feature_impacts' => self::deriveFeatureImpacts($allPermissions),
'freshness' => $freshness,
], ],
'permissions' => $filteredPermissions, 'permissions' => $filteredPermissions,
'filters' => $state, 'filters' => $state,
@ -72,7 +77,7 @@ public function build(Tenant $tenant, array $filters = []): array
/** /**
* @param array<int, TenantPermissionRow> $permissions * @param array<int, TenantPermissionRow> $permissions
*/ */
public static function deriveOverallStatus(array $permissions): string public static function deriveOverallStatus(array $permissions, bool $hasStaleFreshness = false): string
{ {
$hasMissingApplication = collect($permissions)->contains( $hasMissingApplication = collect($permissions)->contains(
fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'application', fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'application',
@ -90,13 +95,35 @@ public static function deriveOverallStatus(array $permissions): string
fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'delegated', fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'delegated',
); );
if ($hasErrors || $hasMissingDelegated) { if ($hasErrors || $hasMissingDelegated || $hasStaleFreshness) {
return VerificationReportOverall::NeedsAttention->value; return VerificationReportOverall::NeedsAttention->value;
} }
return VerificationReportOverall::Ready->value; return VerificationReportOverall::Ready->value;
} }
/**
* @return array{last_refreshed_at:?string,is_stale:bool}
*/
public static function deriveFreshness(?CarbonInterface $lastRefreshedAt, ?CarbonInterface $referenceTime = null): array
{
$reference = $referenceTime instanceof Carbon
? $referenceTime->copy()
: ($referenceTime !== null ? Carbon::instance($referenceTime) : now());
$lastRefreshed = $lastRefreshedAt instanceof Carbon
? $lastRefreshedAt
: ($lastRefreshedAt !== null ? Carbon::instance($lastRefreshedAt) : null);
$isStale = $lastRefreshed === null
|| $lastRefreshed->lt($reference->copy()->subDays(30));
return [
'last_refreshed_at' => $lastRefreshed?->toIso8601String(),
'is_stale' => $isStale,
];
}
/** /**
* @param array<int, TenantPermissionRow> $permissions * @param array<int, TenantPermissionRow> $permissions
* @return array{missing_application:int,missing_delegated:int,present:int,error:int} * @return array{missing_application:int,missing_delegated:int,present:int,error:int}
@ -386,4 +413,25 @@ private static function normalizePermissionRow(array $row): array
'details' => $details, 'details' => $details,
]; ];
} }
private static function parseLastRefreshedAt(mixed $value): ?Carbon
{
if ($value instanceof Carbon) {
return $value;
}
if ($value instanceof CarbonInterface) {
return Carbon::instance($value);
}
if (is_string($value) && $value !== '') {
try {
return Carbon::parse($value);
} catch (\Throwable) {
return null;
}
}
return null;
}
} }

View File

@ -2,6 +2,7 @@
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Links\RequiredPermissionsLinks; use App\Support\Links\RequiredPermissionsLinks;
use Illuminate\Support\Carbon;
$tenant = $this->currentTenant(); $tenant = $this->currentTenant();
@ -9,6 +10,7 @@
$overview = is_array($vm['overview'] ?? null) ? $vm['overview'] : []; $overview = is_array($vm['overview'] ?? null) ? $vm['overview'] : [];
$counts = is_array($overview['counts'] ?? null) ? $overview['counts'] : []; $counts = is_array($overview['counts'] ?? null) ? $overview['counts'] : [];
$featureImpacts = is_array($overview['feature_impacts'] ?? null) ? $overview['feature_impacts'] : []; $featureImpacts = is_array($overview['feature_impacts'] ?? null) ? $overview['feature_impacts'] : [];
$freshness = is_array($overview['freshness'] ?? null) ? $overview['freshness'] : [];
$filters = is_array($vm['filters'] ?? null) ? $vm['filters'] : []; $filters = is_array($vm['filters'] ?? null) ? $vm['filters'] : [];
$selectedFeatures = is_array($filters['features'] ?? null) ? $filters['features'] : []; $selectedFeatures = is_array($filters['features'] ?? null) ? $filters['features'] : [];
@ -47,17 +49,77 @@
$adminConsentLabel = $adminConsentUrl ? 'Open admin consent' : 'Admin consent guide'; $adminConsentLabel = $adminConsentUrl ? 'Open admin consent' : 'Admin consent guide';
$reRunUrl = $this->reRunVerificationUrl(); $reRunUrl = $this->reRunVerificationUrl();
$manageProviderConnectionUrl = $this->manageProviderConnectionUrl();
$lastRefreshedAt = is_string($freshness['last_refreshed_at'] ?? null) ? (string) $freshness['last_refreshed_at'] : null;
$lastRefreshedLabel = $lastRefreshedAt ? Carbon::parse($lastRefreshedAt)->diffForHumans() : 'Unknown';
$isStale = (bool) ($freshness['is_stale'] ?? true);
$hasStoredPermissionData = $lastRefreshedAt !== null;
$issues = [];
if ($missingApplication > 0) {
$issues[] = [
'severity' => 'Blocker',
'title' => 'Missing application permissions',
'description' => "{$missingApplication} required application permission(s) are missing.",
'links' => array_values(array_filter([
['label' => $adminConsentLabel, 'url' => $adminConsentPrimaryUrl, 'external' => true],
$manageProviderConnectionUrl ? ['label' => 'Manage provider connection', 'url' => $manageProviderConnectionUrl, 'external' => false] : null,
['label' => 'Re-run verification', 'url' => $reRunUrl, 'external' => false],
])),
];
}
if ($missingDelegated > 0) {
$issues[] = [
'severity' => 'Warning',
'title' => 'Missing delegated permissions',
'description' => "{$missingDelegated} delegated permission(s) are missing.",
'links' => [
['label' => $adminConsentLabel, 'url' => $adminConsentPrimaryUrl, 'external' => true],
['label' => 'Re-run verification', 'url' => $reRunUrl, 'external' => false],
],
];
}
if ($errorCount > 0) {
$issues[] = [
'severity' => 'Warning',
'title' => 'Verification results need review',
'description' => "{$errorCount} permission row(s) are in an unknown/error state and require follow-up.",
'links' => [
['label' => 'Re-run verification', 'url' => $reRunUrl, 'external' => false],
$manageProviderConnectionUrl ? ['label' => 'Manage provider connection', 'url' => $manageProviderConnectionUrl, 'external' => false] : ['label' => 'Admin consent guide', 'url' => RequiredPermissionsLinks::adminConsentGuideUrl(), 'external' => true],
],
];
}
if ($isStale) {
$issues[] = [
'severity' => 'Warning',
'title' => 'Freshness warning',
'description' => $hasStoredPermissionData
? "Permission data is older than 30 days (last refresh {$lastRefreshedLabel})."
: 'No stored verification data is available yet.',
'links' => [
['label' => 'Start verification', 'url' => $reRunUrl, 'external' => false],
],
];
}
@endphp @endphp
<x-filament::page> <x-filament::page>
<div class="space-y-6"> <div class="space-y-6">
<x-filament::section> <x-filament::section heading="Summary">
<div class="flex flex-col gap-4" x-data="{ showCopyApplication: false, showCopyDelegated: false }"> <div class="flex flex-col gap-4" x-data="{ showCopyApplication: false, showCopyDelegated: false }">
<div class="flex flex-wrap items-start justify-between gap-4"> <div class="flex flex-wrap items-start justify-between gap-4">
<div class="space-y-1"> <div class="space-y-1">
<div class="text-sm text-gray-600 dark:text-gray-300"> <div class="text-sm text-gray-600 dark:text-gray-300">
Review whats missing for this tenant and copy the missing permissions for admin consent. Review whats missing for this tenant and copy the missing permissions for admin consent.
</div> </div>
<div class="text-xs text-gray-500 dark:text-gray-400">
Stored-data view only. Last refreshed: {{ $lastRefreshedLabel }}{{ $isStale ? ' (stale)' : '' }}.
</div>
@if ($overallSpec) @if ($overallSpec)
<x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon"> <x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon">
@ -86,6 +148,16 @@
</div> </div>
</div> </div>
@if (! $hasStoredPermissionData)
<div class="rounded-xl border border-warning-200 bg-warning-50 p-4 text-sm text-warning-800 dark:border-warning-800 dark:bg-warning-950/30 dark:text-warning-200">
<div class="font-semibold">Keine Daten verfügbar</div>
<div class="mt-1">
Für diesen Tenant liegen noch keine gespeicherten Verifikationsdaten vor.
<a href="{{ $reRunUrl }}" class="font-medium underline">Start verification</a>.
</div>
</div>
@endif
<div class="rounded-xl border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200"> <div class="rounded-xl border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
<div class="text-sm font-semibold text-gray-900 dark:text-white">Guidance</div> <div class="text-sm font-semibold text-gray-900 dark:text-white">Guidance</div>
<div class="mt-2 space-y-1"> <div class="mt-2 space-y-1">
@ -322,7 +394,75 @@ class="mt-4 space-y-2"
</div> </div>
</x-filament::section> </x-filament::section>
<x-filament::section heading="Details"> <x-filament::section heading="Issues">
@if ($issues === [])
<div class="rounded-xl border border-success-200 bg-success-50 p-4 text-sm text-success-800 dark:border-success-800 dark:bg-success-950/30 dark:text-success-200">
No blockers or warnings detected from stored data.
</div>
@else
<div class="space-y-3">
@foreach ($issues as $issue)
@php
$severity = (string) ($issue['severity'] ?? 'Warning');
$severityColor = $severity === 'Blocker' ? 'danger' : 'warning';
$title = (string) ($issue['title'] ?? 'Issue');
$description = (string) ($issue['description'] ?? '');
$links = is_array($issue['links'] ?? null) ? $issue['links'] : [];
@endphp
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge :color="$severityColor" size="sm">{{ $severity }}</x-filament::badge>
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $title }}</div>
</div>
<div class="mt-2 text-sm text-gray-700 dark:text-gray-300">{{ $description }}</div>
@if ($links !== [])
<div class="mt-3 flex flex-wrap gap-3 text-sm">
@foreach ($links as $link)
@php
$label = is_array($link) ? (string) ($link['label'] ?? '') : '';
$url = is_array($link) ? (string) ($link['url'] ?? '') : '';
$external = is_array($link) ? (bool) ($link['external'] ?? false) : false;
@endphp
@if ($label !== '' && $url !== '')
<a
href="{{ $url }}"
class="text-primary-600 hover:underline dark:text-primary-400"
@if ($external)
target="_blank"
rel="noreferrer"
@endif
>
{{ $label }}
</a>
@endif
@endforeach
</div>
@endif
</div>
@endforeach
</div>
@endif
</x-filament::section>
<x-filament::section heading="Passed">
<div class="rounded-xl border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
<div class="font-semibold text-gray-950 dark:text-white">
{{ $presentCount }} permission(s) currently pass.
</div>
<div class="mt-1">
{{ $requiredTotal > 0 ? "Out of {$requiredTotal} required permissions, {$presentCount} are currently granted." : 'No required permissions are configured yet.' }}
</div>
</div>
</x-filament::section>
<x-filament::section heading="Technical details">
<details data-testid="technical-details" class="group rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
<summary class="cursor-pointer list-none text-sm font-semibold text-gray-900 dark:text-white">
Expand technical details
</summary>
<div class="mt-4">
@if (! $tenant) @if (! $tenant)
<div class="text-sm text-gray-600 dark:text-gray-300"> <div class="text-sm text-gray-600 dark:text-gray-300">
No tenant selected. No tenant selected.
@ -507,6 +647,8 @@ class="align-top"
@endif @endif
</div> </div>
@endif @endif
</div>
</details>
</x-filament::section> </x-filament::section>
</div> </div>
</x-filament::page> </x-filament::page>