## 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
291 lines
18 KiB
PHP
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>
|