469 lines
26 KiB
PHP
469 lines
26 KiB
PHP
@php
|
||
use App\Support\Badges\BadgeDomain;
|
||
use App\Support\Badges\BadgeRenderer;
|
||
use App\Support\Links\RequiredPermissionsLinks;
|
||
use Illuminate\Support\Carbon;
|
||
|
||
$tenant = $this->currentTenant();
|
||
|
||
$vm = $this->viewModel();
|
||
$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'] : [];
|
||
|
||
$overall = $overview['overall'] ?? null;
|
||
$overallSpec = $overall !== null ? BadgeRenderer::spec(BadgeDomain::VerificationReportOverall, $overall) : null;
|
||
|
||
$copy = is_array($vm['copy'] ?? null) ? $vm['copy'] : [];
|
||
$copyApplication = (string) ($copy['application'] ?? '');
|
||
$copyDelegated = (string) ($copy['delegated'] ?? '');
|
||
|
||
$missingApplication = (int) ($counts['missing_application'] ?? 0);
|
||
$missingDelegated = (int) ($counts['missing_delegated'] ?? 0);
|
||
$presentCount = (int) ($counts['present'] ?? 0);
|
||
$errorCount = (int) ($counts['error'] ?? 0);
|
||
|
||
$missingTotal = $missingApplication + $missingDelegated + $errorCount;
|
||
$requiredTotal = $missingTotal + $presentCount;
|
||
|
||
$adminConsentUrl = $tenant ? RequiredPermissionsLinks::adminConsentUrl($tenant) : null;
|
||
$adminConsentPrimaryUrl = $tenant ? RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant) : RequiredPermissionsLinks::adminConsentGuideUrl();
|
||
$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
|
||
|
||
<x-filament::page>
|
||
<div class="space-y-6">
|
||
<x-filament::section heading="Summary">
|
||
<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="space-y-1">
|
||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||
Review what’s missing for this tenant and copy the missing permissions for admin consent.
|
||
</div>
|
||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||
Stored-data view only. Last refreshed: {{ $lastRefreshedLabel }}{{ $isStale ? ' (stale)' : '' }}.
|
||
</div>
|
||
|
||
@if ($overallSpec)
|
||
<x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon">
|
||
{{ $overallSpec->label }}
|
||
</x-filament::badge>
|
||
@endif
|
||
</div>
|
||
|
||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||
<div class="rounded-lg border border-gray-200 bg-white px-3 py-2 dark:border-gray-800 dark:bg-gray-900">
|
||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Missing (app)</div>
|
||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ (int) ($counts['missing_application'] ?? 0) }}</div>
|
||
</div>
|
||
<div class="rounded-lg border border-gray-200 bg-white px-3 py-2 dark:border-gray-800 dark:bg-gray-900">
|
||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Missing (delegated)</div>
|
||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ (int) ($counts['missing_delegated'] ?? 0) }}</div>
|
||
</div>
|
||
<div class="rounded-lg border border-gray-200 bg-white px-3 py-2 dark:border-gray-800 dark:bg-gray-900">
|
||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Present</div>
|
||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ (int) ($counts['present'] ?? 0) }}</div>
|
||
</div>
|
||
<div class="rounded-lg border border-gray-200 bg-white px-3 py-2 dark:border-gray-800 dark:bg-gray-900">
|
||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Errors</div>
|
||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ (int) ($counts['error'] ?? 0) }}</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="text-sm font-semibold text-gray-900 dark:text-white">Guidance</div>
|
||
<div class="mt-2 space-y-1">
|
||
<div>
|
||
<span class="font-medium">Who can fix this?</span>
|
||
Global Administrator / Privileged Role Administrator.
|
||
</div>
|
||
<div>
|
||
<span class="font-medium">Primary next step:</span>
|
||
<a
|
||
href="{{ $adminConsentPrimaryUrl }}"
|
||
class="text-primary-600 hover:underline dark:text-primary-400"
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
>
|
||
{{ $adminConsentLabel }}
|
||
</a>
|
||
</div>
|
||
@if ($reRunUrl)
|
||
<div>
|
||
<span class="font-medium">After granting consent:</span>
|
||
<a
|
||
href="{{ $reRunUrl }}"
|
||
class="text-primary-600 hover:underline dark:text-primary-400"
|
||
>
|
||
Re-run verification
|
||
</a>
|
||
</div>
|
||
@endif
|
||
</div>
|
||
|
||
<div class="mt-3 flex flex-wrap gap-2">
|
||
<x-filament::button
|
||
color="primary"
|
||
size="sm"
|
||
x-on:click="showCopyApplication = true"
|
||
:disabled="$copyApplication === ''"
|
||
>
|
||
Copy missing application permissions
|
||
</x-filament::button>
|
||
|
||
<x-filament::button
|
||
color="gray"
|
||
size="sm"
|
||
x-on:click="showCopyDelegated = true"
|
||
:disabled="$copyDelegated === ''"
|
||
>
|
||
Copy missing delegated permissions
|
||
</x-filament::button>
|
||
</div>
|
||
</div>
|
||
|
||
@if (is_array($featureImpacts) && $featureImpacts !== [])
|
||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||
@foreach ($featureImpacts as $impact)
|
||
@php
|
||
$featureKey = is_array($impact) ? ($impact['feature'] ?? null) : null;
|
||
$featureKey = is_string($featureKey) ? $featureKey : null;
|
||
$missingCount = is_array($impact) ? (int) ($impact['missing'] ?? 0) : 0;
|
||
$isBlocked = is_array($impact) ? (bool) ($impact['blocked'] ?? false) : false;
|
||
|
||
if ($featureKey === null) {
|
||
continue;
|
||
}
|
||
|
||
$selected = in_array($featureKey, $selectedFeatures, true);
|
||
@endphp
|
||
|
||
<div
|
||
class="rounded-xl border p-4 text-left {{ $selected ? 'border-primary-300 bg-primary-50 dark:border-primary-700 dark:bg-primary-950/40' : 'border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900' }}"
|
||
>
|
||
<div class="flex items-start justify-between gap-3">
|
||
<div class="min-w-0">
|
||
<div class="truncate text-sm font-semibold text-gray-950 dark:text-white">
|
||
{{ $featureKey }}
|
||
</div>
|
||
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||
{{ $missingCount }} missing
|
||
</div>
|
||
</div>
|
||
|
||
<x-filament::badge :color="$isBlocked ? 'danger' : ($missingCount > 0 ? 'warning' : 'success')" size="sm">
|
||
{{ $isBlocked ? 'Blocked' : ($missingCount > 0 ? 'At risk' : 'OK') }}
|
||
</x-filament::badge>
|
||
</div>
|
||
</div>
|
||
@endforeach
|
||
</div>
|
||
@endif
|
||
|
||
<div
|
||
x-cloak
|
||
x-show="showCopyApplication"
|
||
class="fixed inset-0 z-50 flex items-center justify-center px-4 py-6"
|
||
x-on:keydown.escape.window="showCopyApplication = false"
|
||
>
|
||
<div class="absolute inset-0 bg-gray-950/50" x-on:click="showCopyApplication = false"></div>
|
||
<div class="relative w-full max-w-2xl rounded-xl border border-gray-200 bg-white p-5 shadow-xl dark:border-gray-800 dark:bg-gray-900">
|
||
<div class="flex items-start justify-between gap-4">
|
||
<div>
|
||
<div class="text-base font-semibold text-gray-950 dark:text-white">Missing application permissions</div>
|
||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Newline-separated list for admin consent.</div>
|
||
</div>
|
||
<x-filament::button color="gray" size="sm" x-on:click="showCopyApplication = false">Close</x-filament::button>
|
||
</div>
|
||
|
||
@if ($copyApplication === '')
|
||
<div class="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-200">
|
||
Nothing to copy — no missing application permissions in the current feature filter.
|
||
</div>
|
||
@else
|
||
<div
|
||
class="mt-4 space-y-2"
|
||
x-data="{
|
||
text: @js($copyApplication),
|
||
copied: false,
|
||
async copyPayload() {
|
||
try {
|
||
if (navigator.clipboard && (location.protocol === 'https:' || location.hostname === 'localhost')) {
|
||
await navigator.clipboard.writeText(this.text);
|
||
} else {
|
||
const ta = document.createElement('textarea');
|
||
ta.value = this.text;
|
||
ta.style.position = 'fixed';
|
||
ta.style.inset = '0';
|
||
document.body.appendChild(ta);
|
||
ta.focus();
|
||
ta.select();
|
||
document.execCommand('copy');
|
||
ta.remove();
|
||
}
|
||
|
||
this.copied = true;
|
||
setTimeout(() => (this.copied = false), 1500);
|
||
} catch (e) {
|
||
this.copied = false;
|
||
}
|
||
},
|
||
}"
|
||
>
|
||
<div class="flex items-center justify-end gap-2">
|
||
<span x-show="copied" x-transition class="text-sm text-gray-500 dark:text-gray-400">
|
||
Copied
|
||
</span>
|
||
<x-filament::button size="sm" color="primary" x-on:click="copyPayload()">
|
||
Copy
|
||
</x-filament::button>
|
||
</div>
|
||
|
||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-950">
|
||
<pre class="max-h-72 overflow-auto whitespace-pre font-mono text-xs text-gray-900 dark:text-gray-100" x-text="text"></pre>
|
||
</div>
|
||
</div>
|
||
@endif
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
x-cloak
|
||
x-show="showCopyDelegated"
|
||
class="fixed inset-0 z-50 flex items-center justify-center px-4 py-6"
|
||
x-on:keydown.escape.window="showCopyDelegated = false"
|
||
>
|
||
<div class="absolute inset-0 bg-gray-950/50" x-on:click="showCopyDelegated = false"></div>
|
||
<div class="relative w-full max-w-2xl rounded-xl border border-gray-200 bg-white p-5 shadow-xl dark:border-gray-800 dark:bg-gray-900">
|
||
<div class="flex items-start justify-between gap-4">
|
||
<div>
|
||
<div class="text-base font-semibold text-gray-950 dark:text-white">Missing delegated permissions</div>
|
||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Newline-separated list for delegated consent.</div>
|
||
</div>
|
||
<x-filament::button color="gray" size="sm" x-on:click="showCopyDelegated = false">Close</x-filament::button>
|
||
</div>
|
||
|
||
@if ($copyDelegated === '')
|
||
<div class="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-200">
|
||
Nothing to copy — no missing delegated permissions in the current feature filter.
|
||
</div>
|
||
@else
|
||
<div
|
||
class="mt-4 space-y-2"
|
||
x-data="{
|
||
text: @js($copyDelegated),
|
||
copied: false,
|
||
async copyPayload() {
|
||
try {
|
||
if (navigator.clipboard && (location.protocol === 'https:' || location.hostname === 'localhost')) {
|
||
await navigator.clipboard.writeText(this.text);
|
||
} else {
|
||
const ta = document.createElement('textarea');
|
||
ta.value = this.text;
|
||
ta.style.position = 'fixed';
|
||
ta.style.inset = '0';
|
||
document.body.appendChild(ta);
|
||
ta.focus();
|
||
ta.select();
|
||
document.execCommand('copy');
|
||
ta.remove();
|
||
}
|
||
|
||
this.copied = true;
|
||
setTimeout(() => (this.copied = false), 1500);
|
||
} catch (e) {
|
||
this.copied = false;
|
||
}
|
||
},
|
||
}"
|
||
>
|
||
<div class="flex items-center justify-end gap-2">
|
||
<span x-show="copied" x-transition class="text-sm text-gray-500 dark:text-gray-400">
|
||
Copied
|
||
</span>
|
||
<x-filament::button size="sm" color="primary" x-on:click="copyPayload()">
|
||
Copy
|
||
</x-filament::button>
|
||
</div>
|
||
|
||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-950">
|
||
<pre class="max-h-72 overflow-auto whitespace-pre font-mono text-xs text-gray-900 dark:text-gray-100" x-text="text"></pre>
|
||
</div>
|
||
</div>
|
||
@endif
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</x-filament::section>
|
||
|
||
<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"
|
||
x-data="{ open: false }"
|
||
x-bind:open="open"
|
||
x-on:toggle="open = $el.open"
|
||
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)
|
||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||
No tenant selected.
|
||
</div>
|
||
@else
|
||
<div class="space-y-6">
|
||
<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">Native permission matrix</div>
|
||
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||
Search doesn’t affect copy actions. Feature filters do.
|
||
</div>
|
||
</div>
|
||
|
||
{{ $this->table }}
|
||
</div>
|
||
@endif
|
||
</div>
|
||
</details>
|
||
</x-filament::section>
|
||
</div>
|
||
</x-filament::page>
|