TenantAtlas/app/Support/Verification/VerificationLinkBehavior.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

150 lines
4.1 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Verification;
use App\Support\Providers\ProviderReasonCodes;
final class VerificationLinkBehavior
{
/**
* @return array{
* label:string,
* url:string,
* kind:'external'|'internal-diagnostic'|'internal-inline-safe',
* opens_in_new_tab:bool,
* show_new_tab_hint:bool
* }
*/
public function describe(?string $label, ?string $url): array
{
$normalizedLabel = is_string($label) && trim($label) !== ''
? trim($label)
: 'Open link';
$normalizedUrl = is_string($url) && trim($url) !== ''
? trim($url)
: '';
$kind = $this->classify($normalizedUrl);
$opensInNewTab = $kind !== 'internal-inline-safe' && $normalizedUrl !== '';
return [
'label' => $normalizedLabel,
'url' => $normalizedUrl,
'kind' => $kind,
'opens_in_new_tab' => $opensInNewTab,
'show_new_tab_hint' => $opensInNewTab,
];
}
/**
* @param array<string, mixed> $check
*/
public function shouldRouteThroughAssist(array $check, bool $assistVisible): bool
{
if (! $assistVisible) {
return false;
}
$key = $check['key'] ?? null;
$key = is_string($key) ? trim($key) : '';
if ($key !== '' && str_starts_with($key, 'permissions.')) {
return true;
}
$reasonCode = $check['reason_code'] ?? null;
$reasonCode = is_string($reasonCode) ? trim($reasonCode) : '';
return in_array($reasonCode, [
ProviderReasonCodes::ProviderConsentMissing,
ProviderReasonCodes::ProviderConsentFailed,
ProviderReasonCodes::ProviderConsentRevoked,
ProviderReasonCodes::ProviderPermissionMissing,
ProviderReasonCodes::ProviderPermissionDenied,
ProviderReasonCodes::ProviderPermissionRefreshFailed,
ProviderReasonCodes::IntuneRbacPermissionMissing,
], true);
}
/**
* @return 'external'|'internal-diagnostic'|'internal-inline-safe'
*/
private function classify(string $url): string
{
if ($url === '') {
return 'internal-inline-safe';
}
$path = $this->extractPath($url);
if ($path !== null && $this->isInternalDiagnosticPath($path)) {
return 'internal-diagnostic';
}
if ($this->isExternalUrl($url)) {
return 'external';
}
return 'internal-inline-safe';
}
private function extractPath(string $url): ?string
{
$path = parse_url($url, PHP_URL_PATH);
if (is_string($path) && $path !== '') {
return '/'.ltrim($path, '/');
}
if (str_starts_with($url, '/')) {
return '/'.ltrim($url, '/');
}
return null;
}
private function isExternalUrl(string $url): bool
{
$scheme = parse_url($url, PHP_URL_SCHEME);
if (! is_string($scheme) || ! in_array(strtolower($scheme), ['http', 'https'], true)) {
return false;
}
$host = parse_url($url, PHP_URL_HOST);
if (! is_string($host) || $host === '') {
return true;
}
$applicationHost = parse_url(url('/'), PHP_URL_HOST);
$applicationPort = parse_url(url('/'), PHP_URL_PORT);
$urlPort = parse_url($url, PHP_URL_PORT);
if (! is_string($applicationHost) || $applicationHost === '') {
return true;
}
if (strcasecmp($host, $applicationHost) !== 0) {
return true;
}
if ($applicationPort === null || $urlPort === null) {
return false;
}
return (int) $urlPort !== (int) $applicationPort;
}
private function isInternalDiagnosticPath(string $path): bool
{
return (bool) preg_match(
'/^\/admin\/(?:tenants\/[^\/]+\/required-permissions|tenants\/[^\/]+\/provider-connections(?:\/create|\/[^\/]+(?:\/edit)?)?|provider-connections(?:\/create|\/[^\/]+(?:\/edit)?)?)$/',
$path,
);
}
}