TenantAtlas/resources/views/filament/actions/verification-required-permissions-assist.blade.php
ahmido b182f55562 feat: add verify access required permissions assist (#168)
## Summary
- add an in-place Required Permissions assist to the onboarding Verify Access step via a Filament slideover
- route permission-related verification remediation links into the assist first and keep deep-dive links opening in a new tab
- add view-model and link-behavior helpers plus focused feature, browser, RBAC, and unit coverage for the new assist

## Scope
- onboarding wizard Verify Access UX
- Required Permissions assist rendering and link behavior
- Spec 139 artifacts, contracts, and checklist updates

## Notes
- branch: `139-verify-access-permissions-assist`
- commit: `b4193f1`
- worktree was clean at PR creation time

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #168
2026-03-14 02:00:28 +00:00

291 lines
18 KiB
PHP

@php
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use Illuminate\Support\Carbon;
$assist = is_array($assist ?? null) ? $assist : [];
$tenant = is_array($assist['tenant'] ?? null) ? $assist['tenant'] : [];
$verification = is_array($assist['verification'] ?? null) ? $assist['verification'] : [];
$overview = is_array($assist['overview'] ?? null) ? $assist['overview'] : [];
$counts = is_array($overview['counts'] ?? null) ? $overview['counts'] : [];
$freshness = is_array($overview['freshness'] ?? null) ? $overview['freshness'] : [];
$missingPermissions = is_array($assist['missing_permissions'] ?? null) ? $assist['missing_permissions'] : [];
$applicationRows = is_array($missingPermissions['application'] ?? null) ? $missingPermissions['application'] : [];
$delegatedRows = is_array($missingPermissions['delegated'] ?? null) ? $missingPermissions['delegated'] : [];
$copy = is_array($assist['copy'] ?? null) ? $assist['copy'] : [];
$actions = is_array($assist['actions'] ?? null) ? $assist['actions'] : [];
$fallback = is_array($assist['fallback'] ?? null) ? $assist['fallback'] : [];
$overviewSpec = BadgeRenderer::spec(BadgeDomain::VerificationReportOverall, $overview['overall'] ?? null);
$verificationSpec = BadgeRenderer::spec(BadgeDomain::VerificationReportOverall, $verification['overall'] ?? null);
$lastRefreshedAt = is_string($freshness['last_refreshed_at'] ?? null) ? (string) $freshness['last_refreshed_at'] : null;
$lastRefreshedLabel = 'Not yet refreshed';
if ($lastRefreshedAt !== null) {
try {
$lastRefreshedLabel = Carbon::parse($lastRefreshedAt)->diffForHumans();
} catch (\Throwable) {
$lastRefreshedLabel = $lastRefreshedAt;
}
}
$copyApplication = (string) ($copy['application'] ?? '');
$copyDelegated = (string) ($copy['delegated'] ?? '');
$fullPageAction = is_array($actions['full_page'] ?? null) ? $actions['full_page'] : [];
$grantAdminConsentAction = is_array($actions['grant_admin_consent'] ?? null) ? $actions['grant_admin_consent'] : [];
$manageProviderConnectionAction = is_array($actions['manage_provider_connection'] ?? null) ? $actions['manage_provider_connection'] : [];
$rerunVerificationAction = is_array($actions['rerun_verification'] ?? null) ? $actions['rerun_verification'] : [];
$renderActionLink = static function (array $action, string $testId, string $tone = 'primary'): string {
$label = is_string($action['label'] ?? null) ? trim((string) $action['label']) : '';
$url = is_string($action['url'] ?? null) ? trim((string) $action['url']) : '';
$available = (bool) ($action['available'] ?? false);
if (! $available || $label === '' || $url === '') {
return '';
}
$baseClasses = match ($tone) {
'secondary' => 'border-gray-300 bg-white text-gray-900 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:text-white dark:hover:bg-gray-800',
'warning' => 'border-warning-300 bg-warning-50 text-warning-900 hover:bg-warning-100 dark:border-warning-700 dark:bg-warning-950/40 dark:text-warning-100 dark:hover:bg-warning-900/60',
default => 'border-primary-300 bg-primary-50 text-primary-900 hover:bg-primary-100 dark:border-primary-700 dark:bg-primary-950/40 dark:text-primary-100 dark:hover:bg-primary-900/60',
};
return sprintf(
'<a href="%s" target="_blank" rel="noopener noreferrer" data-testid="%s" class="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition %s"><span>%s</span><span class="text-xs font-normal opacity-80">Opens in new tab</span></a>',
e($url),
e($testId),
$baseClasses,
e($label),
);
};
@endphp
<div class="space-y-6" data-testid="verification-assist-root">
<div class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-gray-900">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="space-y-2">
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Tenant
</div>
<div class="text-lg font-semibold text-gray-950 dark:text-white">
{{ (string) ($tenant['name'] ?? 'Tenant') }}
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ (string) ($tenant['external_id'] ?? 'Unknown tenant') }}
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
@if ($overviewSpec)
<x-filament::badge :color="$overviewSpec->color" :icon="$overviewSpec->icon">
{{ $overviewSpec->label }}
</x-filament::badge>
@endif
@if ($verificationSpec)
<x-filament::badge :color="$verificationSpec->color" :icon="$verificationSpec->icon">
Verification: {{ $verificationSpec->label }}
</x-filament::badge>
@endif
<x-filament::badge color="gray">
Refreshed {{ $lastRefreshedLabel }}
</x-filament::badge>
</div>
</div>
<div class="grid auto-rows-fr grid-cols-2 gap-2 sm:grid-cols-4">
<div class="flex h-full min-h-20 flex-col justify-between gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 dark:border-gray-700 dark:bg-gray-950">
<div class="text-xs font-medium leading-tight text-gray-500 dark:text-gray-400">Missing (app)</div>
<div class="text-sm font-semibold leading-none text-gray-950 dark:text-white">{{ (int) ($counts['missing_application'] ?? 0) }}</div>
</div>
<div class="flex h-full min-h-20 flex-col justify-between gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 dark:border-gray-700 dark:bg-gray-950">
<div class="text-xs font-medium leading-tight text-gray-500 dark:text-gray-400">Missing (delegated)</div>
<div class="text-sm font-semibold leading-none text-gray-950 dark:text-white">{{ (int) ($counts['missing_delegated'] ?? 0) }}</div>
</div>
<div class="flex h-full min-h-20 flex-col justify-between gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 dark:border-gray-700 dark:bg-gray-950">
<div class="text-xs font-medium leading-tight text-gray-500 dark:text-gray-400">Present</div>
<div class="text-sm font-semibold leading-none text-gray-950 dark:text-white">{{ (int) ($counts['present'] ?? 0) }}</div>
</div>
<div class="flex h-full min-h-20 flex-col justify-between gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 dark:border-gray-700 dark:bg-gray-950">
<div class="text-xs font-medium leading-tight text-gray-500 dark:text-gray-400">Errors</div>
<div class="text-sm font-semibold leading-none text-gray-950 dark:text-white">{{ (int) ($counts['error'] ?? 0) }}</div>
</div>
</div>
</div>
</div>
@if ((bool) ($verification['is_stale'] ?? false))
<div class="rounded-xl border border-warning-300 bg-warning-50 p-4 text-sm text-warning-900 dark:border-warning-700 dark:bg-warning-950/40 dark:text-warning-100">
<div class="font-semibold">Verification result is stale</div>
<div class="mt-1">
{{ (string) ($verification['stale_reason'] ?? 'Start verification again to validate the current provider connection.') }}
</div>
</div>
@endif
@if ((bool) ($freshness['is_stale'] ?? false))
<div class="rounded-xl border border-warning-300 bg-warning-50 p-4 text-sm text-warning-900 dark:border-warning-700 dark:bg-warning-950/40 dark:text-warning-100">
<div class="font-semibold">Stored permission data needs refresh</div>
<div class="mt-1">
The permission summary is based on stored diagnostics only. Re-run verification after fixing access to refresh the stored result.
</div>
</div>
@endif
@if ((bool) ($fallback['has_incomplete_detail'] ?? false))
<div class="rounded-xl border border-gray-300 bg-gray-50 p-4 text-sm text-gray-800 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-200">
<div class="font-semibold">Compact detail is incomplete</div>
<div class="mt-1">
{{ (string) ($fallback['message'] ?? 'Open the full page for more detail or rerun verification after addressing access issues.') }}
</div>
</div>
@endif
<div class="space-y-3">
<div class="text-sm font-semibold text-gray-950 dark:text-white">Recovery actions</div>
<div class="flex flex-wrap gap-2">
{!! $renderActionLink($grantAdminConsentAction, 'verification-assist-admin-consent', 'warning') !!}
{!! $renderActionLink($manageProviderConnectionAction, 'verification-assist-manage-provider-connection', 'primary') !!}
{!! $renderActionLink($fullPageAction, 'verification-assist-full-page', 'secondary') !!}
</div>
<div class="text-xs text-gray-600 dark:text-gray-300">
{{ (string) ($rerunVerificationAction['label'] ?? 'Use the existing Start verification action in this step after reviewing changes.') }}
</div>
</div>
@if (trim($copyApplication) !== '')
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900" x-data="{ copied: false, text: @js($copyApplication), async copyPayload() { try { if (navigator.clipboard && (location.protocol === 'https:' || location.hostname === 'localhost' || location.hostname === '127.0.0.1')) { await navigator.clipboard.writeText(this.text); } else { const textarea = document.createElement('textarea'); textarea.value = this.text; textarea.setAttribute('readonly', 'readonly'); textarea.style.position = 'absolute'; textarea.style.left = '-9999px'; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); } this.copied = true; setTimeout(() => this.copied = false, 1600); } catch (error) { this.copied = false; } } }">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold text-gray-950 dark:text-white">Copy missing application permissions</div>
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">Newline-separated payload for admin consent or handoff.</div>
</div>
<div class="flex items-center gap-2">
<div x-show="copied" x-cloak class="text-xs font-medium text-success-700 dark:text-success-300">Copied</div>
<x-filament::button size="sm" color="primary" x-on:click="copyPayload()" data-testid="verification-assist-copy-application">
Copy missing application permissions
</x-filament::button>
</div>
</div>
<pre class="mt-3 overflow-x-auto rounded-lg bg-gray-950/95 p-3 text-xs text-white">{{ $copyApplication }}</pre>
</div>
@endif
@if (trim($copyDelegated) !== '')
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900" x-data="{ copied: false, text: @js($copyDelegated), async copyPayload() { try { if (navigator.clipboard && (location.protocol === 'https:' || location.hostname === 'localhost' || location.hostname === '127.0.0.1')) { await navigator.clipboard.writeText(this.text); } else { const textarea = document.createElement('textarea'); textarea.value = this.text; textarea.setAttribute('readonly', 'readonly'); textarea.style.position = 'absolute'; textarea.style.left = '-9999px'; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); } this.copied = true; setTimeout(() => this.copied = false, 1600); } catch (error) { this.copied = false; } } }">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold text-gray-950 dark:text-white">Copy missing delegated permissions</div>
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">Only shown when delegated permission detail exists in the stored diagnostics.</div>
</div>
<div class="flex items-center gap-2">
<div x-show="copied" x-cloak class="text-xs font-medium text-success-700 dark:text-success-300">Copied</div>
<x-filament::button size="sm" color="gray" x-on:click="copyPayload()" data-testid="verification-assist-copy-delegated">
Copy missing delegated permissions
</x-filament::button>
</div>
</div>
<pre class="mt-3 overflow-x-auto rounded-lg bg-gray-950/95 p-3 text-xs text-white">{{ $copyDelegated }}</pre>
</div>
@endif
@if ($applicationRows !== [])
<div class="space-y-3">
<div class="text-sm font-semibold text-gray-950 dark:text-white">Missing application permissions</div>
<div class="space-y-3">
@foreach ($applicationRows as $row)
@php
$features = is_array($row['features'] ?? null) ? $row['features'] : [];
@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-start justify-between gap-3">
<div class="space-y-1">
<div class="font-mono text-sm font-semibold text-gray-950 dark:text-white">
{{ (string) ($row['key'] ?? 'Unknown permission') }}
</div>
@if (filled($row['description'] ?? null))
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ (string) $row['description'] }}
</div>
@endif
</div>
<x-filament::badge :color="($row['status'] ?? null) === 'error' ? 'warning' : 'danger'" size="sm">
{{ ($row['status'] ?? null) === 'error' ? 'Error' : 'Missing' }}
</x-filament::badge>
</div>
@if ($features !== [])
<div class="mt-3 flex flex-wrap gap-2">
@foreach ($features as $feature)
<span class="rounded-full bg-gray-100 px-2 py-1 text-xs text-gray-700 dark:bg-gray-800 dark:text-gray-200">
{{ (string) $feature }}
</span>
@endforeach
</div>
@endif
</div>
@endforeach
</div>
</div>
@endif
@if ($delegatedRows !== [])
<div class="space-y-3">
<div class="text-sm font-semibold text-gray-950 dark:text-white">Missing delegated permissions</div>
<div class="space-y-3">
@foreach ($delegatedRows as $row)
@php
$features = is_array($row['features'] ?? null) ? $row['features'] : [];
@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-start justify-between gap-3">
<div class="space-y-1">
<div class="font-mono text-sm font-semibold text-gray-950 dark:text-white">
{{ (string) ($row['key'] ?? 'Unknown permission') }}
</div>
@if (filled($row['description'] ?? null))
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ (string) $row['description'] }}
</div>
@endif
</div>
<x-filament::badge :color="($row['status'] ?? null) === 'error' ? 'warning' : 'danger'" size="sm">
{{ ($row['status'] ?? null) === 'error' ? 'Error' : 'Missing' }}
</x-filament::badge>
</div>
@if ($features !== [])
<div class="mt-3 flex flex-wrap gap-2">
@foreach ($features as $feature)
<span class="rounded-full bg-gray-100 px-2 py-1 text-xs text-gray-700 dark:bg-gray-800 dark:text-gray-200">
{{ (string) $feature }}
</span>
@endforeach
</div>
@endif
</div>
@endforeach
</div>
</div>
@endif
@if ($applicationRows === [] && $delegatedRows === [] && ! (bool) ($fallback['has_incomplete_detail'] ?? false))
<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">
No missing permission rows are currently stored for this tenant. Use the full page or rerun verification if the summary still needs attention.
</div>
@endif
</div>