514 lines
30 KiB
PHP
514 lines
30 KiB
PHP
@php
|
||
use App\Models\Tenant;
|
||
use App\Support\Badges\BadgeDomain;
|
||
use App\Support\Badges\BadgeRenderer;
|
||
use App\Support\Links\RequiredPermissionsLinks;
|
||
|
||
$tenant = Tenant::current();
|
||
|
||
$vm = is_array($viewModel ?? null) ? $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'] : [];
|
||
|
||
$filters = is_array($vm['filters'] ?? null) ? $vm['filters'] : [];
|
||
$selectedFeatures = is_array($filters['features'] ?? null) ? $filters['features'] : [];
|
||
$selectedStatus = (string) ($filters['status'] ?? 'missing');
|
||
$selectedType = (string) ($filters['type'] ?? 'all');
|
||
$searchTerm = (string) ($filters['search'] ?? '');
|
||
|
||
$featureOptions = collect($featureImpacts)
|
||
->filter(fn (mixed $impact): bool => is_array($impact) && is_string($impact['feature'] ?? null))
|
||
->map(fn (array $impact): string => (string) $impact['feature'])
|
||
->filter()
|
||
->unique()
|
||
->sort()
|
||
->values()
|
||
->all();
|
||
|
||
$permissions = is_array($vm['permissions'] ?? null) ? $vm['permissions'] : [];
|
||
|
||
$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();
|
||
@endphp
|
||
|
||
<x-filament::page>
|
||
<div class="space-y-6">
|
||
<x-filament::section>
|
||
<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>
|
||
|
||
@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>
|
||
|
||
<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
|
||
|
||
<button
|
||
type="button"
|
||
wire:click="applyFeatureFilter(@js($featureKey))"
|
||
class="rounded-xl border p-4 text-left transition hover:bg-gray-50 dark:hover:bg-gray-900/40 {{ $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>
|
||
</button>
|
||
@endforeach
|
||
</div>
|
||
|
||
@if ($selectedFeatures !== [])
|
||
<div>
|
||
<x-filament::button color="gray" size="sm" wire:click="clearFeatureFilter">
|
||
Clear feature filter
|
||
</x-filament::button>
|
||
</div>
|
||
@endif
|
||
@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="Details">
|
||
@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 dark:border-gray-800 dark:bg-gray-900">
|
||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||
<div>
|
||
<div class="text-sm font-semibold text-gray-950 dark:text-white">Filters</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>
|
||
|
||
<x-filament::button color="gray" size="sm" wire:click="resetFilters">
|
||
Reset
|
||
</x-filament::button>
|
||
</div>
|
||
|
||
<div class="mt-4 grid gap-3 sm:grid-cols-4">
|
||
<div class="space-y-1">
|
||
<label class="text-xs font-medium text-gray-600 dark:text-gray-300">Status</label>
|
||
<select wire:model.live="status" class="fi-input fi-select w-full">
|
||
<option value="missing">Missing</option>
|
||
<option value="present">Present</option>
|
||
<option value="all">All</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="space-y-1">
|
||
<label class="text-xs font-medium text-gray-600 dark:text-gray-300">Type</label>
|
||
<select wire:model.live="type" class="fi-input fi-select w-full">
|
||
<option value="all">All</option>
|
||
<option value="application">Application</option>
|
||
<option value="delegated">Delegated</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="space-y-1 sm:col-span-2">
|
||
<label class="text-xs font-medium text-gray-600 dark:text-gray-300">Search</label>
|
||
<input
|
||
type="search"
|
||
wire:model.live.debounce.500ms="search"
|
||
class="fi-input w-full"
|
||
placeholder="Search permission key or description…"
|
||
/>
|
||
</div>
|
||
|
||
@if ($featureOptions !== [])
|
||
<div class="space-y-1 sm:col-span-4">
|
||
<label class="text-xs font-medium text-gray-600 dark:text-gray-300">Features</label>
|
||
<select wire:model.live="features" class="fi-input fi-select w-full" multiple>
|
||
@foreach ($featureOptions as $feature)
|
||
<option value="{{ $feature }}">{{ $feature }}</option>
|
||
@endforeach
|
||
</select>
|
||
</div>
|
||
@endif
|
||
</div>
|
||
</div>
|
||
|
||
@if ($requiredTotal === 0)
|
||
<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">No permissions configured</div>
|
||
<div class="mt-1">
|
||
No required permissions are currently configured in <code class="font-mono text-xs">config/intune_permissions.php</code>.
|
||
</div>
|
||
</div>
|
||
@elseif ($permissions === [])
|
||
<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">
|
||
@if ($selectedStatus === 'missing' && $missingTotal === 0 && $selectedType === 'all' && $selectedFeatures === [] && trim($searchTerm) === '')
|
||
<div class="font-semibold text-gray-950 dark:text-white">All required permissions are present</div>
|
||
<div class="mt-1">
|
||
Switch Status to “All” if you want to review the full matrix.
|
||
</div>
|
||
@else
|
||
<div class="font-semibold text-gray-950 dark:text-white">No matches</div>
|
||
<div class="mt-1">
|
||
No permissions match the current filters.
|
||
</div>
|
||
@endif
|
||
</div>
|
||
@else
|
||
@php
|
||
$featuresToRender = $featureImpacts;
|
||
|
||
if ($selectedFeatures !== []) {
|
||
$featuresToRender = collect($featureImpacts)
|
||
->filter(fn ($impact) => is_array($impact) && in_array((string) ($impact['feature'] ?? ''), $selectedFeatures, true))
|
||
->values()
|
||
->all();
|
||
}
|
||
@endphp
|
||
|
||
@foreach ($featuresToRender as $impact)
|
||
@php
|
||
$featureKey = is_array($impact) ? ($impact['feature'] ?? null) : null;
|
||
$featureKey = is_string($featureKey) ? $featureKey : null;
|
||
|
||
if ($featureKey === null) {
|
||
continue;
|
||
}
|
||
|
||
$rows = collect($permissions)
|
||
->filter(fn ($row) => is_array($row) && in_array($featureKey, (array) ($row['features'] ?? []), true))
|
||
->values()
|
||
->all();
|
||
|
||
if ($rows === []) {
|
||
continue;
|
||
}
|
||
@endphp
|
||
|
||
<div class="space-y-3">
|
||
<div class="flex items-center justify-between gap-4">
|
||
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
||
{{ $featureKey }}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="overflow-hidden rounded-xl border border-gray-200 dark:border-gray-800">
|
||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
|
||
<thead class="bg-gray-50 dark:bg-gray-900">
|
||
<tr>
|
||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300">
|
||
Permission
|
||
</th>
|
||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300">
|
||
Type
|
||
</th>
|
||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300">
|
||
Status
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-800 dark:bg-gray-950">
|
||
@foreach ($rows as $row)
|
||
@php
|
||
$key = is_array($row) ? (string) ($row['key'] ?? '') : '';
|
||
$type = is_array($row) ? (string) ($row['type'] ?? '') : '';
|
||
$status = is_array($row) ? (string) ($row['status'] ?? '') : '';
|
||
$description = is_array($row) ? ($row['description'] ?? null) : null;
|
||
$description = is_string($description) ? $description : null;
|
||
|
||
$statusSpec = BadgeRenderer::spec(BadgeDomain::TenantPermissionStatus, $status);
|
||
@endphp
|
||
|
||
<tr
|
||
class="align-top"
|
||
data-permission-key="{{ $key }}"
|
||
data-permission-type="{{ $type }}"
|
||
data-permission-status="{{ $status }}"
|
||
>
|
||
<td class="px-4 py-3">
|
||
<div class="text-sm font-medium text-gray-950 dark:text-white">
|
||
{{ $key }}
|
||
</div>
|
||
@if ($description)
|
||
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||
{{ $description }}
|
||
</div>
|
||
@endif
|
||
</td>
|
||
<td class="px-4 py-3">
|
||
<x-filament::badge color="gray" size="sm">
|
||
{{ $type === 'delegated' ? 'Delegated' : 'Application' }}
|
||
</x-filament::badge>
|
||
</td>
|
||
<td class="px-4 py-3">
|
||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||
{{ $statusSpec->label }}
|
||
</x-filament::badge>
|
||
</td>
|
||
</tr>
|
||
@endforeach
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
@endforeach
|
||
@endif
|
||
</div>
|
||
@endif
|
||
</x-filament::section>
|
||
</div>
|
||
</x-filament::page>
|