Implements Spec 083 (Canonical Required Permissions manage surface hardening + issues-first UX).
Highlights:
- Enforces canonical route: /admin/tenants/{tenant}/required-permissions
- Legacy tenant-plane URL /admin/t/{tenant}/required-permissions stays non-existent (404)
- Deny-as-not-found (404) for non-workspace members and non-tenant-entitled users
- Strict tenant resolution (no cross-plane fallback)
- DB-only render (no external provider calls on page load)
- Issues-first layout + canonical next-step links (re-run verification -> /admin/onboarding)
- Freshness/stale detection (missing or >30 days -> warning)
Tests (Sail):
- vendor/bin/sail artisan test --compact tests/Feature/RequiredPermissions
- vendor/bin/sail artisan test --compact tests/Unit/TenantRequiredPermissionsFreshnessTest.php tests/Unit/TenantRequiredPermissionsOverallStatusTest.php
Notes:
- Filament v5 / Livewire v4 compliant.
- No destructive actions added in this spec; link-only CTAs.
Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #101
655 lines
38 KiB
PHP
655 lines
38 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 = 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'] : [];
|
||
$freshness = is_array($overview['freshness'] ?? null) ? $overview['freshness'] : [];
|
||
|
||
$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();
|
||
$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
|
||
|
||
<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="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)
|
||
<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
|
||
</div>
|
||
</details>
|
||
</x-filament::section>
|
||
</div>
|
||
</x-filament::page>
|