diff --git a/app/Filament/Pages/TenantRequiredPermissions.php b/app/Filament/Pages/TenantRequiredPermissions.php index 1c874b8..26a8a88 100644 --- a/app/Filament/Pages/TenantRequiredPermissions.php +++ b/app/Filament/Pages/TenantRequiredPermissions.php @@ -5,7 +5,6 @@ namespace App\Filament\Pages; use App\Filament\Resources\ProviderConnectionResource; -use App\Models\ProviderConnection; use App\Models\Tenant; use App\Models\User; use App\Models\WorkspaceMembership; @@ -41,34 +40,28 @@ class TenantRequiredPermissions extends Page */ public array $viewModel = []; + public ?Tenant $scopedTenant = null; + public static function canAccess(): bool { - $tenant = 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(); + return static::hasScopedTenantAccess(static::resolveScopedTenant()); } public function currentTenant(): ?Tenant { - return static::resolveScopedTenant(); + return $this->scopedTenant; } 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); $state = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([ @@ -147,7 +140,7 @@ public function resetFilters(): void private function refreshViewModel(): void { - $tenant = static::resolveScopedTenant(); + $tenant = $this->scopedTenant; if (! $tenant instanceof Tenant) { $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) { return null; } - $connectionId = ProviderConnection::query() - ->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'); + return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin'); } protected static function resolveScopedTenant(): ?Tenant @@ -209,6 +197,32 @@ protected static function resolveScopedTenant(): ?Tenant ->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); } } diff --git a/app/Services/Intune/TenantPermissionService.php b/app/Services/Intune/TenantPermissionService.php index 31a47de..ec72650 100644 --- a/app/Services/Intune/TenantPermissionService.php +++ b/app/Services/Intune/TenantPermissionService.php @@ -5,6 +5,8 @@ use App\Models\Tenant; use App\Models\TenantPermission; use App\Services\Graph\GraphClientInterface; +use DateTimeInterface; +use Illuminate\Support\Carbon; class TenantPermissionService { @@ -44,6 +46,7 @@ public function getGrantedPermissions(Tenant $tenant): array * @return array{ * overall_status:string, * permissions:array,status:string,details:array|null}>, + * last_refreshed_at:?string, * live_check?: array{attempted:bool,succeeded:bool,http_status:?int,reason_code:?string} * } */ @@ -210,6 +213,7 @@ public function compare( $payload = [ 'overall_status' => $overall, 'permissions' => $results, + 'last_refreshed_at' => $this->lastRefreshedAtIso($tenant), ]; 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; + } } diff --git a/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php b/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php index 98d7aaa..a6d9a5a 100644 --- a/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php +++ b/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php @@ -4,6 +4,8 @@ use App\Models\Tenant; use App\Support\Verification\VerificationReportOverall; +use Carbon\CarbonInterface; +use Illuminate\Support\Carbon; class TenantRequiredPermissionsViewModelBuilder { @@ -16,7 +18,8 @@ class TenantRequiredPermissionsViewModelBuilder * overview: array{ * overall: string, * counts: array{missing_application:int,missing_delegated:int,present:int,error:int}, - * feature_impacts: array + * feature_impacts: array, + * freshness: array{last_refreshed_at:?string,is_stale:bool} * }, * permissions: array, * filters: FilterState, @@ -48,6 +51,7 @@ public function build(Tenant $tenant, array $filters = []): array $state = self::normalizeFilterState($filters); $filteredPermissions = self::applyFilterState($allPermissions, $state); + $freshness = self::deriveFreshness(self::parseLastRefreshedAt($comparison['last_refreshed_at'] ?? null)); return [ 'tenant' => [ @@ -56,9 +60,10 @@ public function build(Tenant $tenant, array $filters = []): array 'name' => (string) $tenant->name, ], 'overview' => [ - 'overall' => self::deriveOverallStatus($allPermissions), + 'overall' => self::deriveOverallStatus($allPermissions, (bool) ($freshness['is_stale'] ?? true)), 'counts' => self::deriveCounts($allPermissions), 'feature_impacts' => self::deriveFeatureImpacts($allPermissions), + 'freshness' => $freshness, ], 'permissions' => $filteredPermissions, 'filters' => $state, @@ -72,7 +77,7 @@ public function build(Tenant $tenant, array $filters = []): array /** * @param array $permissions */ - public static function deriveOverallStatus(array $permissions): string + public static function deriveOverallStatus(array $permissions, bool $hasStaleFreshness = false): string { $hasMissingApplication = collect($permissions)->contains( 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', ); - if ($hasErrors || $hasMissingDelegated) { + if ($hasErrors || $hasMissingDelegated || $hasStaleFreshness) { return VerificationReportOverall::NeedsAttention->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 $permissions * @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, ]; } + + 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; + } } diff --git a/resources/views/filament/pages/tenant-required-permissions.blade.php b/resources/views/filament/pages/tenant-required-permissions.blade.php index 68f5cb8..c02d51b 100644 --- a/resources/views/filament/pages/tenant-required-permissions.blade.php +++ b/resources/views/filament/pages/tenant-required-permissions.blade.php @@ -2,6 +2,7 @@ use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\Links\RequiredPermissionsLinks; + use Illuminate\Support\Carbon; $tenant = $this->currentTenant(); @@ -9,6 +10,7 @@ $overview = is_array($vm['overview'] ?? null) ? $vm['overview'] : []; $counts = is_array($overview['counts'] ?? null) ? $overview['counts'] : []; $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'] : []; $selectedFeatures = is_array($filters['features'] ?? null) ? $filters['features'] : []; @@ -47,17 +49,77 @@ $adminConsentLabel = $adminConsentUrl ? 'Open admin consent' : 'Admin consent guide'; $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
- +
Review what’s missing for this tenant and copy the missing permissions for admin consent.
+
+ Stored-data view only. Last refreshed: {{ $lastRefreshedLabel }}{{ $isStale ? ' (stale)' : '' }}. +
@if ($overallSpec) @@ -86,6 +148,16 @@
+ @if (! $hasStoredPermissionData) +
+
Keine Daten verfügbar
+
+ Für diesen Tenant liegen noch keine gespeicherten Verifikationsdaten vor. + Start verification. +
+
+ @endif +
Guidance
@@ -322,7 +394,75 @@ class="mt-4 space-y-2"
- + + @if ($issues === []) +
+ No blockers or warnings detected from stored data. +
+ @else +
+ @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 + +
+
+ {{ $severity }} +
{{ $title }}
+
+
{{ $description }}
+ @if ($links !== []) +
+ @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 !== '') + + {{ $label }} + + @endif + @endforeach +
+ @endif +
+ @endforeach +
+ @endif +
+ + +
+
+ {{ $presentCount }} permission(s) currently pass. +
+
+ {{ $requiredTotal > 0 ? "Out of {$requiredTotal} required permissions, {$presentCount} are currently granted." : 'No required permissions are configured yet.' }} +
+
+
+ + +
+ + Expand technical details + + +
@if (! $tenant)
No tenant selected. @@ -507,6 +647,8 @@ class="align-top" @endif
@endif +
+