Compare commits
48 Commits
docs/remov
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| d98dc30520 | |||
| 55aef627aa | |||
| 02e75e1cda | |||
| 20b6aa6a32 | |||
| c17255f854 | |||
| 7d4d607475 | |||
| 1f0cc5de56 | |||
| 845d21db6d | |||
| 8426741068 | |||
| e7c9b4b853 | |||
| 92f39d9749 | |||
| 3c3daae405 | |||
| a4f2629493 | |||
| b1e1e06861 | |||
| a74ab12f04 | |||
| 5ec62cd117 | |||
| ec71c2d4e7 | |||
| 1f3619bd16 | |||
| 5bcb4f6ab8 | |||
| ede4cc363d | |||
| 417df4f9aa | |||
| 73a879d061 | |||
| 6ca496233b | |||
| 440e63edff | |||
| b0a724acef | |||
| 641bb4afde | |||
| 3f6f80f7af | |||
| 0b5cadc234 | |||
| d2f2c55ead | |||
| b182f55562 | |||
| 98e2b5acd9 | |||
| bab01f07a9 | |||
| 45a804970e | |||
| cc93329672 | |||
| 28cfe38ba4 | |||
| d4fb886de0 | |||
| 8ee1174c8d | |||
| b15d1950b4 | |||
| 4b3113498c | |||
| 3c445709af | |||
| 0c709df54e | |||
| ef41c9193a | |||
| c6e7591d19 | |||
| a490261eca | |||
| 02028be7e4 | |||
| a4f5c4f122 | |||
| 3971c315d8 | |||
| bfc483e9b8 |
76
.codex/prompts/tenantpilot.audit.md
Normal file
76
.codex/prompts/tenantpilot.audit.md
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
description: Scan the TenantPilot repository for architecture and safety violations against the workspace-first, RBAC-first, audit-first governance model.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a Senior Staff Engineer and Enterprise SaaS Architecture Auditor reviewing TenantPilot / TenantAtlas.
|
||||||
|
|
||||||
|
This is not a generic code review. Audit the repository against the TenantPilot audit constitution at `docs/audits/tenantpilot-architecture-audit-constitution.md`.
|
||||||
|
|
||||||
|
## Audit focus
|
||||||
|
|
||||||
|
Prioritize:
|
||||||
|
|
||||||
|
- workspace and tenant isolation
|
||||||
|
- route model binding safety
|
||||||
|
- Filament resources, pages, relation managers, widgets, and actions
|
||||||
|
- Livewire public properties and serialized state risks
|
||||||
|
- jobs, queue boundaries, and backend authorization rechecks
|
||||||
|
- provider access boundaries
|
||||||
|
- `OperationRun` consistency
|
||||||
|
- findings, exceptions, review, drift, and baseline workflow integrity
|
||||||
|
- audit trail completeness
|
||||||
|
- wrong-tenant regression coverage
|
||||||
|
- unauthorized action coverage
|
||||||
|
- workflow misuse and invalid transition coverage
|
||||||
|
|
||||||
|
## Output rules
|
||||||
|
|
||||||
|
Classify every finding as exactly one of:
|
||||||
|
|
||||||
|
- Constitutional Violation
|
||||||
|
- Architectural Drift
|
||||||
|
- Workflow Trust Gap
|
||||||
|
- Test Blind Spot
|
||||||
|
|
||||||
|
Assign one severity:
|
||||||
|
|
||||||
|
- Severity 1: Critical
|
||||||
|
- Severity 2: High
|
||||||
|
- Severity 3: Medium
|
||||||
|
- Severity 4: Low
|
||||||
|
|
||||||
|
Anything directly touching isolation, RBAC, secrets, or auditability must not be rated Low.
|
||||||
|
|
||||||
|
For each finding provide:
|
||||||
|
|
||||||
|
1. Title
|
||||||
|
2. Classification
|
||||||
|
3. Severity
|
||||||
|
4. Affected Area
|
||||||
|
5. Evidence with specific files, classes, methods, routes, or test gaps
|
||||||
|
6. Why this matters in TenantPilot
|
||||||
|
7. Recommended structural correction
|
||||||
|
8. Delivery recommendation: `hotfix`, `follow-up refactor`, or `dedicated spec required`
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Do not praise the codebase.
|
||||||
|
- Do not focus on style unless it affects architecture or safety.
|
||||||
|
- Do not suggest random patterns without proving fit.
|
||||||
|
- Group multiple symptoms under one deeper diagnosis when appropriate.
|
||||||
|
- Be explicit when a local fix is insufficient and a dedicated spec is required.
|
||||||
|
|
||||||
|
## Repository context
|
||||||
|
|
||||||
|
TenantPilot is an enterprise SaaS product for Intune and Microsoft 365 governance, backup, restore, inventory, drift detection, findings, exceptions, operations, and auditability.
|
||||||
|
|
||||||
|
The strategic priorities are:
|
||||||
|
|
||||||
|
- workspace-first context modeling
|
||||||
|
- capability-first RBAC
|
||||||
|
- strong auditability
|
||||||
|
- deterministic workflow semantics
|
||||||
|
- provider access through canonical boundaries
|
||||||
|
- minimal duplication of domain logic across UI surfaces
|
||||||
|
|
||||||
|
Return the audit as a concise but substantive findings report.
|
||||||
104
.codex/prompts/tenantpilot.spec-candidates.md
Normal file
104
.codex/prompts/tenantpilot.spec-candidates.md
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
description: Turn TenantPilot architecture audit findings into bounded spec candidates without colliding with active spec numbering.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a Senior Staff Engineer and Enterprise SaaS Architect working on TenantPilot / TenantAtlas.
|
||||||
|
|
||||||
|
Your task is to produce spec candidates, not implementation code.
|
||||||
|
|
||||||
|
Before writing anything, read and use these repository files as binding context:
|
||||||
|
|
||||||
|
- `docs/audits/tenantpilot-architecture-audit-constitution.md`
|
||||||
|
- `docs/audits/2026-03-15-audit-spec-candidates.md`
|
||||||
|
- `specs/110-ops-ux-enforcement/spec.md`
|
||||||
|
- `specs/111-findings-workflow-sla/spec.md`
|
||||||
|
- `specs/134-audit-log-foundation/spec.md`
|
||||||
|
- `specs/138-managed-tenant-onboarding-draft-identity/spec.md`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Turn the existing audit-derived problem clusters into exactly four proposed follow-up spec candidates.
|
||||||
|
|
||||||
|
The four candidate themes are:
|
||||||
|
|
||||||
|
1. queued execution reauthorization and scope continuity
|
||||||
|
2. tenant-owned query canon and wrong-tenant guards
|
||||||
|
3. findings workflow enforcement and audit backstop
|
||||||
|
4. Livewire context locking and trusted-state reduction
|
||||||
|
|
||||||
|
## Numbering rule
|
||||||
|
|
||||||
|
- Do not invent or reserve fixed spec numbers unless the current repository state proves they are available.
|
||||||
|
- If numbering is uncertain, use `Candidate A`, `Candidate B`, `Candidate C`, and `Candidate D`.
|
||||||
|
- Only recommend a numbering strategy; do not force numbering in the output when collisions are possible.
|
||||||
|
|
||||||
|
## Output requirements
|
||||||
|
|
||||||
|
Create exactly four spec candidates, one per problem class.
|
||||||
|
|
||||||
|
For each candidate provide:
|
||||||
|
|
||||||
|
1. Candidate label or confirmed spec number
|
||||||
|
2. Working title
|
||||||
|
3. Status: `Proposed`
|
||||||
|
4. Summary
|
||||||
|
5. Why this is needed now
|
||||||
|
6. Boundary to existing specs
|
||||||
|
7. Problem statement
|
||||||
|
8. Goals
|
||||||
|
9. Non-goals
|
||||||
|
10. Scope
|
||||||
|
11. Target model
|
||||||
|
12. Key requirements
|
||||||
|
13. Risks if not implemented
|
||||||
|
14. Dependencies and sequencing notes
|
||||||
|
15. Delivery recommendation: `hotfix`, `dedicated spec`, or `phased spec`
|
||||||
|
16. Suggested implementation priority: `Critical`, `High`, or `Medium`
|
||||||
|
17. Suggested slug
|
||||||
|
|
||||||
|
At the end provide:
|
||||||
|
|
||||||
|
A. Recommended implementation order
|
||||||
|
B. Which candidates can run in parallel
|
||||||
|
C. Which candidate should start first and why
|
||||||
|
D. A numbering strategy recommendation if active spec numbers are not yet known
|
||||||
|
|
||||||
|
## Writing rules
|
||||||
|
|
||||||
|
- Write in English.
|
||||||
|
- Use formal enterprise spec language.
|
||||||
|
- Be concrete and opinionated.
|
||||||
|
- Focus on structural integrity, not patch-level fixes.
|
||||||
|
- Treat the audit constitution as binding.
|
||||||
|
- Explicitly say when UI-only authorization is insufficient.
|
||||||
|
- Explicitly say when Livewire public state must be treated as untrusted input.
|
||||||
|
- Explicitly say when negative-path regression tests are required.
|
||||||
|
- Explicitly say when `OperationRun` or audit semantics must be extended or hardened.
|
||||||
|
- Do not duplicate adjacent specs; state the boundary clearly.
|
||||||
|
- Do not collapse all four themes into one umbrella spec.
|
||||||
|
|
||||||
|
## Candidate-specific direction
|
||||||
|
|
||||||
|
### Candidate A — queued execution reauthorization and scope continuity
|
||||||
|
|
||||||
|
- Treat this as an execution trust problem, not a simple `authorize()` omission.
|
||||||
|
- Cover dispatch-time actor and context capture, handle-time scope revalidation, capability reauthorization, execution denial semantics, and audit visibility.
|
||||||
|
- Define what happens when authorization or tenant operability changes between dispatch and execution.
|
||||||
|
|
||||||
|
### Candidate B — tenant-owned query canon and wrong-tenant guards
|
||||||
|
|
||||||
|
- Treat this as canonical data-access hardening.
|
||||||
|
- Cover tenant-owned and workspace-owned query rules, route model binding safety, canonical query paths, anti-pattern elimination, and required wrong-tenant regression tests.
|
||||||
|
- Focus on ownership enforcement, not generic repository-pattern advice.
|
||||||
|
|
||||||
|
### Candidate C — findings workflow enforcement and audit backstop
|
||||||
|
|
||||||
|
- Treat this as a workflow-truth problem.
|
||||||
|
- Cover formal lifecycle enforcement, invalid transition prevention, reopen and recurrence semantics, and audit backstop requirements.
|
||||||
|
- Make clear how this extends but does not duplicate Spec 111.
|
||||||
|
|
||||||
|
### Candidate D — Livewire context locking and trusted-state reduction
|
||||||
|
|
||||||
|
- Treat this as a UI/server trust-boundary hardening problem.
|
||||||
|
- Cover locked identifiers, untrusted public state, server-side reconstruction of workflow truth, sensitive-state reduction, and misuse regression tests.
|
||||||
|
- Make clear how this complements but does not duplicate Spec 138.
|
||||||
@ -1,4 +1,6 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
vendor/
|
vendor/
|
||||||
coverage/
|
coverage/
|
||||||
.git/
|
.git/
|
||||||
|
|||||||
72
.github/agents/copilot-instructions.md
vendored
72
.github/agents/copilot-instructions.md
vendored
@ -48,6 +48,72 @@ ## Active Technologies
|
|||||||
- PostgreSQL + existing workspace/tenant session context; no schema changes (122-empty-state-consistency)
|
- PostgreSQL + existing workspace/tenant session context; no schema changes (122-empty-state-consistency)
|
||||||
- PHP 8.4 runtime target on Laravel 12 code conventions; Composer constraint `php:^8.2` + Laravel 12, Filament v5.2.1, Livewire v4, Pest v4, Laravel Sail (123-operations-auto-refresh)
|
- PHP 8.4 runtime target on Laravel 12 code conventions; Composer constraint `php:^8.2` + Laravel 12, Filament v5.2.1, Livewire v4, Pest v4, Laravel Sail (123-operations-auto-refresh)
|
||||||
- PostgreSQL primary app database (123-operations-auto-refresh)
|
- PostgreSQL primary app database (123-operations-auto-refresh)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4, existing `CoverageCapabilitiesResolver`, `InventoryPolicyTypeMeta`, `BadgeCatalog`, and `TagBadgeCatalog` (124-inventory-coverage-table)
|
||||||
|
- N/A for this feature; page remains read-only and uses registry/config-derived runtime data while PostgreSQL remains unchanged (124-inventory-coverage-table)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4, existing `BadgeCatalog` / `BadgeRenderer`, existing UI enforcement helpers, existing Filament resources, relation managers, widgets, and Livewire table components (125-table-ux-standardization)
|
||||||
|
- PostgreSQL remains unchanged; this feature is presentation-layer and behavior-layer only (125-table-ux-standardization)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4.0+, Tailwind CSS v4, Pest v4, existing `BadgeCatalog` / `BadgeRenderer`, existing `TagBadgeCatalog` / `TagBadgeRenderer`, existing Filament resource tables (126-filter-ux-standardization)
|
||||||
|
- PostgreSQL remains unchanged; session persistence uses Filament-native session keys and existing workspace/tenant contex (126-filter-ux-standardization)
|
||||||
|
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Microsoft Graph provider stack (127-rbac-inventory-backup)
|
||||||
|
- PostgreSQL for tenant-owned inventory, backup items, versions, verification outcomes, and operation runs (127-rbac-inventory-backup)
|
||||||
|
- PostgreSQL via Laravel Sail (128-rbac-baseline-compare)
|
||||||
|
- PostgreSQL via Laravel Sail plus session-backed workspace and tenant contex (129-workspace-admin-home)
|
||||||
|
- PostgreSQL via Laravel Sail using existing `baseline_snapshots`, `baseline_snapshot_items`, and JSONB presentation source fields (130-structured-snapshot-rendering)
|
||||||
|
- PostgreSQL via Laravel Sail, plus existing session-backed workspace and tenant contex (131-cross-resource-navigation)
|
||||||
|
- PostgreSQL via Laravel Sail plus existing workspace and tenant context, existing Eloquent relations, and provider-derived identifiers already stored in domain records (132-guid-context-resolver)
|
||||||
|
- PostgreSQL via Laravel Sail; no schema change expected (133-detail-page-template)
|
||||||
|
- PostgreSQL via Laravel Sail; existing `audit_logs` table expanded in place; JSON context payload remains application-shaped rather than raw archival payloads (134-audit-log-foundation)
|
||||||
|
- PHP 8.4 on Laravel 12 + Filament v5, Livewire v4, Pest v4, Laravel Sail (135-canonical-tenant-context-resolution)
|
||||||
|
- PostgreSQL application database (135-canonical-tenant-context-resolution)
|
||||||
|
- PostgreSQL application database and session-backed Filament table state (136-admin-canonical-tenant)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Laravel Sail, Pest v4, PHPUnit v12 (137-platform-provider-identity)
|
||||||
|
- PostgreSQL via Laravel migrations and encrypted model casts (137-platform-provider-identity)
|
||||||
|
- PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Laravel Blade, existing onboarding/verification support classes (139-verify-access-permissions-assist)
|
||||||
|
- PostgreSQL; existing JSON-backed onboarding draft state and `OperationRun.context.verification_report` (139-verify-access-permissions-assist)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4, existing `OperationRunService`, `ProviderOperationStartGate`, onboarding services, workspace audit logging (140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp)
|
||||||
|
- PostgreSQL tables including `managed_tenant_onboarding_sessions`, `operation_runs`, `tenants`, and provider-connection-backed tenant records (140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp)
|
||||||
|
- PostgreSQL via Laravel Sail for existing source records and JSON payloads; no new persistence introduced (141-shared-diff-presentation-foundation)
|
||||||
|
- PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4, shared `App\Support\Diff` foundation from Spec 141 (142-rbac-role-definition-diff-ux-upgrade)
|
||||||
|
- PostgreSQL via Laravel Sail for existing `findings.evidence_jsonb`; no schema or persistence changes (142-rbac-role-definition-diff-ux-upgrade)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4 (143-tenant-lifecycle-operability-context-semantics)
|
||||||
|
- PostgreSQL via Laravel Eloquent models and workspace/tenant scoped tables (143-tenant-lifecycle-operability-context-semantics)
|
||||||
|
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Gates and Policies, `OperateHubShell`, `OperationRunLinks` (144-canonical-operation-viewer-context-decoupling)
|
||||||
|
- PostgreSQL plus session-backed workspace and remembered tenant context (no schema changes) (144-canonical-operation-viewer-context-decoupling)
|
||||||
|
- PHP 8.4.15 with Laravel 12, Filament v5, Livewire v4.0+ + Filament Actions/Tables/Infolists, Laravel Gates/Policies, `UiEnforcement`, `WorkspaceUiEnforcement`, `ActionSurfaceDeclaration`, `BadgeCatalog`, `TenantOperabilityService`, `OnboardingLifecycleService` (145-tenant-action-taxonomy-lifecycle-safe-visibility)
|
||||||
|
- PostgreSQL for tenants, onboarding sessions, audit logs, operation runs, and workspace membership data (145-tenant-action-taxonomy-lifecycle-safe-visibility)
|
||||||
|
- PostgreSQL (existing tenant and operation records only; no schema changes planned) (146-central-tenant-status-presentation)
|
||||||
|
- PostgreSQL plus existing session-backed workspace and remembered-tenant context; no schema change planned (147-tenant-selector-remembered-context-enforcement)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, existing support-layer helpers such as `UiEnforcement`, `CapabilityResolver`, `WorkspaceContext`, `OperateHubShell`, `TenantOperabilityService`, and `TenantActionPolicySurface` (148-central-tenant-operability-policy)
|
||||||
|
- PostgreSQL plus existing session-backed workspace and remembered-tenant context; no schema change planned for the first implementation slice (148-central-tenant-operability-policy)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, existing `OperationRunService`, `TrackOperationRun`, `ProviderOperationStartGate`, `TenantOperabilityService`, `CapabilityResolver`, and `WriteGateInterface` seams (149-queued-execution-reauthorization)
|
||||||
|
- PostgreSQL-backed application data plus queue-serialized `OperationRun` context; no schema migration planned for the first implementation slice (149-queued-execution-reauthorization)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Pest 4 (150-tenant-owned-query-canon-and-wrong-tenant-guards)
|
||||||
|
- PostgreSQL with existing `findings` and `audit_logs` tables; no new storage engine or external log store (151-findings-workflow-backstop)
|
||||||
|
- PostgreSQL with existing workspace-, tenant-, onboarding-, and audit-related tables; no new persistent storage planned for the first slice (152-livewire-context-locking)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `StoredReport`, `Finding`, `OperationRun`, and `AuditLog` infrastructure (153-evidence-domain-foundation)
|
||||||
|
- PostgreSQL with JSONB-backed snapshot metadata; existing private storage remains a downstream-consumer concern, not a primary evidence-foundation store (153-evidence-domain-foundation)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing Finding, AuditLog, EvidenceSnapshot, CapabilityResolver, WorkspaceCapabilityResolver, and UiEnforcement patterns (001-finding-risk-acceptance)
|
||||||
|
- PostgreSQL with new tenant-owned exception tables and JSONB-backed supporting metadata (001-finding-risk-acceptance)
|
||||||
|
- PHP 8.4, Laravel 12, Livewire 4, Filament 5 + Filament resources/pages/actions, Eloquent models, queued Laravel jobs, existing `EvidenceSnapshotService`, existing `ReviewPackService`, capability registry, `OperationRunService` (155-tenant-review-layer)
|
||||||
|
- PostgreSQL with JSONB-backed summary payloads and tenant/workspace ownership columns (155-tenant-review-layer)
|
||||||
|
- PostgreSQL-backed existing domain records; no new business-domain table is required for the first slice; shared taxonomy reference will live in repository documentation and code-level metadata (156-operator-outcome-taxonomy)
|
||||||
|
- PostgreSQL-backed existing records such as `operation_runs`, tenant governance records, onboarding workflow state, and provider connection state; no new business-domain table is required for the first slice (157-reason-code-translation)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BadgeCatalog` / `BadgeRenderer` / `OperatorOutcomeTaxonomy`, `ReasonPresenter`, `OperationRunService`, `TenantReviewReadinessGate`, existing baseline/evidence/review/review-pack resources and canonical pages (158-artifact-truth-semantics)
|
||||||
|
- PostgreSQL with existing JSONB-backed `summary`, `summary_jsonb`, and `context` payloads on baseline snapshots, evidence snapshots, tenant reviews, review packs, and operation runs; no new primary storage required for the first slice (158-artifact-truth-semantics)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, Laravel queue workers, existing `OperationRunService`, `TrackOperationRun`, `OperationUxPresenter`, `ReasonPresenter`, `BadgeCatalog` domain badges, and current Operations Monitoring pages (160-operation-lifecycle-guarantees)
|
||||||
|
- PostgreSQL for `operation_runs`, `jobs`, and `failed_jobs`; JSONB-backed `context`, `summary_counts`, and `failure_summary`; configuration in `config/queue.php` and `config/tenantpilot.php` (160-operation-lifecycle-guarantees)
|
||||||
|
- PostgreSQL (via Sail) plus existing read models persisted in application tables (161-operator-explanation-layer)
|
||||||
|
- PHP 8.4 / Laravel 12, Blade, Alpine via Filament, Tailwind CSS v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRun` and baseline compare services (162-baseline-gap-details)
|
||||||
|
- PostgreSQL with JSONB-backed `operation_runs.context`; no new tables required (162-baseline-gap-details)
|
||||||
|
- PostgreSQL via existing application tables, especially `operation_runs.context` and baseline snapshot summary JSON (163-baseline-subject-resolution)
|
||||||
|
- PHP 8.4, Laravel 12, Blade views, Alpine via Filament v5 / Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRunResource`, `TenantlessOperationRunViewer`, `EnterpriseDetailBuilder`, `ArtifactTruthPresenter`, `OperationUxPresenter`, and `SummaryCountsNormalizer` (164-run-detail-hardening)
|
||||||
|
- PostgreSQL with existing `operation_runs` JSONB-backed `context`, `summary_counts`, and `failure_summary`; no schema change planned (164-run-detail-hardening)
|
||||||
|
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareStats`, `BaselineCompareExplanationRegistry`, `ReasonPresenter`, `BadgeCatalog` or `BadgeRenderer`, `UiEnforcement`, and `OperationRunLinks` (165-baseline-summary-trust)
|
||||||
|
- PostgreSQL with existing baseline, findings, and `operation_runs` tables plus JSONB-backed compare context; no schema change planned (165-baseline-summary-trust)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `Finding`, `FindingException`, `FindingRiskGovernanceResolver`, `BadgeCatalog`, `BadgeRenderer`, `FilterOptionCatalog`, and tenant dashboard widgets (166-finding-governance-health)
|
||||||
|
- PostgreSQL using existing `findings`, `finding_exceptions`, related decision tables, and existing DB-backed summary sources; no schema changes required (166-finding-governance-health)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `ArtifactTruthPresenter`, `OperationUxPresenter`, `RelatedNavigationResolver`, `AppServiceProvider`, `BadgeCatalog`, `BadgeRenderer`, and current Filament resource/page seams (167-derived-state-memoization)
|
||||||
|
- PostgreSQL unchanged; feature adds no persistence and relies on request-local in-memory state only (167-derived-state-memoization)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -67,8 +133,8 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 123-operations-auto-refresh: Added PHP 8.4 runtime target on Laravel 12 code conventions; Composer constraint `php:^8.2` + Laravel 12, Filament v5.2.1, Livewire v4, Pest v4, Laravel Sail
|
- 167-derived-state-memoization: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `ArtifactTruthPresenter`, `OperationUxPresenter`, `RelatedNavigationResolver`, `AppServiceProvider`, `BadgeCatalog`, `BadgeRenderer`, and current Filament resource/page seams
|
||||||
- 122-empty-state-consistency: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4, Pest v4
|
- 166-finding-governance-health: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `Finding`, `FindingException`, `FindingRiskGovernanceResolver`, `BadgeCatalog`, `BadgeRenderer`, `FilterOptionCatalog`, and tenant dashboard widgets
|
||||||
- 121-workspace-switch-fix: Added PHP 8.4.15 / Laravel 12 + Filament v5 + Livewire v4.0+ + Tailwind CSS v4
|
- 165-baseline-summary-trust: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareStats`, `BaselineCompareExplanationRegistry`, `ReasonPresenter`, `BadgeCatalog` or `BadgeRenderer`, `UiEnforcement`, and `OperationRunLinks`
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- MANUAL ADDITIONS END -->
|
||||||
|
|||||||
76
.github/prompts/tenantpilot.audit.prompt.md
vendored
Normal file
76
.github/prompts/tenantpilot.audit.prompt.md
vendored
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
description: Scan the TenantPilot repository for architecture and safety violations against the workspace-first, RBAC-first, audit-first governance model.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a Senior Staff Engineer and Enterprise SaaS Architecture Auditor reviewing TenantPilot / TenantAtlas.
|
||||||
|
|
||||||
|
This is not a generic code review. Audit the repository against the TenantPilot audit constitution at `docs/audits/tenantpilot-architecture-audit-constitution.md`.
|
||||||
|
|
||||||
|
## Audit focus
|
||||||
|
|
||||||
|
Prioritize:
|
||||||
|
|
||||||
|
- workspace and tenant isolation
|
||||||
|
- route model binding safety
|
||||||
|
- Filament resources, pages, relation managers, widgets, and actions
|
||||||
|
- Livewire public properties and serialized state risks
|
||||||
|
- jobs, queue boundaries, and backend authorization rechecks
|
||||||
|
- provider access boundaries
|
||||||
|
- `OperationRun` consistency
|
||||||
|
- findings, exceptions, review, drift, and baseline workflow integrity
|
||||||
|
- audit trail completeness
|
||||||
|
- wrong-tenant regression coverage
|
||||||
|
- unauthorized action coverage
|
||||||
|
- workflow misuse and invalid transition coverage
|
||||||
|
|
||||||
|
## Output rules
|
||||||
|
|
||||||
|
Classify every finding as exactly one of:
|
||||||
|
|
||||||
|
- Constitutional Violation
|
||||||
|
- Architectural Drift
|
||||||
|
- Workflow Trust Gap
|
||||||
|
- Test Blind Spot
|
||||||
|
|
||||||
|
Assign one severity:
|
||||||
|
|
||||||
|
- Severity 1: Critical
|
||||||
|
- Severity 2: High
|
||||||
|
- Severity 3: Medium
|
||||||
|
- Severity 4: Low
|
||||||
|
|
||||||
|
Anything directly touching isolation, RBAC, secrets, or auditability must not be rated Low.
|
||||||
|
|
||||||
|
For each finding provide:
|
||||||
|
|
||||||
|
1. Title
|
||||||
|
2. Classification
|
||||||
|
3. Severity
|
||||||
|
4. Affected Area
|
||||||
|
5. Evidence with specific files, classes, methods, routes, or test gaps
|
||||||
|
6. Why this matters in TenantPilot
|
||||||
|
7. Recommended structural correction
|
||||||
|
8. Delivery recommendation: `hotfix`, `follow-up refactor`, or `dedicated spec required`
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Do not praise the codebase.
|
||||||
|
- Do not focus on style unless it affects architecture or safety.
|
||||||
|
- Do not suggest random patterns without proving fit.
|
||||||
|
- Group multiple symptoms under one deeper diagnosis when appropriate.
|
||||||
|
- Be explicit when a local fix is insufficient and a dedicated spec is required.
|
||||||
|
|
||||||
|
## Repository context
|
||||||
|
|
||||||
|
TenantPilot is an enterprise SaaS product for Intune and Microsoft 365 governance, backup, restore, inventory, drift detection, findings, exceptions, operations, and auditability.
|
||||||
|
|
||||||
|
The strategic priorities are:
|
||||||
|
|
||||||
|
- workspace-first context modeling
|
||||||
|
- capability-first RBAC
|
||||||
|
- strong auditability
|
||||||
|
- deterministic workflow semantics
|
||||||
|
- provider access through canonical boundaries
|
||||||
|
- minimal duplication of domain logic across UI surfaces
|
||||||
|
|
||||||
|
Return the audit as a concise but substantive findings report.
|
||||||
105
.github/prompts/tenantpilot.spec-candidates.prompt.md
vendored
Normal file
105
.github/prompts/tenantpilot.spec-candidates.prompt.md
vendored
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
---
|
||||||
|
description: Turn TenantPilot architecture audit findings into bounded spec candidates without colliding with active spec numbering.
|
||||||
|
agent: speckit.specify
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a Senior Staff Engineer and Enterprise SaaS Architect working on TenantPilot / TenantAtlas.
|
||||||
|
|
||||||
|
Your task is to produce spec candidates, not implementation code.
|
||||||
|
|
||||||
|
Before writing anything, read and use these repository files as binding context:
|
||||||
|
|
||||||
|
- `docs/audits/tenantpilot-architecture-audit-constitution.md`
|
||||||
|
- `docs/audits/2026-03-15-audit-spec-candidates.md`
|
||||||
|
- `specs/110-ops-ux-enforcement/spec.md`
|
||||||
|
- `specs/111-findings-workflow-sla/spec.md`
|
||||||
|
- `specs/134-audit-log-foundation/spec.md`
|
||||||
|
- `specs/138-managed-tenant-onboarding-draft-identity/spec.md`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Turn the existing audit-derived problem clusters into exactly four proposed follow-up spec candidates.
|
||||||
|
|
||||||
|
The four candidate themes are:
|
||||||
|
|
||||||
|
1. queued execution reauthorization and scope continuity
|
||||||
|
2. tenant-owned query canon and wrong-tenant guards
|
||||||
|
3. findings workflow enforcement and audit backstop
|
||||||
|
4. Livewire context locking and trusted-state reduction
|
||||||
|
|
||||||
|
## Numbering rule
|
||||||
|
|
||||||
|
- Do not invent or reserve fixed spec numbers unless the current repository state proves they are available.
|
||||||
|
- If numbering is uncertain, use `Candidate A`, `Candidate B`, `Candidate C`, and `Candidate D`.
|
||||||
|
- Only recommend a numbering strategy; do not force numbering in the output when collisions are possible.
|
||||||
|
|
||||||
|
## Output requirements
|
||||||
|
|
||||||
|
Create exactly four spec candidates, one per problem class.
|
||||||
|
|
||||||
|
For each candidate provide:
|
||||||
|
|
||||||
|
1. Candidate label or confirmed spec number
|
||||||
|
2. Working title
|
||||||
|
3. Status: `Proposed`
|
||||||
|
4. Summary
|
||||||
|
5. Why this is needed now
|
||||||
|
6. Boundary to existing specs
|
||||||
|
7. Problem statement
|
||||||
|
8. Goals
|
||||||
|
9. Non-goals
|
||||||
|
10. Scope
|
||||||
|
11. Target model
|
||||||
|
12. Key requirements
|
||||||
|
13. Risks if not implemented
|
||||||
|
14. Dependencies and sequencing notes
|
||||||
|
15. Delivery recommendation: `hotfix`, `dedicated spec`, or `phased spec`
|
||||||
|
16. Suggested implementation priority: `Critical`, `High`, or `Medium`
|
||||||
|
17. Suggested slug
|
||||||
|
|
||||||
|
At the end provide:
|
||||||
|
|
||||||
|
A. Recommended implementation order
|
||||||
|
B. Which candidates can run in parallel
|
||||||
|
C. Which candidate should start first and why
|
||||||
|
D. A numbering strategy recommendation if active spec numbers are not yet known
|
||||||
|
|
||||||
|
## Writing rules
|
||||||
|
|
||||||
|
- Write in English.
|
||||||
|
- Use formal enterprise spec language.
|
||||||
|
- Be concrete and opinionated.
|
||||||
|
- Focus on structural integrity, not patch-level fixes.
|
||||||
|
- Treat the audit constitution as binding.
|
||||||
|
- Explicitly say when UI-only authorization is insufficient.
|
||||||
|
- Explicitly say when Livewire public state must be treated as untrusted input.
|
||||||
|
- Explicitly say when negative-path regression tests are required.
|
||||||
|
- Explicitly say when `OperationRun` or audit semantics must be extended or hardened.
|
||||||
|
- Do not duplicate adjacent specs; state the boundary clearly.
|
||||||
|
- Do not collapse all four themes into one umbrella spec.
|
||||||
|
|
||||||
|
## Candidate-specific direction
|
||||||
|
|
||||||
|
### Candidate A — queued execution reauthorization and scope continuity
|
||||||
|
|
||||||
|
- Treat this as an execution trust problem, not a simple `authorize()` omission.
|
||||||
|
- Cover dispatch-time actor and context capture, handle-time scope revalidation, capability reauthorization, execution denial semantics, and audit visibility.
|
||||||
|
- Define what happens when authorization or tenant operability changes between dispatch and execution.
|
||||||
|
|
||||||
|
### Candidate B — tenant-owned query canon and wrong-tenant guards
|
||||||
|
|
||||||
|
- Treat this as canonical data-access hardening.
|
||||||
|
- Cover tenant-owned and workspace-owned query rules, route model binding safety, canonical query paths, anti-pattern elimination, and required wrong-tenant regression tests.
|
||||||
|
- Focus on ownership enforcement, not generic repository-pattern advice.
|
||||||
|
|
||||||
|
### Candidate C — findings workflow enforcement and audit backstop
|
||||||
|
|
||||||
|
- Treat this as a workflow-truth problem.
|
||||||
|
- Cover formal lifecycle enforcement, invalid transition prevention, reopen and recurrence semantics, and audit backstop requirements.
|
||||||
|
- Make clear how this extends but does not duplicate Spec 111.
|
||||||
|
|
||||||
|
### Candidate D — Livewire context locking and trusted-state reduction
|
||||||
|
|
||||||
|
- Treat this as a UI/server trust-boundary hardening problem.
|
||||||
|
- Cover locked identifiers, untrusted public state, server-side reconstruction of workflow truth, sensitive-state reduction, and misuse regression tests.
|
||||||
|
- Make clear how this complements but does not duplicate Spec 138.
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -32,5 +32,6 @@ Homestead.json
|
|||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
/references
|
/references
|
||||||
|
/tests/Browser/Screenshots
|
||||||
*.tmp
|
*.tmp
|
||||||
*.swp
|
*.swp
|
||||||
|
|||||||
@ -1,23 +1,36 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
Sync Impact Report
|
||||||
|
|
||||||
- Version change: 1.9.0 → 1.10.0
|
- Version change: 1.13.0 → 1.14.0
|
||||||
- Modified principles:
|
- Modified principles:
|
||||||
- Operations / Run Observability Standard (clarified as non-negotiable 3-surface contract; added lifecycle, summary, guards, system-run policy)
|
- Governance / Scope & Compliance → Governance / Scope, Compliance, and Review Expectations
|
||||||
- Added sections:
|
- Added sections:
|
||||||
- Operations UX — 3-Surface Feedback (OPS-UX-055) (NON-NEGOTIABLE)
|
- Proportionality First (PROP-001)
|
||||||
- OperationRun lifecycle is service-owned (OPS-UX-LC-001)
|
- No Premature Abstraction (ABSTR-001)
|
||||||
- Summary counts contract (OPS-UX-SUM-001)
|
- No New Persisted Truth Without Source-of-Truth Need (PERSIST-001)
|
||||||
- Ops-UX regression guards are mandatory (OPS-UX-GUARD-001)
|
- No New State Without Behavioral Consequence (STATE-001)
|
||||||
- Scheduled/system runs (OPS-UX-SYS-001)
|
- UI Semantics Must Not Become Their Own Framework (UI-SEM-001)
|
||||||
|
- V1 Prefers Explicit Narrow Implementations (V1-EXP-001)
|
||||||
|
- One Truth, Few Layers (LAYER-001)
|
||||||
|
- Spec Discipline Over Slice Proliferation (SPEC-DISC-001)
|
||||||
|
- Tests Must Protect Business Truth (TEST-TRUTH-001)
|
||||||
|
- Enterprise Complexity Is Allowed Only Where Risk Demands It (RISK-COMP-001)
|
||||||
|
- Mandatory Bloat Check for New Specs (BLOAT-001)
|
||||||
|
- Default Bias (BIAS-001)
|
||||||
- Removed sections: None
|
- Removed sections: None
|
||||||
- Templates requiring updates:
|
- Templates requiring updates:
|
||||||
- ✅ .specify/templates/plan-template.md
|
- ✅ .specify/memory/constitution.md
|
||||||
- ✅ .specify/templates/spec-template.md
|
- ✅ .specify/templates/spec-template.md
|
||||||
|
- ✅ .specify/templates/plan-template.md
|
||||||
- ✅ .specify/templates/tasks-template.md
|
- ✅ .specify/templates/tasks-template.md
|
||||||
- N/A: .specify/templates/commands/ (directory not present in this repo)
|
- ✅ docs/product/standards/README.md
|
||||||
|
- ✅ docs/HANDOVER.md
|
||||||
|
- ✅ docs/product/principles.md
|
||||||
|
- ✅ Agents.md
|
||||||
|
- Commands checked:
|
||||||
|
- N/A `.specify/templates/commands/*.md` directory is not present in this repo
|
||||||
- Follow-up TODOs:
|
- Follow-up TODOs:
|
||||||
- Add CI regression guards for “no naked forms” + “view must use infolist” (heuristic scan) in test suite.
|
- None.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# TenantPilot Constitution
|
# TenantPilot Constitution
|
||||||
@ -44,6 +57,73 @@ ### Deterministic Capabilities
|
|||||||
- Backup/restore/risk/support flags MUST be derived deterministically from config/contracts via a Capabilities Resolver.
|
- Backup/restore/risk/support flags MUST be derived deterministically from config/contracts via a Capabilities Resolver.
|
||||||
- The resolver output MUST be programmatically testable (snapshot/golden tests) so config changes cannot silently break behavior.
|
- The resolver output MUST be programmatically testable (snapshot/golden tests) so config changes cannot silently break behavior.
|
||||||
|
|
||||||
|
### Proportionality First (PROP-001)
|
||||||
|
- New structure, layering, persistence, or semantic machinery MUST be justified by current release truth, current operator workflow, and a concrete reason a narrower implementation is insufficient.
|
||||||
|
- Code MUST NOT become more generic, more layered, or more persistent than the current product actually needs.
|
||||||
|
- Reviews MUST reject speculative generalization framed only as future flexibility.
|
||||||
|
|
||||||
|
### No Premature Abstraction (ABSTR-001)
|
||||||
|
- New factories, registries, resolvers, strategy systems, interfaces, extension-point frameworks, type registries, or orchestration pipelines MUST NOT be introduced before at least two real concrete cases require them.
|
||||||
|
- Test convenience alone is not sufficient justification for a new abstraction.
|
||||||
|
- Narrow abstractions are allowed when required for security, tenant isolation, auditability, compliance evidence, or queue/job execution correctness.
|
||||||
|
|
||||||
|
### No New Persisted Truth Without Source-of-Truth Need (PERSIST-001)
|
||||||
|
- New tables, persisted entities, or stored artifacts MUST represent real product truth that survives independently of the originating request, run, or view.
|
||||||
|
- Persisted storage is justified only when at least one of these is true: it is a source of truth, has an independent lifecycle, must be audited independently, must outlive its originating run/request, is required for permissions/routing/compliance evidence, or is required for stable operator workflows over time.
|
||||||
|
- Convenience projections, UI helpers, speculative artifacts, derived summaries, and temporary semantic wrappers MUST remain derived unless current-release operator workflows require independent persistence.
|
||||||
|
- Release 2/3 entities MUST NOT be fully built in Release 1 unless they are foundational and already exercised by the shipped workflow.
|
||||||
|
|
||||||
|
### No New State Without Behavioral Consequence (STATE-001)
|
||||||
|
- New states, statuses, reason codes, lifecycle labels, and semantic categories MUST change operator action, workflow routing, permission or policy enforcement, lifecycle behavior, persistence truth, audit responsibility, retention behavior, or retry/failure handling.
|
||||||
|
- Presentation-only distinctions MUST remain derived labels rather than persisted domain state.
|
||||||
|
- Reason code families MUST NOT expand unless each added value has a distinct system or operator consequence.
|
||||||
|
|
||||||
|
### UI Semantics Must Not Become Their Own Framework (UI-SEM-001)
|
||||||
|
- Badges, explanation text, trust/confidence labels, detail cards, and status summaries MUST remain lightweight presentation helpers unless they are proven product contracts.
|
||||||
|
- New UI semantics MUST NOT require mandatory presenter, badge, explanation, taxonomy, or multi-step interpretation pipelines by default.
|
||||||
|
- Direct mapping from canonical domain truth to UI is preferred over intermediate semantic superstructures.
|
||||||
|
- Presentation helpers SHOULD remain optional adapters, not mandatory architecture.
|
||||||
|
|
||||||
|
### V1 Prefers Explicit Narrow Implementations (V1-EXP-001)
|
||||||
|
- For V1 and early product maturity, direct implementation, local mapping, explicit per-domain logic, small focused helpers, derived read models, and minimal UI adapters are preferred.
|
||||||
|
- Generic platform engines, meta-frameworks, universal resolver systems, workflow frameworks, and broad semantic taxonomies are disfavored until real variance proves them necessary.
|
||||||
|
- The burden of proof is always on the broader abstraction.
|
||||||
|
|
||||||
|
### One Truth, Few Layers (LAYER-001)
|
||||||
|
- A single domain truth MUST NOT be redundantly modeled across model fields, service result objects, presenters, UI summaries, explanation builders, badge taxonomies, run context wrappers, and persisted mirror entities without clear necessity.
|
||||||
|
- Prefer one canonical truth with thin adapters.
|
||||||
|
- Any new layer MUST replace an existing layer or prove why the existing layer cannot serve the need.
|
||||||
|
- Additive semantic layering is discouraged; absorption is preferred over accumulation.
|
||||||
|
|
||||||
|
### Spec Discipline Over Slice Proliferation (SPEC-DISC-001)
|
||||||
|
- Related semantic, taxonomy, and presentation-contract changes SHOULD be grouped into one coherent spec instead of many micro-specs that each add classes, enums, DTOs, and tests.
|
||||||
|
- Every spec MUST explicitly state whether it introduces a new source of truth, persisted entity, abstraction, state, or cross-cutting framework.
|
||||||
|
- If the answer is yes, the spec MUST explain why the addition is necessary now.
|
||||||
|
|
||||||
|
### Tests Must Protect Business Truth (TEST-TRUTH-001)
|
||||||
|
- Testing is mandatory, but test growth MUST follow business truth rather than indirection created for its own sake.
|
||||||
|
- Tests MUST prioritize domain behavior, permissions, isolation, lifecycle correctness, and operator-critical outcomes.
|
||||||
|
- Large dedicated test surfaces for thin presentation indirection SHOULD be avoided.
|
||||||
|
- If a pattern creates more test burden than product certainty, the pattern SHOULD be simplified.
|
||||||
|
|
||||||
|
### Enterprise Complexity Is Allowed Only Where Risk Demands It (RISK-COMP-001)
|
||||||
|
- Heavier architecture is explicitly legitimate for workspace or tenant isolation, RBAC and policy enforcement, auditability, immutable history and snapshot truth, queue/job execution legitimacy, provider credential safety, retention/compliance evidence, and operator-critical lifecycle correctness.
|
||||||
|
- Badge systems, explanation builders, trust/confidence overlays, presentation taxonomies, generic provider frameworks without real provider variance, speculative export/report/review infrastructure, UI meta-governance frameworks, and derived helper entities promoted into persisted truth are high-risk overproduction zones and require extra restraint.
|
||||||
|
|
||||||
|
### Mandatory Bloat Check for New Specs (BLOAT-001)
|
||||||
|
- Any spec that introduces a new enum or status family, DTO/envelope/presenter layer, persisted entity or table, interface/contract/registry/resolver, cross-domain UI framework, or taxonomy/classification system MUST include a proportionality review.
|
||||||
|
- That review MUST answer:
|
||||||
|
1. What current operator problem does this solve?
|
||||||
|
2. Why is existing structure insufficient?
|
||||||
|
3. Why is this the narrowest correct implementation?
|
||||||
|
4. What ownership cost does this create?
|
||||||
|
5. What alternative was intentionally rejected?
|
||||||
|
6. Is this current-release truth or future-release preparation?
|
||||||
|
- Specs that cannot answer these questions clearly MUST NOT merge.
|
||||||
|
|
||||||
|
### Default Bias (BIAS-001)
|
||||||
|
- Default codebase bias is: derive before persist, map before frameworkize, localize before generalize, simplify before extend, replace before layer, explicit before generic, and present directly before interpreting recursively.
|
||||||
|
|
||||||
### Workspace Isolation is Non-negotiable
|
### Workspace Isolation is Non-negotiable
|
||||||
- Workspace membership is an isolation boundary. If the actor is not entitled to the workspace scope, the system MUST respond as
|
- Workspace membership is an isolation boundary. If the actor is not entitled to the workspace scope, the system MUST respond as
|
||||||
deny-as-not-found (404).
|
deny-as-not-found (404).
|
||||||
@ -72,6 +152,7 @@ ### Tenant Isolation is Non-negotiable
|
|||||||
- Workspace-owned tables MUST include workspace_id and MUST NOT include tenant_id.
|
- Workspace-owned tables MUST include workspace_id and MUST NOT include tenant_id.
|
||||||
- Exception: OperationRun MAY have tenant_id nullable to support canonical workspace-context monitoring views; however, revealing any tenant-bound runs still MUST enforce entitlement checks to the referenced tenant scope.
|
- Exception: OperationRun MAY have tenant_id nullable to support canonical workspace-context monitoring views; however, revealing any tenant-bound runs still MUST enforce entitlement checks to the referenced tenant scope.
|
||||||
- Exception: AlertDelivery MAY have tenant_id nullable for workspace-scoped, non-tenant-operational artifacts (e.g., `event_type=alerts.test`). Tenant-bound delivery records still MUST enforce tenant entitlement checks, and tenantless delivery rows MUST NOT contain tenant-specific data.
|
- Exception: AlertDelivery MAY have tenant_id nullable for workspace-scoped, non-tenant-operational artifacts (e.g., `event_type=alerts.test`). Tenant-bound delivery records still MUST enforce tenant entitlement checks, and tenantless delivery rows MUST NOT contain tenant-specific data.
|
||||||
|
- Exception: `managed_tenant_onboarding_sessions` MAY keep `tenant_id` nullable as a workspace-scoped workflow-coordination record. It begins before tenant identification, may later reference a tenant for authorization continuity and resume semantics, and MUST always enforce workspace entitlement plus tenant entitlement once a tenant reference is attached. This exception is specific to onboarding draft workflow state and does not create a general precedent for workspace-owned domain records.
|
||||||
|
|
||||||
### RBAC & UI Enforcement Standards (RBAC-UX)
|
### RBAC & UI Enforcement Standards (RBAC-UX)
|
||||||
|
|
||||||
@ -298,6 +379,103 @@ ### Filament UI — Layout & Information Architecture Standards (UX-001)
|
|||||||
- New resources/pages SHOULD use shared layout builders (e.g., `MainAsideForm`, `MainAsideInfolist`, `StandardTableDefaults`) to keep screens consistent.
|
- New resources/pages SHOULD use shared layout builders (e.g., `MainAsideForm`, `MainAsideInfolist`, `StandardTableDefaults`) to keep screens consistent.
|
||||||
- A change is not “Done” unless UX-001 is satisfied OR an explicit exemption exists with a documented rationale in the spec/PR.
|
- A change is not “Done” unless UX-001 is satisfied OR an explicit exemption exists with a documented rationale in the spec/PR.
|
||||||
|
|
||||||
|
### Operator-facing UI Naming Standards (UI-NAMING-001)
|
||||||
|
|
||||||
|
Goal: operator-facing actions, run labels, notifications, audit prose, and related UI copy MUST use consistent,
|
||||||
|
enterprise-grade product language.
|
||||||
|
|
||||||
|
Naming model
|
||||||
|
- Operator-facing copy MUST distinguish four layers: Scope, Source/Domain, Operation, and Target Object.
|
||||||
|
- Scope terms (`Workspace`, `Tenant`) describe execution context and MUST NOT be used as the primary action label unless they are the actual target object.
|
||||||
|
- Source/Domain terms (`Intune`, `Entra`, `Teams`, future providers) are secondary and MUST NOT lead the primary label unless the current screen presents competing sources that need explicit disambiguation.
|
||||||
|
|
||||||
|
Primary action labels
|
||||||
|
- Primary buttons, header actions, and menu actions MUST use `Verb + Object`.
|
||||||
|
- Preferred examples: `Sync policies`, `Sync groups`, `Capture baseline`, `Compare baseline`, `Restore policy`, `Run review`, `Export review pack`.
|
||||||
|
- Forbidden examples: `Sync from tenant`, `Backup tenant`, `Compare tenant`, `Sync from Intune`, `Run tenant sync now`, `Start inventory refresh from provider`.
|
||||||
|
|
||||||
|
Domain vocabulary
|
||||||
|
- Operator-facing copy MUST prefer product-domain objects such as `policies`, `groups`, `baseline`, `findings`, `review pack`, `alerts`, and `operations`.
|
||||||
|
- Primary operator-facing copy MUST NOT use implementation-first terms such as `provider`, `gateway`, `resolver`, `collector`, `contract registry`, or `job dispatch`.
|
||||||
|
- Source/domain details MAY appear in modal descriptions, helper text, run metadata, audit metadata, and notifications when needed for precision.
|
||||||
|
|
||||||
|
Run, notification, and audit semantics
|
||||||
|
- Visible run titles MUST use the same domain vocabulary as the initiating action and SHOULD be concise noun phrases such as `Policy sync`, `Baseline capture`, `Baseline compare`, `Policy restore`, and `Tenant review`.
|
||||||
|
- Notifications MUST use either `{Object} {state}` or `{Operation} {result}` and remain short, e.g. `Policy sync queued`, `Policy sync completed`, `Policy sync failed`, `Baseline compare detected drift`.
|
||||||
|
- Audit prose MUST use the same operator-facing language, e.g. `{actor} queued policy sync`, `{actor} captured baseline`, `{actor} reopened finding`.
|
||||||
|
- The same user-visible action MUST keep the same domain vocabulary across button labels, modal titles, run titles, notifications, and audit prose.
|
||||||
|
|
||||||
|
Verb standard
|
||||||
|
- Preferred verbs are `Sync`, `Capture`, `Compare`, `Restore`, `Review`, `Export`, `Open`, `Archive`, `Resolve`, `Reopen`, and `Assign`.
|
||||||
|
- `Start`, `Execute`, `Trigger`, and `Perform` SHOULD be avoided for operator-facing copy unless there is a deliberate domain reason.
|
||||||
|
- `Run` MAY be used only when the object is itself run-like, such as `Run review` or `Run compare`; it MUST NOT be the generic fallback verb for all operations.
|
||||||
|
|
||||||
|
Current binding decision
|
||||||
|
- The Policies screen primary action MUST be `Sync policies`.
|
||||||
|
- The Policies screen modal title MUST be `Sync policies`.
|
||||||
|
- The Policies screen success toast MUST be `Policy sync queued`.
|
||||||
|
- The visible run label for that action MUST be `Policy sync`.
|
||||||
|
- The audit prose for that action MUST be `{actor} queued policy sync`.
|
||||||
|
|
||||||
|
### Operator Surface Principles (OPSURF-001)
|
||||||
|
|
||||||
|
Goal: operator-facing surfaces MUST optimize for the primary working audience rather than raw implementation visibility.
|
||||||
|
|
||||||
|
Operator-first default surfaces
|
||||||
|
- `/admin` is operator-first.
|
||||||
|
- Default-visible content MUST use operator-facing language, clear scope, and actionable status communication.
|
||||||
|
- Raw implementation details MUST NOT be default-visible on primary operator surfaces.
|
||||||
|
|
||||||
|
Progressive disclosure for diagnostics
|
||||||
|
- Diagnostic detail MAY be available where needed, but it MUST be secondary and explicitly revealed.
|
||||||
|
- JSON payloads, raw IDs, internal field names, provider error details, and low-level technical metadata belong in diagnostics surfaces, drawers, tabs, accordions, or modals rather than primary content.
|
||||||
|
- A surface MUST NOT require operators to parse raw JSON or provider-specific field names to understand the primary state or next action.
|
||||||
|
|
||||||
|
Distinct status dimensions
|
||||||
|
- Operator-facing surfaces MUST distinguish at least the following dimensions when they all exist in the domain:
|
||||||
|
- execution outcome
|
||||||
|
- data completeness
|
||||||
|
- governance result
|
||||||
|
- lifecycle or readiness state
|
||||||
|
- These dimensions MUST NOT be collapsed into a single ambiguous status model.
|
||||||
|
- If a surface summarizes multiple status dimensions, the default-visible presentation MUST label each dimension explicitly.
|
||||||
|
|
||||||
|
Explicit mutation scope
|
||||||
|
- Every action that changes state MUST communicate before execution whether it affects:
|
||||||
|
- TenantPilot only
|
||||||
|
- the Microsoft tenant
|
||||||
|
- simulation only
|
||||||
|
- Mutation scope MUST be understandable from the action label, helper text, confirmation copy, preview, or nearby status copy before the operator commits.
|
||||||
|
- A mutating action MUST NOT rely on hidden implementation knowledge to communicate its blast radius.
|
||||||
|
|
||||||
|
Safe execution for dangerous actions
|
||||||
|
- Dangerous actions MUST follow a consistent safe-execution pattern:
|
||||||
|
- configuration
|
||||||
|
- safety checks or simulation
|
||||||
|
- preview
|
||||||
|
- hard confirmation where required
|
||||||
|
- execute
|
||||||
|
- One-click destructive actions are not acceptable for high-blast-radius operations.
|
||||||
|
- When a full multi-step flow is not feasible, the spec MUST document the explicit exemption and the replacement safeguards.
|
||||||
|
|
||||||
|
Explicit workspace and tenant context
|
||||||
|
- Workspace and tenant scope MUST remain explicit in navigation, actions, and page semantics.
|
||||||
|
- Tenant-scoped surfaces MUST NOT silently expose workspace-wide actions.
|
||||||
|
- Canonical workspace views that reference tenant-owned records MUST make the workspace and tenant context legible before the operator acts.
|
||||||
|
|
||||||
|
Page contract requirement
|
||||||
|
- Every new or materially refactored operator-facing page MUST define:
|
||||||
|
- primary persona
|
||||||
|
- surface type
|
||||||
|
- primary operator question
|
||||||
|
- default-visible information
|
||||||
|
- diagnostics-only information
|
||||||
|
- status dimensions used
|
||||||
|
- mutation scope
|
||||||
|
- primary actions
|
||||||
|
- dangerous actions
|
||||||
|
- This page contract MUST be recorded in the governing spec and kept in sync when the page semantics materially change.
|
||||||
|
|
||||||
Spec Scope Fields (SCOPE-002)
|
Spec Scope Fields (SCOPE-002)
|
||||||
|
|
||||||
- Every feature spec MUST declare:
|
- Every feature spec MUST declare:
|
||||||
@ -320,6 +498,50 @@ ### Badge Semantics Are Centralized (BADGE-001)
|
|||||||
- Introducing or changing a status-like value MUST include updating the relevant badge mapper and adding/updating tests for the mapping.
|
- Introducing or changing a status-like value MUST include updating the relevant badge mapper and adding/updating tests for the mapping.
|
||||||
- Tag/category chips (e.g., type/platform/environment) are not status-like and are not governed by BADGE-001.
|
- Tag/category chips (e.g., type/platform/environment) are not status-like and are not governed by BADGE-001.
|
||||||
|
|
||||||
|
### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
||||||
|
- Admin and operator-facing surfaces MUST use native Filament components, existing shared UI primitives, and centralized design patterns first.
|
||||||
|
- If Filament already provides the required semantic element, feature code MUST use the Filament-native component instead of a locally assembled replacement.
|
||||||
|
- Preferred native elements include `x-filament::badge`, `x-filament::button`, `x-filament::icon`, and Filament Forms, Infolists, Tables, Sections, Tabs, Grids, and Actions.
|
||||||
|
|
||||||
|
Forbidden local replacements
|
||||||
|
- Feature code MUST NOT hand-build badges, pills, status chips, alert cards, or action buttons from raw `<span>`, `<div>`, or `<button>` markup plus Tailwind classes when Filament-native or shared project primitives can express the same meaning.
|
||||||
|
- Feature code MUST NOT introduce page-local visual status languages for status, risk, outcome, drift, trust, importance, or severity.
|
||||||
|
- Feature code MUST NOT make local color, border, rounding, or emphasis decisions for semantic UI states using ad-hoc classes such as `bg-danger-100`, `text-warning-900`, `border-dashed`, or `rounded-full` when the same state can be expressed through Filament props or shared primitives.
|
||||||
|
|
||||||
|
Shared primitive before local override
|
||||||
|
- If the same UI pattern can recur, it MUST use an existing shared primitive or introduce a new central primitive instead of reassembling the pattern inside a Blade view.
|
||||||
|
- Central badge and status catalogs remain the canonical source for status semantics; local views MUST consume them rather than re-map them.
|
||||||
|
|
||||||
|
Upgrade-safe preference
|
||||||
|
- Update-safe, framework-native implementations take priority over page-local styling shortcuts.
|
||||||
|
- Filament props such as `color`, `icon`, and standard component composition are the default mechanism for semantic emphasis.
|
||||||
|
- Publishing or emulating Filament internals for cosmetic speed is not acceptable when native composition or shared primitives are sufficient.
|
||||||
|
|
||||||
|
Exception rule
|
||||||
|
- Ad-hoc markup or styling is allowed only when all of the following are true:
|
||||||
|
- native Filament components cannot express the required semantics,
|
||||||
|
- no suitable shared primitive exists,
|
||||||
|
- and the deviation is justified briefly in code and in the governing spec or PR.
|
||||||
|
- Approved exceptions MUST stay layout-neutral, use the minimum local classes necessary, and MUST NOT invent a new page-local status language.
|
||||||
|
|
||||||
|
Review and enforcement
|
||||||
|
- Every UI review MUST answer:
|
||||||
|
- which native Filament element or shared primitive was used,
|
||||||
|
- why an existing component was insufficient if an exception was taken,
|
||||||
|
- and whether any ad-hoc status or emphasis styling was introduced.
|
||||||
|
- UI work is not Done if it introduces ad-hoc status styling or framework-foreign replacement components where a native Filament or shared UI solution was viable.
|
||||||
|
|
||||||
|
### Incremental UI Standards Enforcement (UI-STD-001)
|
||||||
|
- UI consistency is enforced incrementally, not by recurring cleanup passes.
|
||||||
|
- New tables, filters, and list surfaces MUST follow established Filament-native standards from the first implementation.
|
||||||
|
- Deviations MUST be explicit and justified in the spec or PR.
|
||||||
|
- Canonical standards live in `docs/product/standards/` and are the source of truth for:
|
||||||
|
- Table UX (column tiers, sort, search, toggle, pagination, persistence, empty states)
|
||||||
|
- Filter UX (persistence, soft-delete, date range, enum sourcing, defaults)
|
||||||
|
- Actions UX (row/bulk/header actions, grouping, destructive safety)
|
||||||
|
- Guard tests enforce critical constraints automatically; the list surface review checklist catches the rest.
|
||||||
|
- A new spec that adds or modifies a list surface MUST reference the review checklist (`docs/product/standards/list-surface-review-checklist.md`).
|
||||||
|
|
||||||
### Spec-First Workflow
|
### Spec-First Workflow
|
||||||
- For any feature that changes runtime behavior, include or update `specs/<NNN>-<slug>/` with `spec.md`, `plan.md`, `tasks.md`, and `checklists/requirements.md`.
|
- For any feature that changes runtime behavior, include or update `specs/<NNN>-<slug>/` with `spec.md`, `plan.md`, `tasks.md`, and `checklists/requirements.md`.
|
||||||
- New work branches from `dev` using `feat/<NNN>-<slug>` (spec + code in the same PR).
|
- New work branches from `dev` using `feat/<NNN>-<slug>` (spec + code in the same PR).
|
||||||
@ -330,9 +552,12 @@ ## Quality Gates
|
|||||||
|
|
||||||
## Governance
|
## Governance
|
||||||
|
|
||||||
### Scope & Compliance
|
### Scope, Compliance, and Review Expectations
|
||||||
- This constitution applies across the repo. Feature specs may add stricter constraints but not weaker ones.
|
- This constitution applies across the repo. Feature specs may add stricter constraints but not weaker ones.
|
||||||
- Restore semantics changes require: spec update, checklist update (if applicable), and tests proving safety.
|
- Restore semantics changes require: spec update, checklist update (if applicable), and tests proving safety.
|
||||||
|
- Specs and PRs that introduce new persisted truth, abstractions, states, DTO/presenter layers, or taxonomies MUST include the proportionality review required by BLOAT-001.
|
||||||
|
- Review and approval MUST favor simplification, replacement, and absorption over additive semantic layering.
|
||||||
|
- Future-release preparation alone is not sufficient justification for new persistence or frameworkization unless security, tenant isolation, auditability, compliance evidence, or queue correctness already require it.
|
||||||
|
|
||||||
### Amendment Procedure
|
### Amendment Procedure
|
||||||
- Propose changes as a PR that updates `.specify/memory/constitution.md`.
|
- Propose changes as a PR that updates `.specify/memory/constitution.md`.
|
||||||
@ -344,4 +569,4 @@ ### Versioning Policy (SemVer)
|
|||||||
- **MINOR**: new principle/section or materially expanded guidance.
|
- **MINOR**: new principle/section or materially expanded guidance.
|
||||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||||
|
|
||||||
**Version**: 1.10.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-02-23
|
**Version**: 1.14.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-27
|
||||||
|
|||||||
@ -48,8 +48,24 @@ ## Constitution Check
|
|||||||
- Ops-UX system runs: initiator-null runs emit no terminal DB notification; audit remains via Monitoring; tenant-wide alerting goes through Alerts (not OperationRun notifications)
|
- Ops-UX system runs: initiator-null runs emit no terminal DB notification; audit remains via Monitoring; tenant-wide alerting goes through Alerts (not OperationRun notifications)
|
||||||
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
|
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
|
||||||
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
|
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
|
||||||
|
- Proportionality (PROP-001): any new structure, layer, persisted truth, or semantic machinery is justified by current release truth, current operator workflow, and why a narrower solution is insufficient
|
||||||
|
- No premature abstraction (ABSTR-001): no new factories, registries, resolvers, strategy systems, interfaces, type registries, or orchestration pipelines before at least 2 real concrete cases exist, unless security, tenant isolation, auditability, compliance evidence, or queue correctness require it now
|
||||||
|
- Persisted truth (PERSIST-001): new tables/entities/artifacts represent independent product truth or lifecycle; convenience projections and UI helpers stay derived
|
||||||
|
- Behavioral state (STATE-001): new states/statuses/reason codes change behavior, routing, permissions, lifecycle, audit, retention, or retry handling; presentation-only distinctions stay derived
|
||||||
|
- UI semantics (UI-SEM-001): avoid turning badges, explanation text, trust/confidence labels, or detail summaries into mandatory interpretation frameworks; prefer direct domain-to-UI mapping
|
||||||
|
- V1 explicitness / few layers (V1-EXP-001, LAYER-001): prefer direct implementation, local mappings, and small helpers; any new layer replaces an old one or proves the old one cannot serve
|
||||||
|
- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): related semantic changes are grouped coherently, and any new enum, DTO/presenter, persisted entity, interface/registry/resolver, or taxonomy includes a proportionality review covering operator problem, insufficiency, narrowness, ownership cost, rejected alternative, and whether it is current-release truth
|
||||||
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
||||||
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted- Filament UI UX-001 (Layout & IA): Create/Edit uses Main/Aside (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)); all fields inside Sections/Cards (no naked inputs); View uses Infolists (not disabled edit forms); status badges use BADGE-001; empty states have specific title + explanation + 1 CTA; max 1 primary + 1 secondary header action; tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency
|
- Filament-native UI (UI-FIL-001): admin/operator surfaces use native Filament components or shared primitives first; no ad-hoc status UI, local semantic color/border decisions, or hand-built replacements when native/shared semantics exist; any exception is explicitly justified
|
||||||
|
- UI naming (UI-NAMING-001): operator-facing labels use `Verb + Object`; scope (`Workspace`, `Tenant`) is never the primary action label; source/domain is secondary unless disambiguation is required; runs/toasts/audit prose use the same domain vocabulary; implementation-first terms do not appear in primary operator UI
|
||||||
|
- Operator surfaces (OPSURF-001): `/admin` defaults are operator-first; default-visible content avoids raw implementation detail; diagnostics are explicitly revealed secondarily
|
||||||
|
- Operator surfaces (OPSURF-001): execution outcome, data completeness, governance result, and lifecycle/readiness are modeled as distinct status dimensions when all apply; they are not collapsed into one ambiguous status
|
||||||
|
- Operator surfaces (OPSURF-001): every mutating action communicates whether it changes TenantPilot only, the Microsoft tenant, or simulation only before execution
|
||||||
|
- Operator surfaces (OPSURF-001): dangerous actions follow configuration → safety checks/simulation → preview → hard confirmation where required → execute, unless a spec documents an explicit exemption and replacement safeguards
|
||||||
|
- Operator surfaces (OPSURF-001): workspace and tenant context remain explicit in navigation, actions, and page semantics; tenant surfaces do not silently expose workspace-wide actions
|
||||||
|
- Operator surfaces (OPSURF-001): each new or materially refactored operator-facing page defines a page contract covering persona, surface type, operator question, default-visible info, diagnostics-only info, status dimensions, mutation scope, primary actions, and dangerous actions
|
||||||
|
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted
|
||||||
|
- Filament UI UX-001 (Layout & IA): Create/Edit uses Main/Aside (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)); all fields inside Sections/Cards (no naked inputs); View uses Infolists (not disabled edit forms); status badges use BADGE-001; empty states have specific title + explanation + 1 CTA; max 1 primary + 1 secondary header action; tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
### Documentation (this feature)
|
### Documentation (this feature)
|
||||||
@ -113,9 +129,20 @@ # [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected)
|
|||||||
|
|
||||||
## Complexity Tracking
|
## Complexity Tracking
|
||||||
|
|
||||||
> **Fill ONLY if Constitution Check has violations that must be justified**
|
> **Fill when Constitution Check has violations that must be justified OR when BLOAT-001 is triggered by new persistence, abstractions, states, or semantic frameworks.**
|
||||||
|
|
||||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|-----------|------------|-------------------------------------|
|
|-----------|------------|-------------------------------------|
|
||||||
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
|
||||||
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
|
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
> **Fill when the feature introduces a new enum/status family, DTO/presenter/envelope, persisted entity/table/artifact, interface/contract/registry/resolver, taxonomy/classification system, or cross-domain UI framework.**
|
||||||
|
|
||||||
|
- **Current operator problem**: [What present-day workflow or risk requires this?]
|
||||||
|
- **Existing structure is insufficient because**: [Why the current code cannot serve safely or clearly]
|
||||||
|
- **Narrowest correct implementation**: [Why this shape is the smallest viable one]
|
||||||
|
- **Ownership cost created**: [Maintenance, testing, cognitive load, migration, or review burden]
|
||||||
|
- **Alternative intentionally rejected**: [Simpler option and why it failed]
|
||||||
|
- **Release truth**: [Current-release truth or future-release preparation]
|
||||||
|
|||||||
@ -17,6 +17,35 @@ ## Spec Scope Fields *(mandatory)*
|
|||||||
- **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant]
|
- **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant]
|
||||||
- **Explicit entitlement checks preventing cross-tenant leakage**: [Describe checks]
|
- **Explicit entitlement checks preventing cross-tenant leakage**: [Describe checks]
|
||||||
|
|
||||||
|
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
If this feature adds a new operator-facing page or materially refactors one, fill out one row per affected page/surface.
|
||||||
|
|
||||||
|
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| e.g. Tenant policies page | Tenant operator | List/detail | What needs action right now? | Policy health, drift, assignment coverage | Raw payloads, provider IDs, low-level API details | lifecycle, data completeness, governance result | TenantPilot only / Microsoft tenant / simulation only | Sync policies, View policy | Restore policy |
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
Fill this section if the feature introduces any of the following:
|
||||||
|
- a new source of truth
|
||||||
|
- a new persisted entity, table, or artifact
|
||||||
|
- a new abstraction (interface, contract, registry, resolver, strategy, factory, orchestration layer)
|
||||||
|
- a new enum, status family, reason code family, or lifecycle category
|
||||||
|
- a new cross-domain UI framework, taxonomy, or classification system
|
||||||
|
|
||||||
|
- **New source of truth?**: [yes/no]
|
||||||
|
- **New persisted entity/table/artifact?**: [yes/no]
|
||||||
|
- **New abstraction?**: [yes/no]
|
||||||
|
- **New enum/state/reason family?**: [yes/no]
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: [yes/no]
|
||||||
|
- **Current operator problem**: [What present-day workflow or risk does this solve?]
|
||||||
|
- **Existing structure is insufficient because**: [Why the current implementation shape cannot safely or clearly solve it]
|
||||||
|
- **Narrowest correct implementation**: [Why this is the smallest viable solution]
|
||||||
|
- **Ownership cost**: [What maintenance, testing, review, migration, or conceptual cost this adds]
|
||||||
|
- **Alternative intentionally rejected**: [What simpler option was considered and why it was not sufficient]
|
||||||
|
- **Release truth**: [Current-release truth or future-release preparation]
|
||||||
|
|
||||||
## User Scenarios & Testing *(mandatory)*
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@ -94,6 +123,16 @@ ## Requirements *(mandatory)*
|
|||||||
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
|
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
|
||||||
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
|
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
|
||||||
|
|
||||||
|
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** If this feature introduces new persistence,
|
||||||
|
new abstractions, new states, or new semantic layers, the spec MUST explain:
|
||||||
|
- which current operator workflow or current product truth requires the addition now,
|
||||||
|
- why a narrower implementation is insufficient,
|
||||||
|
- whether the addition is current-release truth or future-release preparation,
|
||||||
|
- what ownership cost it creates,
|
||||||
|
- and how the choice follows the default bias of deriving before persisting, replacing before layering, and being explicit before generic.
|
||||||
|
If the feature introduces a new enum/status family, DTO/presenter/envelope, persisted entity/table, interface/contract/registry/resolver,
|
||||||
|
or taxonomy/classification system, the Proportionality Review section above is mandatory.
|
||||||
|
|
||||||
**Constitution alignment (OPS-UX):** If this feature creates/reuses an `OperationRun`, the spec MUST:
|
**Constitution alignment (OPS-UX):** If this feature creates/reuses an `OperationRun`, the spec MUST:
|
||||||
- explicitly state compliance with the Ops-UX 3-surface feedback contract (toast intent-only, progress surfaces, terminal DB notification),
|
- explicitly state compliance with the Ops-UX 3-surface feedback contract (toast intent-only, progress surfaces, terminal DB notification),
|
||||||
- state that `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`),
|
- state that `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`),
|
||||||
@ -119,9 +158,40 @@ ## Requirements *(mandatory)*
|
|||||||
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
|
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
|
||||||
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
|
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** If this feature adds or changes Filament or Blade UI for admin/operator surfaces, the spec MUST describe:
|
||||||
|
- which native Filament components or shared UI primitives are used,
|
||||||
|
- whether any local replacement markup was avoided for badges, alerts, buttons, or status surfaces,
|
||||||
|
- how semantic emphasis is expressed through Filament props or central primitives rather than page-local color/border classes,
|
||||||
|
- and any exception where Filament or a shared primitive was insufficient, including why the exception is necessary and how it avoids introducing a new local status language.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** If this feature adds or changes operator-facing buttons, header actions, run titles,
|
||||||
|
notifications, audit prose, or related helper copy, the spec MUST describe:
|
||||||
|
- the target object,
|
||||||
|
- the operator verb,
|
||||||
|
- whether source/domain disambiguation is actually needed,
|
||||||
|
- how the same domain vocabulary is preserved across button labels, modal titles, run titles, notifications, and audit prose,
|
||||||
|
- and how implementation-first terms are kept out of primary operator-facing labels.
|
||||||
|
|
||||||
|
**Constitution alignment (OPSURF-001):** If this feature adds or materially refactors an operator-facing surface, the spec MUST describe:
|
||||||
|
- how the default-visible content stays operator-first on `/admin` and avoids raw implementation detail,
|
||||||
|
- which diagnostics are secondary and how they are explicitly revealed,
|
||||||
|
- which status dimensions are shown separately (execution outcome, data completeness, governance result, lifecycle/readiness) and why,
|
||||||
|
- how each mutating action communicates its mutation scope before execution (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
||||||
|
- how dangerous actions follow the safe-execution pattern (configuration, safety checks/simulation, preview, hard confirmation where required, execute),
|
||||||
|
- how workspace and tenant context remain explicit in navigation, action copy, and page semantics,
|
||||||
|
- and the page contract for each new or materially refactored operator-facing page.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** If this feature adds UI semantics, presenters, explanation layers,
|
||||||
|
status taxonomies, or other interpretation layers, the spec MUST describe:
|
||||||
|
- why direct mapping from canonical domain truth to UI is insufficient,
|
||||||
|
- which existing layer is replaced or why no existing layer can serve,
|
||||||
|
- how the feature avoids creating redundant truth across models, service results, presenters, summaries, wrappers, and persisted mirrors,
|
||||||
|
- and how tests focus on business consequences rather than thin indirection alone.
|
||||||
|
|
||||||
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
|
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
|
||||||
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
|
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
|
||||||
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
|
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
|
||||||
|
The same section MUST also state whether UI-FIL-001 is satisfied and identify any approved exception.
|
||||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** If this feature adds or modifies any Filament screen,
|
**Constitution alignment (UX-001 — Layout & Information Architecture):** If this feature adds or modifies any Filament screen,
|
||||||
the spec MUST describe compliance with UX-001: Create/Edit uses Main/Aside layout (3-col grid), all fields inside Sections/Cards
|
the spec MUST describe compliance with UX-001: Create/Edit uses Main/Aside layout (3-col grid), all fields inside Sections/Cards
|
||||||
(no naked inputs), View pages use Infolists (not disabled edit forms), status badges use BADGE-001, empty states have a specific
|
(no naked inputs), View pages use Infolists (not disabled edit forms), status badges use BADGE-001, empty states have a specific
|
||||||
|
|||||||
@ -32,6 +32,19 @@ # Tasks: [FEATURE NAME]
|
|||||||
- destructive-like actions use `->requiresConfirmation()` (authorization still server-side),
|
- destructive-like actions use `->requiresConfirmation()` (authorization still server-side),
|
||||||
- cross-plane deny-as-not-found (404) checks where applicable,
|
- cross-plane deny-as-not-found (404) checks where applicable,
|
||||||
- at least one positive + one negative authorization test.
|
- at least one positive + one negative authorization test.
|
||||||
|
**UI Naming**: If this feature adds or changes operator-facing actions, run titles, notifications, audit prose, or helper copy, tasks MUST include:
|
||||||
|
- aligning primary action labels to `Verb + Object`,
|
||||||
|
- keeping scope terms (`Workspace`, `Tenant`) out of primary action labels unless they are the actual target object,
|
||||||
|
- using source/domain terms only where same-screen disambiguation is required,
|
||||||
|
- aligning button labels, modal titles, run titles, notifications, and audit prose to the same domain vocabulary,
|
||||||
|
- removing implementation-first wording from primary operator-facing copy.
|
||||||
|
**Operator Surfaces**: If this feature adds or materially refactors an operator-facing page or flow, tasks MUST include:
|
||||||
|
- filling the spec’s Operator Surface Contract for every affected page,
|
||||||
|
- making default-visible content operator-first and moving JSON payloads, raw IDs, internal field names, provider error details, and low-level metadata into explicitly revealed diagnostics surfaces,
|
||||||
|
- modeling execution outcome, data completeness, governance result, and lifecycle/readiness as distinct status dimensions when applicable,
|
||||||
|
- making mutation scope legible before execution for every state-changing action (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
||||||
|
- implementing the safe-execution flow for dangerous actions (configuration, safety checks/simulation, preview, hard confirmation where required, execute) or documenting an approved exemption,
|
||||||
|
- keeping workspace and tenant context explicit in navigation, actions, and page semantics so tenant pages do not silently expose workspace-wide actions.
|
||||||
**Filament UI Action Surfaces**: If this feature adds/modifies any Filament Resource / RelationManager / Page, tasks MUST include:
|
**Filament UI Action Surfaces**: If this feature adds/modifies any Filament Resource / RelationManager / Page, tasks MUST include:
|
||||||
- filling the spec’s “UI Action Matrix” for all changed surfaces,
|
- filling the spec’s “UI Action Matrix” for all changed surfaces,
|
||||||
- implementing required action surfaces (header/row/bulk/empty-state CTA for lists; header actions for view; consistent save/cancel on create/edit),
|
- implementing required action surfaces (header/row/bulk/empty-state CTA for lists; header actions for view; consistent save/cancel on create/edit),
|
||||||
@ -40,6 +53,9 @@ # Tasks: [FEATURE NAME]
|
|||||||
- grouping bulk actions via BulkActionGroup,
|
- grouping bulk actions via BulkActionGroup,
|
||||||
- adding confirmations for destructive actions (and typed confirmation where required by scale),
|
- adding confirmations for destructive actions (and typed confirmation where required by scale),
|
||||||
- adding `AuditLog` entries for relevant mutations,
|
- adding `AuditLog` entries for relevant mutations,
|
||||||
|
- using native Filament components or shared UI primitives before any local Blade/Tailwind assembly for badges, alerts, buttons, and semantic status surfaces,
|
||||||
|
- avoiding page-local semantic color, border, rounding, or highlight styling when Filament props or shared primitives can express the same state,
|
||||||
|
- documenting any UI-FIL-001 exception with rationale in the spec/PR,
|
||||||
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
|
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
|
||||||
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
|
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
|
||||||
- ensuring Create/Edit pages use Main/Aside layout (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)),
|
- ensuring Create/Edit pages use Main/Aside layout (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)),
|
||||||
@ -51,6 +67,13 @@ # Tasks: [FEATURE NAME]
|
|||||||
- OR documenting an explicit exemption with rationale if UX-001 is not fully satisfied.
|
- OR documenting an explicit exemption with rationale if UX-001 is not fully satisfied.
|
||||||
**Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001),
|
**Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001),
|
||||||
avoid ad-hoc mappings in Filament, and include mapping tests for any new/changed values.
|
avoid ad-hoc mappings in Filament, and include mapping tests for any new/changed values.
|
||||||
|
**Proportionality / Anti-Bloat**: If this feature introduces a new enum/status family, DTO/presenter/envelope, persisted entity/table/artifact,
|
||||||
|
interface/contract/registry/resolver, taxonomy/classification system, or cross-domain UI framework, tasks MUST include:
|
||||||
|
- completing the spec’s Proportionality Review,
|
||||||
|
- implementing the narrowest correct shape justified by current-release truth,
|
||||||
|
- removing or replacing superseded layers where practical instead of stacking new ones on top,
|
||||||
|
- keeping convenience projections and UI helpers derived unless independent persistence is explicitly justified,
|
||||||
|
- and adding tests around business consequences, permissions, lifecycle behavior, isolation, or audit responsibilities rather than thin indirection alone.
|
||||||
|
|
||||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||||
|
|
||||||
@ -197,6 +220,7 @@ ## Phase N: Polish & Cross-Cutting Concerns
|
|||||||
- [ ] TXXX Performance optimization across all stories
|
- [ ] TXXX Performance optimization across all stories
|
||||||
- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/
|
- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/
|
||||||
- [ ] TXXX Security hardening
|
- [ ] TXXX Security hardening
|
||||||
|
- [ ] TXXX Proportionality cleanup: remove or collapse superseded layers introduced during implementation
|
||||||
- [ ] TXXX Run quickstart.md validation
|
- [ ] TXXX Run quickstart.md validation
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@ -25,12 +25,14 @@ ## Scope Reference
|
|||||||
- Tenant-scoped RBAC and audit logs
|
- Tenant-scoped RBAC and audit logs
|
||||||
|
|
||||||
## Workflow (Spec Kit)
|
## Workflow (Spec Kit)
|
||||||
1. Read `.specify/constitution.md`
|
1. Read `.specify/memory/constitution.md`
|
||||||
2. For new work: create/update `specs/<NNN>-<slug>/spec.md`
|
2. For new work: create/update `specs/<NNN>-<slug>/spec.md`
|
||||||
3. Produce `specs/<NNN>-<slug>/plan.md`
|
3. Produce `specs/<NNN>-<slug>/plan.md`
|
||||||
4. Break into `specs/<NNN>-<slug>/tasks.md`
|
4. Break into `specs/<NNN>-<slug>/tasks.md`
|
||||||
5. Implement changes in small PRs
|
5. Implement changes in small PRs
|
||||||
|
|
||||||
|
Any spec that introduces a new persisted entity, abstraction, enum/status family, or taxonomy/framework must include the proportionality review required by the constitution before implementation starts.
|
||||||
|
|
||||||
If requirements change during implementation, update spec/plan before continuing.
|
If requirements change during implementation, update spec/plan before continuing.
|
||||||
|
|
||||||
## Workflow (SDD in diesem Repo)
|
## Workflow (SDD in diesem Repo)
|
||||||
@ -681,7 +683,7 @@ ## Foundational Context
|
|||||||
|
|
||||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||||
|
|
||||||
- php - 8.4.1
|
- php - 8.4.15
|
||||||
- filament/filament (FILAMENT) - v5
|
- filament/filament (FILAMENT) - v5
|
||||||
- laravel/framework (LARAVEL) - v12
|
- laravel/framework (LARAVEL) - v12
|
||||||
- laravel/prompts (PROMPTS) - v0
|
- laravel/prompts (PROMPTS) - v0
|
||||||
|
|||||||
@ -521,7 +521,7 @@ ## Foundational Context
|
|||||||
|
|
||||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||||
|
|
||||||
- php - 8.4.1
|
- php - 8.4.15
|
||||||
- filament/filament (FILAMENT) - v5
|
- filament/filament (FILAMENT) - v5
|
||||||
- laravel/framework (LARAVEL) - v12
|
- laravel/framework (LARAVEL) - v12
|
||||||
- laravel/prompts (PROMPTS) - v0
|
- laravel/prompts (PROMPTS) - v0
|
||||||
|
|||||||
232
app/Console/Commands/ClassifyProviderConnections.php
Normal file
232
app/Console/Commands/ClassifyProviderConnections.php
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\ProviderCredential;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Intune\AuditLogger;
|
||||||
|
use App\Services\Providers\ProviderConnectionClassificationResult;
|
||||||
|
use App\Services\Providers\ProviderConnectionClassifier;
|
||||||
|
use App\Services\Providers\ProviderConnectionStateProjector;
|
||||||
|
use App\Support\Providers\ProviderConnectionType;
|
||||||
|
use App\Support\Providers\ProviderCredentialKind;
|
||||||
|
use App\Support\Providers\ProviderCredentialSource;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class ClassifyProviderConnections extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'tenantpilot:provider-connections:classify
|
||||||
|
{--tenant= : Restrict to a tenant id, external id, or tenant guid}
|
||||||
|
{--connection= : Restrict to a single provider connection id}
|
||||||
|
{--provider=microsoft : Restrict to one provider}
|
||||||
|
{--chunk=100 : Chunk size for large write runs}
|
||||||
|
{--write : Persist the classification results}';
|
||||||
|
|
||||||
|
protected $description = 'Classify legacy provider connections into platform, dedicated, or review-required outcomes.';
|
||||||
|
|
||||||
|
public function handle(
|
||||||
|
ProviderConnectionClassifier $classifier,
|
||||||
|
ProviderConnectionStateProjector $stateProjector,
|
||||||
|
): int {
|
||||||
|
$query = $this->query();
|
||||||
|
$write = (bool) $this->option('write');
|
||||||
|
$chunkSize = max(1, (int) $this->option('chunk'));
|
||||||
|
|
||||||
|
$candidateCount = (clone $query)->count();
|
||||||
|
|
||||||
|
if ($candidateCount === 0) {
|
||||||
|
$this->info('No provider connections matched the classification scope.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantCounts = (clone $query)
|
||||||
|
->selectRaw('tenant_id, count(*) as aggregate')
|
||||||
|
->groupBy('tenant_id')
|
||||||
|
->pluck('aggregate', 'tenant_id')
|
||||||
|
->map(static fn (mixed $count): int => (int) $count)
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$startedTenants = [];
|
||||||
|
$classifiedCount = 0;
|
||||||
|
$appliedCount = 0;
|
||||||
|
$reviewRequiredCount = 0;
|
||||||
|
|
||||||
|
$query
|
||||||
|
->with(['tenant', 'credential'])
|
||||||
|
->orderBy('id')
|
||||||
|
->chunkById($chunkSize, function ($connections) use (
|
||||||
|
$classifier,
|
||||||
|
$stateProjector,
|
||||||
|
$write,
|
||||||
|
$tenantCounts,
|
||||||
|
&$startedTenants,
|
||||||
|
&$classifiedCount,
|
||||||
|
&$appliedCount,
|
||||||
|
&$reviewRequiredCount,
|
||||||
|
): void {
|
||||||
|
foreach ($connections as $connection) {
|
||||||
|
$classifiedCount++;
|
||||||
|
|
||||||
|
$result = $classifier->classify(
|
||||||
|
$connection,
|
||||||
|
source: 'tenantpilot:provider-connections:classify',
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result->reviewRequired) {
|
||||||
|
$reviewRequiredCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $write) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $connection->tenant;
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
$this->warn(sprintf('Skipping provider connection #%d without tenant context.', (int) $connection->getKey()));
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantKey = (int) $tenant->getKey();
|
||||||
|
|
||||||
|
if (! array_key_exists($tenantKey, $startedTenants)) {
|
||||||
|
$this->auditStart($tenant, $tenantCounts[$tenantKey] ?? 0);
|
||||||
|
$startedTenants[$tenantKey] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$connection = $this->applyClassification($connection, $result, $stateProjector);
|
||||||
|
$this->auditApplied($tenant, $connection, $result);
|
||||||
|
$appliedCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($write) {
|
||||||
|
$this->info(sprintf('Applied classifications: %d', $appliedCount));
|
||||||
|
} else {
|
||||||
|
$this->info(sprintf('Dry-run classifications: %d', $classifiedCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf('Review required: %d', $reviewRequiredCount));
|
||||||
|
$this->info(sprintf('Mode: %s', $write ? 'write' : 'dry-run'));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function query(): Builder
|
||||||
|
{
|
||||||
|
$query = ProviderConnection::query()
|
||||||
|
->where('provider', (string) $this->option('provider'));
|
||||||
|
|
||||||
|
$tenantOption = $this->option('tenant');
|
||||||
|
|
||||||
|
if (is_string($tenantOption) && trim($tenantOption) !== '') {
|
||||||
|
$tenant = Tenant::query()
|
||||||
|
->forTenant(trim($tenantOption))
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$query->where('tenant_id', (int) $tenant->getKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
$connectionOption = $this->option('connection');
|
||||||
|
|
||||||
|
if (is_numeric($connectionOption)) {
|
||||||
|
$query->whereKey((int) $connectionOption);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyClassification(
|
||||||
|
ProviderConnection $connection,
|
||||||
|
ProviderConnectionClassificationResult $result,
|
||||||
|
ProviderConnectionStateProjector $stateProjector,
|
||||||
|
): ProviderConnection {
|
||||||
|
DB::transaction(function () use ($connection, $result, $stateProjector): void {
|
||||||
|
$connection->forceFill(
|
||||||
|
$connection->classificationProjection($result, $stateProjector)
|
||||||
|
)->save();
|
||||||
|
|
||||||
|
$credential = $connection->credential;
|
||||||
|
|
||||||
|
if (! $credential instanceof ProviderCredential) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$updates = [];
|
||||||
|
|
||||||
|
if (
|
||||||
|
$result->suggestedConnectionType === ProviderConnectionType::Dedicated
|
||||||
|
&& $credential->source === null
|
||||||
|
) {
|
||||||
|
$updates['source'] = ProviderCredentialSource::LegacyMigrated->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($credential->credential_kind === null && $credential->type === ProviderCredentialKind::ClientSecret->value) {
|
||||||
|
$updates['credential_kind'] = ProviderCredentialKind::ClientSecret->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($updates !== []) {
|
||||||
|
$credential->forceFill($updates)->save();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $connection->fresh(['tenant', 'credential']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function auditStart(Tenant $tenant, int $candidateCount): void
|
||||||
|
{
|
||||||
|
app(AuditLogger::class)->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'provider_connection.migration_classification_started',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'source' => 'tenantpilot:provider-connections:classify',
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'candidate_count' => $candidateCount,
|
||||||
|
'write' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
resourceType: 'tenant',
|
||||||
|
resourceId: (string) $tenant->getKey(),
|
||||||
|
status: 'success',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function auditApplied(
|
||||||
|
Tenant $tenant,
|
||||||
|
ProviderConnection $connection,
|
||||||
|
ProviderConnectionClassificationResult $result,
|
||||||
|
): void {
|
||||||
|
$effectiveApp = $connection->effectiveAppMetadata();
|
||||||
|
|
||||||
|
app(AuditLogger::class)->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'provider_connection.migration_classification_applied',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'source' => 'tenantpilot:provider-connections:classify',
|
||||||
|
'workspace_id' => (int) $connection->workspace_id,
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'provider' => (string) $connection->provider,
|
||||||
|
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
||||||
|
'connection_type' => $connection->connection_type->value,
|
||||||
|
'migration_review_required' => $connection->migration_review_required,
|
||||||
|
'legacy_identity_result' => $result->suggestedConnectionType->value,
|
||||||
|
'effective_app_id' => $effectiveApp['app_id'],
|
||||||
|
'effective_app_source' => $effectiveApp['source'],
|
||||||
|
'signals' => $result->signals,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
resourceType: 'provider_connection',
|
||||||
|
resourceId: (string) $connection->getKey(),
|
||||||
|
status: 'success',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
153
app/Console/Commands/PurgeLegacyBaselineGapRuns.php
Normal file
153
app/Console/Commands/PurgeLegacyBaselineGapRuns.php
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class PurgeLegacyBaselineGapRuns extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'tenantpilot:baselines:purge-legacy-gap-runs
|
||||||
|
{--type=* : Limit cleanup to baseline_compare and/or baseline_capture runs}
|
||||||
|
{--tenant=* : Limit cleanup to tenant ids or tenant external ids}
|
||||||
|
{--workspace=* : Limit cleanup to workspace ids}
|
||||||
|
{--limit=500 : Maximum candidate runs to inspect}
|
||||||
|
{--force : Actually delete matched legacy runs}';
|
||||||
|
|
||||||
|
protected $description = 'Purge development-only baseline compare/capture runs that still use legacy broad gap payloads.';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
if (! app()->environment(['local', 'testing'])) {
|
||||||
|
$this->error('This cleanup command is limited to local and testing environments.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$types = $this->normalizedTypes();
|
||||||
|
$workspaceIds = array_values(array_filter(
|
||||||
|
array_map(
|
||||||
|
static fn (mixed $workspaceId): int => is_numeric($workspaceId) ? (int) $workspaceId : 0,
|
||||||
|
(array) $this->option('workspace'),
|
||||||
|
),
|
||||||
|
static fn (int $workspaceId): bool => $workspaceId > 0,
|
||||||
|
));
|
||||||
|
$tenantIds = $this->resolveTenantIds(array_values(array_filter((array) $this->option('tenant'))));
|
||||||
|
$limit = max(1, (int) $this->option('limit'));
|
||||||
|
$dryRun = ! (bool) $this->option('force');
|
||||||
|
|
||||||
|
$query = OperationRun::query()
|
||||||
|
->whereIn('type', $types)
|
||||||
|
->orderBy('id')
|
||||||
|
->limit($limit);
|
||||||
|
|
||||||
|
if ($workspaceIds !== []) {
|
||||||
|
$query->whereIn('workspace_id', $workspaceIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenantIds !== []) {
|
||||||
|
$query->whereIn('tenant_id', $tenantIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidates = $query->get();
|
||||||
|
$matched = $candidates
|
||||||
|
->filter(static fn (OperationRun $run): bool => $run->hasLegacyBaselineGapPayload())
|
||||||
|
->values();
|
||||||
|
|
||||||
|
if ($matched->isEmpty()) {
|
||||||
|
$this->info('No legacy baseline gap runs matched the current filters.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['Run', 'Type', 'Tenant', 'Workspace', 'Legacy signal'],
|
||||||
|
$matched
|
||||||
|
->map(fn (OperationRun $run): array => [
|
||||||
|
'Run' => (string) $run->getKey(),
|
||||||
|
'Type' => (string) $run->type,
|
||||||
|
'Tenant' => $run->tenant_id !== null ? (string) $run->tenant_id : '—',
|
||||||
|
'Workspace' => $run->workspace_id !== null ? (string) $run->workspace_id : '—',
|
||||||
|
'Legacy signal' => $this->legacySignal($run),
|
||||||
|
])
|
||||||
|
->all(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn(sprintf(
|
||||||
|
'Dry run: matched %d legacy baseline run(s). Re-run with --force to delete them.',
|
||||||
|
$matched->count(),
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
OperationRun::query()
|
||||||
|
->whereKey($matched->modelKeys())
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
$this->info(sprintf('Deleted %d legacy baseline run(s).', $matched->count()));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function normalizedTypes(): array
|
||||||
|
{
|
||||||
|
$types = array_values(array_unique(array_filter(
|
||||||
|
array_map(
|
||||||
|
static fn (mixed $type): ?string => is_string($type) && trim($type) !== '' ? trim($type) : null,
|
||||||
|
(array) $this->option('type'),
|
||||||
|
),
|
||||||
|
)));
|
||||||
|
|
||||||
|
if ($types === []) {
|
||||||
|
return ['baseline_compare', 'baseline_capture'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_filter(
|
||||||
|
$types,
|
||||||
|
static fn (string $type): bool => in_array($type, ['baseline_compare', 'baseline_capture'], true),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $tenantIdentifiers
|
||||||
|
* @return array<int, int>
|
||||||
|
*/
|
||||||
|
private function resolveTenantIds(array $tenantIdentifiers): array
|
||||||
|
{
|
||||||
|
if ($tenantIdentifiers === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantIds = [];
|
||||||
|
|
||||||
|
foreach ($tenantIdentifiers as $identifier) {
|
||||||
|
$tenant = Tenant::query()->forTenant($identifier)->first();
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
$tenantIds[] = (int) $tenant->getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($tenantIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function legacySignal(OperationRun $run): string
|
||||||
|
{
|
||||||
|
$byReason = $run->baselineGapEnvelope()['by_reason'] ?? null;
|
||||||
|
$byReason = is_array($byReason) ? $byReason : [];
|
||||||
|
|
||||||
|
if (array_key_exists('policy_not_found', $byReason)) {
|
||||||
|
return 'legacy_reason_code';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'legacy_subject_shape';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,6 +12,7 @@
|
|||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
@ -33,7 +34,7 @@ class TenantpilotPurgeNonPersistentData extends Command
|
|||||||
*
|
*
|
||||||
* @var string
|
* @var string
|
||||||
*/
|
*/
|
||||||
protected $description = 'Permanently delete non-persistent (regeneratable) tenant data like policies, backups, runs, and logs.';
|
protected $description = 'Permanently delete non-persistent tenant data like policies, backups, and runs while preserving durable audit logs.';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute the console command.
|
* Execute the console command.
|
||||||
@ -88,10 +89,6 @@ public function handle(): int
|
|||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
->delete();
|
->delete();
|
||||||
|
|
||||||
AuditLog::query()
|
|
||||||
->where('tenant_id', $tenant->id)
|
|
||||||
->delete();
|
|
||||||
|
|
||||||
RestoreRun::withTrashed()
|
RestoreRun::withTrashed()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
->forceDelete();
|
->forceDelete();
|
||||||
@ -150,7 +147,7 @@ private function countsForTenant(Tenant $tenant): array
|
|||||||
return [
|
return [
|
||||||
'backup_schedules' => BackupSchedule::query()->where('tenant_id', $tenant->id)->count(),
|
'backup_schedules' => BackupSchedule::query()->where('tenant_id', $tenant->id)->count(),
|
||||||
'operation_runs' => OperationRun::query()->where('tenant_id', $tenant->id)->count(),
|
'operation_runs' => OperationRun::query()->where('tenant_id', $tenant->id)->count(),
|
||||||
'audit_logs' => AuditLog::query()->where('tenant_id', $tenant->id)->count(),
|
'audit_logs_retained' => AuditLog::query()->where('tenant_id', $tenant->id)->count(),
|
||||||
'restore_runs' => RestoreRun::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
'restore_runs' => RestoreRun::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
||||||
'backup_items' => BackupItem::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
'backup_items' => BackupItem::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
||||||
'backup_sets' => BackupSet::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
'backup_sets' => BackupSet::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
||||||
@ -164,6 +161,8 @@ private function countsForTenant(Tenant $tenant): array
|
|||||||
*/
|
*/
|
||||||
private function recordPurgeOperationRun(Tenant $tenant, array $counts): void
|
private function recordPurgeOperationRun(Tenant $tenant, array $counts): void
|
||||||
{
|
{
|
||||||
|
$deletedRows = Arr::except($counts, ['audit_logs_retained']);
|
||||||
|
|
||||||
OperationRun::query()->create([
|
OperationRun::query()->create([
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
'tenant_id' => (int) $tenant->id,
|
'tenant_id' => (int) $tenant->id,
|
||||||
@ -179,15 +178,16 @@ private function recordPurgeOperationRun(Tenant $tenant, array $counts): void
|
|||||||
Str::uuid()->toString(),
|
Str::uuid()->toString(),
|
||||||
])),
|
])),
|
||||||
'summary_counts' => [
|
'summary_counts' => [
|
||||||
'total' => array_sum($counts),
|
'total' => array_sum($deletedRows),
|
||||||
'processed' => array_sum($counts),
|
'processed' => array_sum($deletedRows),
|
||||||
'succeeded' => array_sum($counts),
|
'succeeded' => array_sum($deletedRows),
|
||||||
'failed' => 0,
|
'failed' => 0,
|
||||||
],
|
],
|
||||||
'failure_summary' => [],
|
'failure_summary' => [],
|
||||||
'context' => [
|
'context' => [
|
||||||
'source' => 'tenantpilot:purge-nonpersistent',
|
'source' => 'tenantpilot:purge-nonpersistent',
|
||||||
'deleted_rows' => $counts,
|
'deleted_rows' => $deletedRows,
|
||||||
|
'audit_logs_retained' => $counts['audit_logs_retained'] ?? 0,
|
||||||
],
|
],
|
||||||
'started_at' => now(),
|
'started_at' => now(),
|
||||||
'completed_at' => now(),
|
'completed_at' => now(),
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
|
use App\Services\Operations\OperationLifecycleReconciler;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
@ -18,8 +19,10 @@ class TenantpilotReconcileBackupScheduleOperationRuns extends Command
|
|||||||
|
|
||||||
protected $description = 'Reconcile stuck backup schedule OperationRuns without legacy run-table lookups.';
|
protected $description = 'Reconcile stuck backup schedule OperationRuns without legacy run-table lookups.';
|
||||||
|
|
||||||
public function handle(OperationRunService $operationRunService): int
|
public function handle(
|
||||||
{
|
OperationRunService $operationRunService,
|
||||||
|
OperationLifecycleReconciler $operationLifecycleReconciler,
|
||||||
|
): int {
|
||||||
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
|
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
|
||||||
$olderThanMinutes = max(0, (int) $this->option('older-than'));
|
$olderThanMinutes = max(0, (int) $this->option('older-than'));
|
||||||
$dryRun = (bool) $this->option('dry-run');
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
@ -96,31 +99,9 @@ public function handle(OperationRunService $operationRunService): int
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($operationRun->status === 'queued' && $operationRunService->isStaleQueuedRun($operationRun, max(1, $olderThanMinutes))) {
|
$change = $operationLifecycleReconciler->reconcileRun($operationRun, $dryRun);
|
||||||
if (! $dryRun) {
|
|
||||||
$operationRunService->failStaleQueuedRun($operationRun, 'Backup schedule run was queued but never started.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$reconciled++;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($operationRun->status === 'running') {
|
|
||||||
if (! $dryRun) {
|
|
||||||
$operationRunService->updateRun(
|
|
||||||
$operationRun,
|
|
||||||
status: 'completed',
|
|
||||||
outcome: OperationRunOutcome::Failed->value,
|
|
||||||
failures: [
|
|
||||||
[
|
|
||||||
'code' => 'backup_schedule.stalled',
|
|
||||||
'message' => 'Backup schedule run exceeded reconciliation timeout and was marked failed.',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if ($change !== null) {
|
||||||
$reconciled++;
|
$reconciled++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
105
app/Console/Commands/TenantpilotReconcileOperationRuns.php
Normal file
105
app/Console/Commands/TenantpilotReconcileOperationRuns.php
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Operations\OperationLifecycleReconciler;
|
||||||
|
use App\Support\Operations\OperationLifecyclePolicy;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class TenantpilotReconcileOperationRuns extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'tenantpilot:operation-runs:reconcile
|
||||||
|
{--type=* : Limit reconciliation to one or more covered operation types}
|
||||||
|
{--tenant=* : Limit reconciliation to tenant_id or tenant external_id}
|
||||||
|
{--workspace=* : Limit reconciliation to workspace ids}
|
||||||
|
{--limit=100 : Maximum number of active runs to inspect}
|
||||||
|
{--dry-run : Report the changes without writing them}';
|
||||||
|
|
||||||
|
protected $description = 'Reconcile stale covered operation runs back to deterministic terminal truth.';
|
||||||
|
|
||||||
|
public function handle(
|
||||||
|
OperationLifecycleReconciler $reconciler,
|
||||||
|
OperationLifecyclePolicy $policy,
|
||||||
|
): int {
|
||||||
|
$types = array_values(array_filter(
|
||||||
|
(array) $this->option('type'),
|
||||||
|
static fn (mixed $type): bool => is_string($type) && trim($type) !== '',
|
||||||
|
));
|
||||||
|
$workspaceIds = array_values(array_filter(
|
||||||
|
array_map(
|
||||||
|
static fn (mixed $workspaceId): int => is_numeric($workspaceId) ? (int) $workspaceId : 0,
|
||||||
|
(array) $this->option('workspace'),
|
||||||
|
),
|
||||||
|
static fn (int $workspaceId): bool => $workspaceId > 0,
|
||||||
|
));
|
||||||
|
$tenantIds = $this->resolveTenantIds(array_values(array_filter((array) $this->option('tenant'))));
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
|
||||||
|
if ($types === []) {
|
||||||
|
$types = $policy->coveredTypeNames();
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $reconciler->reconcile([
|
||||||
|
'types' => $types,
|
||||||
|
'tenant_ids' => $tenantIds,
|
||||||
|
'workspace_ids' => $workspaceIds,
|
||||||
|
'limit' => max(1, (int) $this->option('limit')),
|
||||||
|
'dry_run' => $dryRun,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rows = collect($result['changes'] ?? [])
|
||||||
|
->map(static function (array $change): array {
|
||||||
|
return [
|
||||||
|
'Run' => (string) ($change['operation_run_id'] ?? '—'),
|
||||||
|
'Type' => (string) ($change['type'] ?? '—'),
|
||||||
|
'Reason' => (string) ($change['reason_code'] ?? '—'),
|
||||||
|
'Applied' => (($change['applied'] ?? false) === true) ? 'yes' : 'no',
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if ($rows !== []) {
|
||||||
|
$this->table(['Run', 'Type', 'Reason', 'Applied'], $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Inspected %d run(s); reconciled %d; skipped %d.',
|
||||||
|
(int) ($result['candidates'] ?? 0),
|
||||||
|
(int) ($result['reconciled'] ?? 0),
|
||||||
|
(int) ($result['skipped'] ?? 0),
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->comment('Dry-run: no changes written.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $tenantIdentifiers
|
||||||
|
* @return array<int, int>
|
||||||
|
*/
|
||||||
|
private function resolveTenantIds(array $tenantIdentifiers): array
|
||||||
|
{
|
||||||
|
if ($tenantIdentifiers === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantIds = [];
|
||||||
|
|
||||||
|
foreach ($tenantIdentifiers as $identifier) {
|
||||||
|
$tenant = Tenant::query()->forTenant($identifier)->first();
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
$tenantIds[] = (int) $tenant->getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($tenantIds));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions\Onboarding;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class OnboardingDraftConflictException extends RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly int $draftId,
|
||||||
|
public readonly int $expectedVersion,
|
||||||
|
public readonly int $actualVersion,
|
||||||
|
string $message = 'This onboarding draft changed in another tab or session.',
|
||||||
|
) {
|
||||||
|
parent::__construct($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions\Onboarding;
|
||||||
|
|
||||||
|
use App\Support\Onboarding\OnboardingLifecycleState;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class OnboardingDraftImmutableException extends RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly int $draftId,
|
||||||
|
public readonly OnboardingLifecycleState $lifecycleState,
|
||||||
|
string $message = 'This onboarding draft is no longer editable.',
|
||||||
|
) {
|
||||||
|
parent::__construct($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Exceptions/ReviewPackEvidenceResolutionException.php
Normal file
27
app/Exceptions/ReviewPackEvidenceResolutionException.php
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use App\Services\Evidence\EvidenceResolutionResult;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class ReviewPackEvidenceResolutionException extends RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly EvidenceResolutionResult $result,
|
||||||
|
?string $message = null,
|
||||||
|
) {
|
||||||
|
parent::__construct($message ?? self::defaultMessage($result));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function defaultMessage(EvidenceResolutionResult $result): string
|
||||||
|
{
|
||||||
|
return match ($result->outcome) {
|
||||||
|
'missing_snapshot' => 'No eligible evidence snapshot is available for this review pack.',
|
||||||
|
'snapshot_ineligible' => 'The latest evidence snapshot is not eligible for review-pack generation.',
|
||||||
|
default => 'Evidence snapshot resolution failed.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Clusters\Cluster;
|
use Filament\Clusters\Cluster;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Filament\Pages\Enums\SubNavigationPosition;
|
use Filament\Pages\Enums\SubNavigationPosition;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
@ -18,4 +19,13 @@ class InventoryCluster extends Cluster
|
|||||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Items';
|
protected static ?string $navigationLabel = 'Items';
|
||||||
|
|
||||||
|
public static function shouldRegisterNavigation(): bool
|
||||||
|
{
|
||||||
|
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::shouldRegisterNavigation();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
72
app/Filament/Concerns/InteractsWithTenantOwnedRecords.php
Normal file
72
app/Filament/Concerns/InteractsWithTenantOwnedRecords.php
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Concerns;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\WorkspaceIsolation\TenantOwnedQueryScope;
|
||||||
|
use App\Support\WorkspaceIsolation\TenantOwnedRecordResolver;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
trait InteractsWithTenantOwnedRecords
|
||||||
|
{
|
||||||
|
protected static function tenantOwnedRelationshipName(): string
|
||||||
|
{
|
||||||
|
$relationshipName = property_exists(static::class, 'tenantOwnershipRelationshipName')
|
||||||
|
? static::$tenantOwnershipRelationshipName
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return is_string($relationshipName) && $relationshipName !== ''
|
||||||
|
? $relationshipName
|
||||||
|
: 'tenant';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function resolveTenantContextForTenantOwnedRecords(): ?Tenant
|
||||||
|
{
|
||||||
|
if (method_exists(static::class, 'resolveTenantContextForCurrentPanel')) {
|
||||||
|
return static::resolveTenantContextForCurrentPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method_exists(static::class, 'panelTenantContext')) {
|
||||||
|
return static::panelTenantContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getTenantOwnedEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
return static::scopeTenantOwnedQuery(parent::getEloquentQuery());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function scopeTenantOwnedQuery(Builder $query, ?Tenant $tenant = null): Builder
|
||||||
|
{
|
||||||
|
return app(TenantOwnedQueryScope::class)->apply(
|
||||||
|
$query,
|
||||||
|
$tenant ?? static::resolveTenantContextForTenantOwnedRecords(),
|
||||||
|
static::tenantOwnedRelationshipName(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function resolveTenantOwnedRecord(Model|int|string|null $record, ?Builder $query = null, ?Tenant $tenant = null): ?Model
|
||||||
|
{
|
||||||
|
$scopedQuery = static::scopeTenantOwnedQuery(
|
||||||
|
$query ?? parent::getEloquentQuery(),
|
||||||
|
$tenant,
|
||||||
|
);
|
||||||
|
|
||||||
|
return app(TenantOwnedRecordResolver::class)->resolve($scopedQuery, $record);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function resolveTenantOwnedRecordOrFail(Model|int|string|null $record, ?Builder $query = null, ?Tenant $tenant = null): Model
|
||||||
|
{
|
||||||
|
$scopedQuery = static::scopeTenantOwnedQuery(
|
||||||
|
$query ?? parent::getEloquentQuery(),
|
||||||
|
$tenant,
|
||||||
|
);
|
||||||
|
|
||||||
|
return app(TenantOwnedRecordResolver::class)->resolveOrFail($scopedQuery, $record);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
app/Filament/Concerns/ResolvesPanelTenantContext.php
Normal file
52
app/Filament/Concerns/ResolvesPanelTenantContext.php
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Concerns;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
trait ResolvesPanelTenantContext
|
||||||
|
{
|
||||||
|
protected static function resolveTenantContextForCurrentPanel(): ?Tenant
|
||||||
|
{
|
||||||
|
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||||
|
$tenant = app(OperateHubShell::class)->tenantOwnedPanelContext(request());
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant ? $tenant : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant ? $tenant : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function panelTenantContext(): ?Tenant
|
||||||
|
{
|
||||||
|
return static::resolveTenantContextForCurrentPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function trustedPanelTenantContext(): ?Tenant
|
||||||
|
{
|
||||||
|
return static::panelTenantContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function resolveTenantContextForCurrentPanelOrFail(): Tenant
|
||||||
|
{
|
||||||
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
throw new RuntimeException('No tenant context selected.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function resolveTrustedPanelTenantContextOrFail(): Tenant
|
||||||
|
{
|
||||||
|
return static::resolveTenantContextForCurrentPanelOrFail();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,9 @@
|
|||||||
|
|
||||||
namespace App\Filament\Concerns;
|
namespace App\Filament\Concerns;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
|
use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@ -19,6 +22,10 @@ public static function getGlobalSearchEloquentQuery(): Builder
|
|||||||
{
|
{
|
||||||
$query = static::getModel()::query();
|
$query = static::getModel()::query();
|
||||||
|
|
||||||
|
if (! TenantOwnedModelFamilies::supportsScopedGlobalSearch(static::getModel())) {
|
||||||
|
return $query->whereRaw('1 = 0');
|
||||||
|
}
|
||||||
|
|
||||||
if (! static::isScopedToTenant()) {
|
if (! static::isScopedToTenant()) {
|
||||||
$panel = Filament::getCurrentOrDefaultPanel();
|
$panel = Filament::getCurrentOrDefaultPanel();
|
||||||
|
|
||||||
@ -27,7 +34,7 @@ public static function getGlobalSearchEloquentQuery(): Builder
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = Filament::getTenant();
|
$tenant = static::resolveGlobalSearchTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Model) {
|
if (! $tenant instanceof Model) {
|
||||||
return $query->whereRaw('1 = 0');
|
return $query->whereRaw('1 = 0');
|
||||||
@ -41,4 +48,17 @@ public static function getGlobalSearchEloquentQuery(): Builder
|
|||||||
|
|
||||||
return $query->whereBelongsTo($tenant, static::$globalSearchTenantRelationship);
|
return $query->whereBelongsTo($tenant, static::$globalSearchTenantRelationship);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected static function resolveGlobalSearchTenant(): ?Model
|
||||||
|
{
|
||||||
|
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||||
|
$tenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant ? $tenant : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
return $tenant instanceof Model ? $tenant : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Pages;
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -12,6 +13,7 @@
|
|||||||
use App\Services\Baselines\BaselineCompareService;
|
use App\Services\Baselines\BaselineCompareService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
|
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
||||||
use App\Support\Baselines\BaselineCompareStats;
|
use App\Support\Baselines\BaselineCompareStats;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
@ -28,6 +30,8 @@
|
|||||||
|
|
||||||
class BaselineCompareLanding extends Page
|
class BaselineCompareLanding extends Page
|
||||||
{
|
{
|
||||||
|
use ResolvesPanelTenantContext;
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-scale';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-scale';
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||||
@ -56,6 +60,8 @@ class BaselineCompareLanding extends Page
|
|||||||
|
|
||||||
public ?int $duplicateNamePoliciesCount = null;
|
public ?int $duplicateNamePoliciesCount = null;
|
||||||
|
|
||||||
|
public ?int $duplicateNameSubjectsCount = null;
|
||||||
|
|
||||||
public ?int $operationRunId = null;
|
public ?int $operationRunId = null;
|
||||||
|
|
||||||
public ?int $findingsCount = null;
|
public ?int $findingsCount = null;
|
||||||
@ -83,6 +89,24 @@ class BaselineCompareLanding extends Page
|
|||||||
/** @var array<string, int>|null */
|
/** @var array<string, int>|null */
|
||||||
public ?array $evidenceGapsTopReasons = null;
|
public ?array $evidenceGapsTopReasons = null;
|
||||||
|
|
||||||
|
/** @var array<string, mixed>|null */
|
||||||
|
public ?array $evidenceGapSummary = null;
|
||||||
|
|
||||||
|
/** @var list<array<string, mixed>>|null */
|
||||||
|
public ?array $evidenceGapBuckets = null;
|
||||||
|
|
||||||
|
/** @var array<string, mixed>|null */
|
||||||
|
public ?array $baselineCompareDiagnostics = null;
|
||||||
|
|
||||||
|
/** @var array<string, int>|null */
|
||||||
|
public ?array $rbacRoleDefinitionSummary = null;
|
||||||
|
|
||||||
|
/** @var array<string, mixed>|null */
|
||||||
|
public ?array $operatorExplanation = null;
|
||||||
|
|
||||||
|
/** @var array<string, mixed>|null */
|
||||||
|
public ?array $summaryAssessment = null;
|
||||||
|
|
||||||
public static function canAccess(): bool
|
public static function canAccess(): bool
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -91,7 +115,7 @@ public static function canAccess(): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return false;
|
return false;
|
||||||
@ -109,7 +133,7 @@ public function mount(): void
|
|||||||
|
|
||||||
public function refreshStats(): void
|
public function refreshStats(): void
|
||||||
{
|
{
|
||||||
$stats = BaselineCompareStats::forTenant(Tenant::current());
|
$stats = BaselineCompareStats::forTenant(static::resolveTenantContextForCurrentPanel());
|
||||||
|
|
||||||
$this->state = $stats->state;
|
$this->state = $stats->state;
|
||||||
$this->message = $stats->message;
|
$this->message = $stats->message;
|
||||||
@ -117,6 +141,7 @@ public function refreshStats(): void
|
|||||||
$this->profileId = $stats->profileId;
|
$this->profileId = $stats->profileId;
|
||||||
$this->snapshotId = $stats->snapshotId;
|
$this->snapshotId = $stats->snapshotId;
|
||||||
$this->duplicateNamePoliciesCount = $stats->duplicateNamePoliciesCount;
|
$this->duplicateNamePoliciesCount = $stats->duplicateNamePoliciesCount;
|
||||||
|
$this->duplicateNameSubjectsCount = $stats->duplicateNameSubjectsCount;
|
||||||
$this->operationRunId = $stats->operationRunId;
|
$this->operationRunId = $stats->operationRunId;
|
||||||
$this->findingsCount = $stats->findingsCount;
|
$this->findingsCount = $stats->findingsCount;
|
||||||
$this->severityCounts = $stats->severityCounts !== [] ? $stats->severityCounts : null;
|
$this->severityCounts = $stats->severityCounts !== [] ? $stats->severityCounts : null;
|
||||||
@ -133,6 +158,18 @@ public function refreshStats(): void
|
|||||||
|
|
||||||
$this->evidenceGapsCount = $stats->evidenceGapsCount;
|
$this->evidenceGapsCount = $stats->evidenceGapsCount;
|
||||||
$this->evidenceGapsTopReasons = $stats->evidenceGapsTopReasons !== [] ? $stats->evidenceGapsTopReasons : null;
|
$this->evidenceGapsTopReasons = $stats->evidenceGapsTopReasons !== [] ? $stats->evidenceGapsTopReasons : null;
|
||||||
|
$this->evidenceGapSummary = is_array($stats->evidenceGapDetails['summary'] ?? null)
|
||||||
|
? $stats->evidenceGapDetails['summary']
|
||||||
|
: null;
|
||||||
|
$this->evidenceGapBuckets = is_array($stats->evidenceGapDetails['buckets'] ?? null) && $stats->evidenceGapDetails['buckets'] !== []
|
||||||
|
? $stats->evidenceGapDetails['buckets']
|
||||||
|
: null;
|
||||||
|
$this->baselineCompareDiagnostics = $stats->baselineCompareDiagnostics !== []
|
||||||
|
? $stats->baselineCompareDiagnostics
|
||||||
|
: null;
|
||||||
|
$this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null;
|
||||||
|
$this->operatorExplanation = $stats->operatorExplanation()->toArray();
|
||||||
|
$this->summaryAssessment = $stats->summaryAssessment()->toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -145,24 +182,32 @@ public function refreshStats(): void
|
|||||||
*/
|
*/
|
||||||
protected function getViewData(): array
|
protected function getViewData(): array
|
||||||
{
|
{
|
||||||
|
$evidenceGapSummary = is_array($this->evidenceGapSummary) ? $this->evidenceGapSummary : [];
|
||||||
$hasCoverageWarnings = in_array($this->coverageStatus, ['warning', 'unproven'], true);
|
$hasCoverageWarnings = in_array($this->coverageStatus, ['warning', 'unproven'], true);
|
||||||
$evidenceGapsCountValue = (int) ($this->evidenceGapsCount ?? 0);
|
$evidenceGapsCountValue = is_numeric($evidenceGapSummary['count'] ?? null)
|
||||||
|
? (int) $evidenceGapSummary['count']
|
||||||
|
: (int) ($this->evidenceGapsCount ?? 0);
|
||||||
$hasEvidenceGaps = $evidenceGapsCountValue > 0;
|
$hasEvidenceGaps = $evidenceGapsCountValue > 0;
|
||||||
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
|
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
|
||||||
|
$hasRbacRoleDefinitionSummary = is_array($this->rbacRoleDefinitionSummary)
|
||||||
|
&& array_sum($this->rbacRoleDefinitionSummary) > 0;
|
||||||
|
$evidenceGapDetailState = is_string($evidenceGapSummary['detail_state'] ?? null)
|
||||||
|
? (string) $evidenceGapSummary['detail_state']
|
||||||
|
: 'no_gaps';
|
||||||
|
$hasEvidenceGapDetailSection = $evidenceGapDetailState !== 'no_gaps';
|
||||||
|
$hasEvidenceGapDiagnostics = is_array($this->baselineCompareDiagnostics) && $this->baselineCompareDiagnostics !== [];
|
||||||
|
|
||||||
$evidenceGapsSummary = null;
|
$evidenceGapsSummary = null;
|
||||||
$evidenceGapsTooltip = null;
|
$evidenceGapsTooltip = null;
|
||||||
|
|
||||||
if ($hasEvidenceGaps && is_array($this->evidenceGapsTopReasons) && $this->evidenceGapsTopReasons !== []) {
|
if ($hasEvidenceGaps) {
|
||||||
$parts = [];
|
$parts = array_map(
|
||||||
|
static fn (array $reason): string => $reason['reason_label'].' ('.$reason['count'].')',
|
||||||
foreach (array_slice($this->evidenceGapsTopReasons, 0, 5, true) as $reason => $count) {
|
BaselineCompareEvidenceGapDetails::topReasons(
|
||||||
if (! is_string($reason) || $reason === '' || ! is_numeric($count)) {
|
is_array($evidenceGapSummary['by_reason'] ?? null) ? $evidenceGapSummary['by_reason'] : [],
|
||||||
continue;
|
5,
|
||||||
}
|
),
|
||||||
|
);
|
||||||
$parts[] = $reason.' ('.((int) $count).')';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($parts !== []) {
|
if ($parts !== []) {
|
||||||
$evidenceGapsSummary = implode(', ', $parts);
|
$evidenceGapsSummary = implode(', ', $parts);
|
||||||
@ -197,12 +242,17 @@ protected function getViewData(): array
|
|||||||
'evidenceGapsCountValue' => $evidenceGapsCountValue,
|
'evidenceGapsCountValue' => $evidenceGapsCountValue,
|
||||||
'hasEvidenceGaps' => $hasEvidenceGaps,
|
'hasEvidenceGaps' => $hasEvidenceGaps,
|
||||||
'hasWarnings' => $hasWarnings,
|
'hasWarnings' => $hasWarnings,
|
||||||
|
'hasRbacRoleDefinitionSummary' => $hasRbacRoleDefinitionSummary,
|
||||||
|
'evidenceGapDetailState' => $evidenceGapDetailState,
|
||||||
|
'hasEvidenceGapDetailSection' => $hasEvidenceGapDetailSection,
|
||||||
|
'hasEvidenceGapDiagnostics' => $hasEvidenceGapDiagnostics,
|
||||||
'evidenceGapsSummary' => $evidenceGapsSummary,
|
'evidenceGapsSummary' => $evidenceGapsSummary,
|
||||||
'evidenceGapsTooltip' => $evidenceGapsTooltip,
|
'evidenceGapsTooltip' => $evidenceGapsTooltip,
|
||||||
'findingsColorClass' => $findingsColorClass,
|
'findingsColorClass' => $findingsColorClass,
|
||||||
'whyNoFindingsMessage' => $whyNoFindingsMessage,
|
'whyNoFindingsMessage' => $whyNoFindingsMessage,
|
||||||
'whyNoFindingsFallback' => $whyNoFindingsFallback,
|
'whyNoFindingsFallback' => $whyNoFindingsFallback,
|
||||||
'whyNoFindingsColor' => $whyNoFindingsColor,
|
'whyNoFindingsColor' => $whyNoFindingsColor,
|
||||||
|
'summaryAssessment' => is_array($this->summaryAssessment) ? $this->summaryAssessment : null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -285,10 +335,10 @@ private function compareNowAction(): Action
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
Notification::make()->title('No tenant context')->danger()->send();
|
Notification::make()->title('Select a tenant to compare baselines')->danger()->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -297,9 +347,22 @@ private function compareNowAction(): Action
|
|||||||
$result = $service->startCompare($tenant, $user);
|
$result = $service->startCompare($tenant, $user);
|
||||||
|
|
||||||
if (! ($result['ok'] ?? false)) {
|
if (! ($result['ok'] ?? false)) {
|
||||||
|
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
|
||||||
|
|
||||||
|
$message = match ($reasonCode) {
|
||||||
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
|
||||||
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'The assigned baseline profile is not active.',
|
||||||
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
|
||||||
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'No complete baseline snapshot is currently available for compare.',
|
||||||
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
|
||||||
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
|
||||||
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete baseline snapshot is current. Compare uses the latest complete baseline only.',
|
||||||
|
default => 'Reason: '.$reasonCode,
|
||||||
|
};
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Cannot start comparison')
|
->title('Cannot start comparison')
|
||||||
->body('Reason: '.($result['reason_code'] ?? 'unknown'))
|
->body($message)
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
@ -333,7 +396,7 @@ private function compareNowAction(): Action
|
|||||||
|
|
||||||
public function getFindingsUrl(): ?string
|
public function getFindingsUrl(): ?string
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return null;
|
return null;
|
||||||
@ -348,7 +411,7 @@ public function getRunUrl(): ?string
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@ -7,6 +7,10 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserTenantPreference;
|
use App\Models\UserTenantPreference;
|
||||||
|
use App\Services\Tenants\TenantOperabilityService;
|
||||||
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
|
use App\Support\Tenants\TenantLifecyclePresentation;
|
||||||
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
@ -52,10 +56,10 @@ public function getTenants(): Collection
|
|||||||
$tenants = $user->getTenants(Filament::getCurrentOrDefaultPanel());
|
$tenants = $user->getTenants(Filament::getCurrentOrDefaultPanel());
|
||||||
|
|
||||||
if ($tenants instanceof Collection) {
|
if ($tenants instanceof Collection) {
|
||||||
return $tenants;
|
return app(TenantOperabilityService::class)->filterSelectable($tenants);
|
||||||
}
|
}
|
||||||
|
|
||||||
return collect($tenants);
|
return app(TenantOperabilityService::class)->filterSelectable(collect($tenants));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function selectTenant(int $tenantId): void
|
public function selectTenant(int $tenantId): void
|
||||||
@ -66,10 +70,35 @@ public function selectTenant(int $tenantId): void
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$workspaceContext = app(WorkspaceContext::class);
|
||||||
|
$workspaceId = $workspaceContext->currentWorkspaceId(request());
|
||||||
|
$tenant = null;
|
||||||
|
|
||||||
|
if ($workspaceId === null) {
|
||||||
|
$tenant = Tenant::query()->whereKey($tenantId)->first();
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
$workspace = $tenant->workspace;
|
||||||
|
|
||||||
|
if ($workspace !== null && $user->canAccessTenant($tenant)) {
|
||||||
|
$workspaceContext->setCurrentWorkspace($workspace, $user, request());
|
||||||
|
$workspaceId = (int) $workspace->getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($workspaceId === null) {
|
||||||
|
$this->redirect(route('filament.admin.pages.choose-workspace'));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== $workspaceId) {
|
||||||
$tenant = Tenant::query()
|
$tenant = Tenant::query()
|
||||||
->where('status', 'active')
|
->where('workspace_id', $workspaceId)
|
||||||
->whereKey($tenantId)
|
->whereKey($tenantId)
|
||||||
->first();
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
abort(404);
|
abort(404);
|
||||||
@ -79,13 +108,32 @@ public function selectTenant(int $tenantId): void
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$outcome = app(TenantOperabilityService::class)->outcomeFor(
|
||||||
|
tenant: $tenant,
|
||||||
|
question: TenantOperabilityQuestion::SelectorEligibility,
|
||||||
|
actor: $user,
|
||||||
|
workspaceId: $workspaceId,
|
||||||
|
lane: TenantInteractionLane::StandardActiveOperating,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $outcome->allowed) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
$this->persistLastTenant($user, $tenant);
|
$this->persistLastTenant($user, $tenant);
|
||||||
|
|
||||||
app(WorkspaceContext::class)->rememberLastTenantId((int) $tenant->workspace_id, (int) $tenant->getKey(), request());
|
if (! $workspaceContext->rememberTenantContext($tenant, request())) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function tenantLifecyclePresentation(Tenant $tenant): TenantLifecyclePresentation
|
||||||
|
{
|
||||||
|
return TenantLifecyclePresentation::fromTenant($tenant);
|
||||||
|
}
|
||||||
|
|
||||||
private function persistLastTenant(User $user, Tenant $tenant): void
|
private function persistLastTenant(User $user, Tenant $tenant): void
|
||||||
{
|
{
|
||||||
if (Schema::hasColumn('users', 'last_tenant_id')) {
|
if (Schema::hasColumn('users', 'last_tenant_id')) {
|
||||||
|
|||||||
@ -132,7 +132,9 @@ public function selectWorkspace(int $workspaceId): void
|
|||||||
/** @var WorkspaceRedirectResolver $resolver */
|
/** @var WorkspaceRedirectResolver $resolver */
|
||||||
$resolver = app(WorkspaceRedirectResolver::class);
|
$resolver = app(WorkspaceRedirectResolver::class);
|
||||||
|
|
||||||
$this->redirect($intendedUrl ?: $resolver->resolve($workspace, $user));
|
$redirectTarget = $intendedUrl ?: $resolver->resolve($workspace, $user);
|
||||||
|
|
||||||
|
$this->redirect($redirectTarget);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -173,6 +175,8 @@ public function createWorkspace(array $data): void
|
|||||||
/** @var WorkspaceRedirectResolver $resolver */
|
/** @var WorkspaceRedirectResolver $resolver */
|
||||||
$resolver = app(WorkspaceRedirectResolver::class);
|
$resolver = app(WorkspaceRedirectResolver::class);
|
||||||
|
|
||||||
$this->redirect($intendedUrl ?: $resolver->resolve($workspace, $user));
|
$redirectTarget = $intendedUrl ?: $resolver->resolve($workspace, $user);
|
||||||
|
|
||||||
|
$this->redirect($redirectTarget);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,21 +1,45 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Filament\Pages;
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
use App\Filament\Clusters\Inventory\InventoryCluster;
|
use App\Filament\Clusters\Inventory\InventoryCluster;
|
||||||
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Inventory\CoverageCapabilitiesResolver;
|
use App\Services\Inventory\CoverageCapabilitiesResolver;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\Badges\TagBadgeCatalog;
|
||||||
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
|
use App\Support\Badges\TagBadgeRenderer;
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Support\Enums\FontFamily;
|
||||||
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class InventoryCoverage extends Page
|
class InventoryCoverage extends Page implements HasTable
|
||||||
{
|
{
|
||||||
|
use InteractsWithTable;
|
||||||
|
use ResolvesPanelTenantContext;
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-table-cells';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-table-cells';
|
||||||
|
|
||||||
protected static ?int $navigationSort = 3;
|
protected static ?int $navigationSort = 3;
|
||||||
@ -28,9 +52,18 @@ class InventoryCoverage extends Page
|
|||||||
|
|
||||||
protected string $view = 'filament.pages.inventory-coverage';
|
protected string $view = 'filament.pages.inventory-coverage';
|
||||||
|
|
||||||
|
public static function shouldRegisterNavigation(): bool
|
||||||
|
{
|
||||||
|
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::shouldRegisterNavigation();
|
||||||
|
}
|
||||||
|
|
||||||
public static function canAccess(): bool
|
public static function canAccess(): bool
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
@ -44,6 +77,11 @@ public static function canAccess(): bool
|
|||||||
&& $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
&& $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->mountInteractsWithTable();
|
||||||
|
}
|
||||||
|
|
||||||
protected function getHeaderWidgets(): array
|
protected function getHeaderWidgets(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@ -51,35 +89,364 @@ protected function getHeaderWidgets(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function table(Table $table): Table
|
||||||
* @var array<int, array<string, mixed>>
|
{
|
||||||
*/
|
return $table
|
||||||
public array $supportedPolicyTypes = [];
|
->searchable()
|
||||||
|
->searchPlaceholder('Search by policy type or label')
|
||||||
|
->defaultSort('label')
|
||||||
|
->defaultPaginationPageOption(50)
|
||||||
|
->paginated(\App\Support\Filament\TablePaginationProfiles::customPage())
|
||||||
|
->records(function (
|
||||||
|
?string $sortColumn,
|
||||||
|
?string $sortDirection,
|
||||||
|
?string $search,
|
||||||
|
array $filters,
|
||||||
|
int $page,
|
||||||
|
int $recordsPerPage
|
||||||
|
): LengthAwarePaginator {
|
||||||
|
$rows = $this->filterRows(
|
||||||
|
rows: $this->coverageRows(),
|
||||||
|
search: $search,
|
||||||
|
filters: $filters,
|
||||||
|
);
|
||||||
|
|
||||||
|
$rows = $this->sortRows(
|
||||||
|
rows: $rows,
|
||||||
|
sortColumn: $sortColumn,
|
||||||
|
sortDirection: $sortDirection,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->paginateRows(
|
||||||
|
rows: $rows,
|
||||||
|
page: $page,
|
||||||
|
recordsPerPage: $recordsPerPage,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('type')
|
||||||
|
->label('Type')
|
||||||
|
->sortable()
|
||||||
|
->fontFamily(FontFamily::Mono)
|
||||||
|
->copyable()
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('label')
|
||||||
|
->label('Label')
|
||||||
|
->sortable()
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(function (?string $state, array $record): string {
|
||||||
|
return TagBadgeCatalog::spec(
|
||||||
|
TagBadgeDomain::PolicyType,
|
||||||
|
$record['type'] ?? $state,
|
||||||
|
)->label;
|
||||||
|
})
|
||||||
|
->color(function (?string $state, array $record): string {
|
||||||
|
return TagBadgeCatalog::spec(
|
||||||
|
TagBadgeDomain::PolicyType,
|
||||||
|
$record['type'] ?? $state,
|
||||||
|
)->color;
|
||||||
|
})
|
||||||
|
->icon(function (?string $state, array $record): ?string {
|
||||||
|
return TagBadgeCatalog::spec(
|
||||||
|
TagBadgeDomain::PolicyType,
|
||||||
|
$record['type'] ?? $state,
|
||||||
|
)->icon;
|
||||||
|
})
|
||||||
|
->iconColor(function (?string $state, array $record): ?string {
|
||||||
|
$spec = TagBadgeCatalog::spec(
|
||||||
|
TagBadgeDomain::PolicyType,
|
||||||
|
$record['type'] ?? $state,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $spec->iconColor ?? $spec->color;
|
||||||
|
})
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('risk')
|
||||||
|
->label('Risk')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRisk))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::PolicyRisk))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRisk))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRisk)),
|
||||||
|
TextColumn::make('restore')
|
||||||
|
->label('Restore')
|
||||||
|
->badge()
|
||||||
|
->state(fn (array $record): ?string => $record['restore'])
|
||||||
|
->formatStateUsing(function (?string $state): string {
|
||||||
|
return filled($state)
|
||||||
|
? BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state)->label
|
||||||
|
: 'Not provided';
|
||||||
|
})
|
||||||
|
->color(function (?string $state): string {
|
||||||
|
return filled($state)
|
||||||
|
? BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state)->color
|
||||||
|
: 'gray';
|
||||||
|
})
|
||||||
|
->icon(function (?string $state): ?string {
|
||||||
|
return filled($state)
|
||||||
|
? BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state)->icon
|
||||||
|
: 'heroicon-m-minus-circle';
|
||||||
|
})
|
||||||
|
->iconColor(function (?string $state): ?string {
|
||||||
|
if (! filled($state)) {
|
||||||
|
return 'gray';
|
||||||
|
}
|
||||||
|
|
||||||
|
$spec = BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state);
|
||||||
|
|
||||||
|
return $spec->iconColor ?? $spec->color;
|
||||||
|
}),
|
||||||
|
TextColumn::make('category')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
|
||||||
|
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory))
|
||||||
|
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyCategory))
|
||||||
|
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyCategory))
|
||||||
|
->toggleable()
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('segment')
|
||||||
|
->label('Segment')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(fn (?string $state): string => $state === 'foundation' ? 'Foundation' : 'Policy')
|
||||||
|
->color(fn (?string $state): string => $state === 'foundation' ? 'gray' : 'info')
|
||||||
|
->toggleable(),
|
||||||
|
IconColumn::make('dependencies')
|
||||||
|
->label('Dependencies')
|
||||||
|
->boolean()
|
||||||
|
->trueIcon('heroicon-m-check-circle')
|
||||||
|
->falseIcon('heroicon-m-minus-circle')
|
||||||
|
->trueColor('success')
|
||||||
|
->falseColor('gray')
|
||||||
|
->alignCenter()
|
||||||
|
->toggleable(),
|
||||||
|
])
|
||||||
|
->filters($this->tableFilters())
|
||||||
|
->emptyStateHeading('No coverage entries match this view')
|
||||||
|
->emptyStateDescription('Clear the current search or filters to return to the full coverage matrix.')
|
||||||
|
->emptyStateIcon('heroicon-o-funnel')
|
||||||
|
->emptyStateActions([
|
||||||
|
Action::make('clear_filters')
|
||||||
|
->label('Clear filters')
|
||||||
|
->icon('heroicon-o-x-mark')
|
||||||
|
->color('gray')
|
||||||
|
->action(function (): void {
|
||||||
|
$this->resetTable();
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->actions([])
|
||||||
|
->bulkActions([]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<int, array<string, mixed>>
|
* @return array<int, SelectFilter>
|
||||||
*/
|
*/
|
||||||
public array $foundationTypes = [];
|
protected function tableFilters(): array
|
||||||
|
{
|
||||||
|
$filters = [
|
||||||
|
SelectFilter::make('category')
|
||||||
|
->label('Category')
|
||||||
|
->options($this->categoryFilterOptions()),
|
||||||
|
];
|
||||||
|
|
||||||
public function mount(): void
|
if ($this->restoreFilterOptions() !== []) {
|
||||||
|
$filters[] = SelectFilter::make('restore')
|
||||||
|
->label('Restore mode')
|
||||||
|
->options($this->restoreFilterOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<string, array{
|
||||||
|
* __key: string,
|
||||||
|
* key: string,
|
||||||
|
* segment: string,
|
||||||
|
* type: string,
|
||||||
|
* label: string,
|
||||||
|
* category: string,
|
||||||
|
* dependencies: bool,
|
||||||
|
* restore: ?string,
|
||||||
|
* risk: string,
|
||||||
|
* source_order: int
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
protected function coverageRows(): Collection
|
||||||
{
|
{
|
||||||
$resolver = app(CoverageCapabilitiesResolver::class);
|
$resolver = app(CoverageCapabilitiesResolver::class);
|
||||||
|
|
||||||
$this->supportedPolicyTypes = collect(InventoryPolicyTypeMeta::supported())
|
$supported = $this->mapCoverageRows(
|
||||||
->map(function (array $row) use ($resolver): array {
|
rows: InventoryPolicyTypeMeta::supported(),
|
||||||
|
segment: 'policy',
|
||||||
|
sourceOrderOffset: 0,
|
||||||
|
resolver: $resolver,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $supported->merge($this->mapCoverageRows(
|
||||||
|
rows: InventoryPolicyTypeMeta::foundations(),
|
||||||
|
segment: 'foundation',
|
||||||
|
sourceOrderOffset: $supported->count(),
|
||||||
|
resolver: $resolver,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $rows
|
||||||
|
* @return Collection<string, array{
|
||||||
|
* __key: string,
|
||||||
|
* key: string,
|
||||||
|
* segment: string,
|
||||||
|
* type: string,
|
||||||
|
* label: string,
|
||||||
|
* category: string,
|
||||||
|
* dependencies: bool,
|
||||||
|
* restore: ?string,
|
||||||
|
* risk: string,
|
||||||
|
* source_order: int
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
protected function mapCoverageRows(
|
||||||
|
array $rows,
|
||||||
|
string $segment,
|
||||||
|
int $sourceOrderOffset,
|
||||||
|
CoverageCapabilitiesResolver $resolver
|
||||||
|
): Collection {
|
||||||
|
return collect($rows)
|
||||||
|
->values()
|
||||||
|
->mapWithKeys(function (array $row, int $index) use ($resolver, $segment, $sourceOrderOffset): array {
|
||||||
$type = (string) ($row['type'] ?? '');
|
$type = (string) ($row['type'] ?? '');
|
||||||
|
|
||||||
return array_merge($row, [
|
if ($type === '') {
|
||||||
'dependencies' => $type !== '' && $resolver->supportsDependencies($type),
|
return [];
|
||||||
]);
|
}
|
||||||
|
|
||||||
|
$key = "{$segment}:{$type}";
|
||||||
|
$restore = $row['restore'] ?? null;
|
||||||
|
$risk = $row['risk'] ?? 'n/a';
|
||||||
|
|
||||||
|
return [
|
||||||
|
$key => [
|
||||||
|
'__key' => $key,
|
||||||
|
'key' => $key,
|
||||||
|
'segment' => $segment,
|
||||||
|
'type' => $type,
|
||||||
|
'label' => (string) ($row['label'] ?? $type),
|
||||||
|
'category' => (string) ($row['category'] ?? 'Other'),
|
||||||
|
'dependencies' => $segment === 'policy' && $resolver->supportsDependencies($type),
|
||||||
|
'restore' => is_string($restore) ? $restore : null,
|
||||||
|
'risk' => is_string($risk) ? $risk : 'n/a',
|
||||||
|
'source_order' => $sourceOrderOffset + $index,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<string, array<string, mixed>> $rows
|
||||||
|
* @param array<string, mixed> $filters
|
||||||
|
* @return Collection<string, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
protected function filterRows(Collection $rows, ?string $search, array $filters): Collection
|
||||||
|
{
|
||||||
|
$normalizedSearch = Str::lower(trim((string) $search));
|
||||||
|
$category = $filters['category']['value'] ?? null;
|
||||||
|
$restore = $filters['restore']['value'] ?? null;
|
||||||
|
|
||||||
|
return $rows
|
||||||
|
->when(
|
||||||
|
$normalizedSearch !== '',
|
||||||
|
function (Collection $rows) use ($normalizedSearch): Collection {
|
||||||
|
return $rows->filter(function (array $row) use ($normalizedSearch): bool {
|
||||||
|
return str_contains(Str::lower((string) $row['type']), $normalizedSearch)
|
||||||
|
|| str_contains(Str::lower((string) $row['label']), $normalizedSearch);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
filled($category),
|
||||||
|
fn (Collection $rows): Collection => $rows->where('category', (string) $category),
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
filled($restore),
|
||||||
|
fn (Collection $rows): Collection => $rows->where('restore', (string) $restore),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<string, array<string, mixed>> $rows
|
||||||
|
* @return Collection<string, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
protected function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
|
||||||
|
{
|
||||||
|
$sortColumn = in_array($sortColumn, ['type', 'label'], true) ? $sortColumn : null;
|
||||||
|
|
||||||
|
if ($sortColumn === null) {
|
||||||
|
return $rows->sortBy('source_order');
|
||||||
|
}
|
||||||
|
|
||||||
|
$records = $rows->all();
|
||||||
|
|
||||||
|
uasort($records, function (array $left, array $right) use ($sortColumn, $sortDirection): int {
|
||||||
|
$comparison = strnatcasecmp(
|
||||||
|
(string) ($left[$sortColumn] ?? ''),
|
||||||
|
(string) ($right[$sortColumn] ?? ''),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($comparison === 0) {
|
||||||
|
$comparison = ((int) ($left['source_order'] ?? 0)) <=> ((int) ($right['source_order'] ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sortDirection === 'desc' ? ($comparison * -1) : $comparison;
|
||||||
|
});
|
||||||
|
|
||||||
|
return collect($records);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<string, array<string, mixed>> $rows
|
||||||
|
*/
|
||||||
|
protected function paginateRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
return new LengthAwarePaginator(
|
||||||
|
items: $rows->forPage($page, $recordsPerPage),
|
||||||
|
total: $rows->count(),
|
||||||
|
perPage: $recordsPerPage,
|
||||||
|
currentPage: $page,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function categoryFilterOptions(): array
|
||||||
|
{
|
||||||
|
return $this->coverageRows()
|
||||||
|
->pluck('category')
|
||||||
|
->filter(fn (mixed $category): bool => is_string($category) && $category !== '')
|
||||||
|
->unique()
|
||||||
|
->sort()
|
||||||
|
->mapWithKeys(function (string $category): array {
|
||||||
|
return [
|
||||||
|
$category => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $category)->label,
|
||||||
|
];
|
||||||
})
|
})
|
||||||
->all();
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
$this->foundationTypes = collect(InventoryPolicyTypeMeta::foundations())
|
/**
|
||||||
->map(function (array $row): array {
|
* @return array<string, string>
|
||||||
return array_merge($row, [
|
*/
|
||||||
'dependencies' => false,
|
protected function restoreFilterOptions(): array
|
||||||
]);
|
{
|
||||||
|
return $this->coverageRows()
|
||||||
|
->pluck('restore')
|
||||||
|
->filter(fn (mixed $restore): bool => is_string($restore) && $restore !== '')
|
||||||
|
->unique()
|
||||||
|
->sort()
|
||||||
|
->mapWithKeys(function (string $restore): array {
|
||||||
|
return [
|
||||||
|
$restore => BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $restore)->label,
|
||||||
|
];
|
||||||
})
|
})
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,14 +4,47 @@
|
|||||||
|
|
||||||
namespace App\Filament\Pages\Monitoring;
|
namespace App\Filament\Pages\Monitoring;
|
||||||
|
|
||||||
|
use App\Models\AuditLog as AuditLogModel;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||||
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
|
use App\Support\Filament\FilterPresets;
|
||||||
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class AuditLog extends Page
|
class AuditLog extends Page implements HasTable
|
||||||
{
|
{
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
public ?int $selectedAuditLogId = null;
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
@ -28,6 +61,41 @@ class AuditLog extends Page
|
|||||||
|
|
||||||
protected string $view = 'filament.pages.monitoring.audit-log';
|
protected string $view = 'filament.pages.monitoring.audit-log';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<int, Tenant>|null
|
||||||
|
*/
|
||||||
|
private ?array $authorizedTenants = null;
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
||||||
|
->withDefaults(new ActionSurfaceDefaults(
|
||||||
|
moreGroupLabel: 'More',
|
||||||
|
exportIsDefaultBulkActionForReadOnly: false,
|
||||||
|
))
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep the Monitoring scope visible and expose selected-event detail actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Audit history is immutable and intentionally omits bulk actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The table exposes a clear-filters CTA when no audit events match the current view.')
|
||||||
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Selected-event detail keeps close-inspection and related-navigation actions at the page header.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->authorizePageAccess();
|
||||||
|
$requestedEventId = is_numeric(request()->query('event')) ? (int) request()->query('event') : null;
|
||||||
|
|
||||||
|
app(CanonicalAdminTenantFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request());
|
||||||
|
|
||||||
|
$this->mountInteractsWithTable();
|
||||||
|
|
||||||
|
if ($requestedEventId !== null) {
|
||||||
|
$this->resolveAuditLog($requestedEventId);
|
||||||
|
$this->selectedAuditLogId = $requestedEventId;
|
||||||
|
$this->mountTableAction('inspect', (string) $requestedEventId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<Action>
|
* @return array<Action>
|
||||||
*/
|
*/
|
||||||
@ -38,4 +106,296 @@ protected function getHeaderActions(): array
|
|||||||
returnActionName: 'operate_hub_return_audit_log',
|
returnActionName: 'operate_hub_return_audit_log',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->query(fn (): Builder => $this->auditBaseQuery())
|
||||||
|
->defaultSort('recorded_at', 'desc')
|
||||||
|
->paginated(TablePaginationProfiles::customPage())
|
||||||
|
->persistFiltersInSession()
|
||||||
|
->persistSearchInSession()
|
||||||
|
->persistSortInSession()
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('outcome')
|
||||||
|
->label('Outcome')
|
||||||
|
->badge()
|
||||||
|
->getStateUsing(fn (AuditLogModel $record): string => $record->normalizedOutcome()->value)
|
||||||
|
->formatStateUsing(fn (string $state): string => BadgeRenderer::label(BadgeDomain::AuditOutcome)($state))
|
||||||
|
->color(fn (string $state): string => BadgeRenderer::color(BadgeDomain::AuditOutcome)($state))
|
||||||
|
->icon(fn (string $state): ?string => BadgeRenderer::icon(BadgeDomain::AuditOutcome)($state))
|
||||||
|
->iconColor(fn (string $state): ?string => BadgeRenderer::iconColor(BadgeDomain::AuditOutcome)($state)),
|
||||||
|
TextColumn::make('summary')
|
||||||
|
->label('Event')
|
||||||
|
->getStateUsing(fn (AuditLogModel $record): string => $record->summaryText())
|
||||||
|
->description(fn (AuditLogModel $record): string => AuditActionId::labelFor((string) $record->action))
|
||||||
|
->searchable()
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('actor_label')
|
||||||
|
->label('Actor')
|
||||||
|
->getStateUsing(fn (AuditLogModel $record): string => $record->actorDisplayLabel())
|
||||||
|
->description(fn (AuditLogModel $record): string => BadgeRenderer::label(BadgeDomain::AuditActorType)($record->actorSnapshot()->type->value))
|
||||||
|
->searchable(),
|
||||||
|
TextColumn::make('target_label')
|
||||||
|
->label('Target')
|
||||||
|
->getStateUsing(fn (AuditLogModel $record): string => $record->targetDisplayLabel() ?? 'No target snapshot')
|
||||||
|
->searchable()
|
||||||
|
->toggleable(),
|
||||||
|
TextColumn::make('tenant.name')
|
||||||
|
->label('Tenant')
|
||||||
|
->formatStateUsing(fn (?string $state): string => $state ?: 'Workspace')
|
||||||
|
->toggleable(),
|
||||||
|
TextColumn::make('recorded_at')
|
||||||
|
->label('Recorded')
|
||||||
|
->since()
|
||||||
|
->sortable(),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
SelectFilter::make('tenant_id')
|
||||||
|
->label('Tenant')
|
||||||
|
->options(fn (): array => $this->tenantFilterOptions())
|
||||||
|
->default(fn (): ?string => $this->defaultTenantFilter())
|
||||||
|
->searchable(),
|
||||||
|
SelectFilter::make('action')
|
||||||
|
->label('Event type')
|
||||||
|
->options(fn (): array => $this->actionFilterOptions())
|
||||||
|
->searchable(),
|
||||||
|
SelectFilter::make('outcome')
|
||||||
|
->label('Outcome')
|
||||||
|
->options(FilterOptionCatalog::auditOutcomes()),
|
||||||
|
SelectFilter::make('actor_label')
|
||||||
|
->label('Actor')
|
||||||
|
->options(fn (): array => $this->actorFilterOptions())
|
||||||
|
->searchable(),
|
||||||
|
SelectFilter::make('resource_type')
|
||||||
|
->label('Target type')
|
||||||
|
->options(fn (): array => $this->targetTypeFilterOptions()),
|
||||||
|
FilterPresets::dateRange('recorded_at', 'Recorded', 'recorded_at'),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Action::make('inspect')
|
||||||
|
->label('Inspect event')
|
||||||
|
->icon('heroicon-o-eye')
|
||||||
|
->color('gray')
|
||||||
|
->before(function (AuditLogModel $record): void {
|
||||||
|
$this->selectedAuditLogId = (int) $record->getKey();
|
||||||
|
})
|
||||||
|
->slideOver()
|
||||||
|
->stickyModalHeader()
|
||||||
|
->modalSubmitAction(false)
|
||||||
|
->modalCancelAction(fn (Action $action): Action => $action->label('Close details'))
|
||||||
|
->modalHeading(fn (AuditLogModel $record): string => $record->summaryText())
|
||||||
|
->modalDescription(fn (AuditLogModel $record): ?string => $record->recorded_at?->toDayDateTimeString())
|
||||||
|
->modalContent(fn (AuditLogModel $record): View => view('filament.pages.monitoring.partials.audit-log-inspect-event', [
|
||||||
|
'selectedAudit' => $record,
|
||||||
|
'selectedAuditLink' => $this->auditTargetLink($record),
|
||||||
|
])),
|
||||||
|
])
|
||||||
|
->bulkActions([])
|
||||||
|
->emptyStateHeading('No audit events match this view')
|
||||||
|
->emptyStateDescription('Clear the current search or filters to return to the workspace-wide audit history.')
|
||||||
|
->emptyStateIcon('heroicon-o-funnel')
|
||||||
|
->emptyStateActions([
|
||||||
|
Action::make('clear_filters')
|
||||||
|
->label('Clear filters')
|
||||||
|
->icon('heroicon-o-x-mark')
|
||||||
|
->color('gray')
|
||||||
|
->action(function (): void {
|
||||||
|
$this->resetTable();
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, Tenant>
|
||||||
|
*/
|
||||||
|
public function authorizedTenants(): array
|
||||||
|
{
|
||||||
|
if ($this->authorizedTenants !== null) {
|
||||||
|
return $this->authorizedTenants;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! is_numeric($workspaceId)) {
|
||||||
|
return $this->authorizedTenants = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenants = collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
|
||||||
|
->filter(static fn (Tenant $tenant): bool => (int) $tenant->workspace_id === (int) $workspaceId)
|
||||||
|
->filter(static fn (Tenant $tenant): bool => $tenant->isActive())
|
||||||
|
->keyBy(fn (Tenant $tenant): int => (int) $tenant->getKey())
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return $this->authorizedTenants = $tenants;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizePageAccess(): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
$workspace = is_numeric($workspaceId) ? Workspace::query()->whereKey((int) $workspaceId)->first() : null;
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
throw new NotFoundHttpException;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $workspace)) {
|
||||||
|
throw new NotFoundHttpException;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $workspace, Capabilities::AUDIT_VIEW)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function auditBaseQuery(): Builder
|
||||||
|
{
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
$authorizedTenantIds = array_map(
|
||||||
|
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
|
||||||
|
$this->authorizedTenants(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return AuditLogModel::query()
|
||||||
|
->with(['tenant', 'workspace', 'operationRun'])
|
||||||
|
->forWorkspace((int) $workspaceId)
|
||||||
|
->where(function (Builder $query) use ($authorizedTenantIds): void {
|
||||||
|
$query->whereNull('tenant_id');
|
||||||
|
|
||||||
|
if ($authorizedTenantIds !== []) {
|
||||||
|
$query->orWhereIn('tenant_id', $authorizedTenantIds);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->latestFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveAuditLog(int $auditLogId): AuditLogModel
|
||||||
|
{
|
||||||
|
$record = $this->auditBaseQuery()
|
||||||
|
->whereKey($auditLogId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $record instanceof AuditLogModel) {
|
||||||
|
throw new NotFoundHttpException;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function selectedAuditRecord(): ?AuditLogModel
|
||||||
|
{
|
||||||
|
if (! is_int($this->selectedAuditLogId) || $this->selectedAuditLogId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $this->resolveAuditLog($this->selectedAuditLogId);
|
||||||
|
} catch (NotFoundHttpException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{label: string, url: string}|null
|
||||||
|
*/
|
||||||
|
public function selectedAuditTargetLink(): ?array
|
||||||
|
{
|
||||||
|
$record = $this->selectedAuditRecord();
|
||||||
|
|
||||||
|
if (! $record instanceof AuditLogModel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->auditTargetLink($record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{label: string, url: string}|null
|
||||||
|
*/
|
||||||
|
private function auditTargetLink(AuditLogModel $record): ?array
|
||||||
|
{
|
||||||
|
return app(RelatedNavigationResolver::class)->auditTargetLink($record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function tenantFilterOptions(): array
|
||||||
|
{
|
||||||
|
return collect($this->authorizedTenants())
|
||||||
|
->mapWithKeys(static fn (Tenant $tenant): array => [
|
||||||
|
(string) $tenant->getKey() => $tenant->getFilamentName(),
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function defaultTenantFilter(): ?string
|
||||||
|
{
|
||||||
|
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||||
|
|
||||||
|
if (! $activeTenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_key_exists((int) $activeTenant->getKey(), $this->authorizedTenants())
|
||||||
|
? (string) $activeTenant->getKey()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function actionFilterOptions(): array
|
||||||
|
{
|
||||||
|
$values = (clone $this->auditBaseQuery())
|
||||||
|
->reorder()
|
||||||
|
->select('action')
|
||||||
|
->distinct()
|
||||||
|
->orderBy('action')
|
||||||
|
->pluck('action')
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return FilterOptionCatalog::auditActions($values);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function actorFilterOptions(): array
|
||||||
|
{
|
||||||
|
return (clone $this->auditBaseQuery())
|
||||||
|
->reorder()
|
||||||
|
->whereNotNull('actor_label')
|
||||||
|
->select('actor_label')
|
||||||
|
->distinct()
|
||||||
|
->orderBy('actor_label')
|
||||||
|
->pluck('actor_label', 'actor_label')
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function targetTypeFilterOptions(): array
|
||||||
|
{
|
||||||
|
$values = (clone $this->auditBaseQuery())
|
||||||
|
->reorder()
|
||||||
|
->whereNotNull('resource_type')
|
||||||
|
->select('resource_type')
|
||||||
|
->distinct()
|
||||||
|
->orderBy('resource_type')
|
||||||
|
->pluck('resource_type')
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return FilterOptionCatalog::auditTargetTypes($values);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
146
app/Filament/Pages/Monitoring/EvidenceOverview.php
Normal file
146
app/Filament/Pages/Monitoring/EvidenceOverview.php
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\Monitoring;
|
||||||
|
|
||||||
|
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||||
|
use App\Models\EvidenceSnapshot;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Illuminate\Auth\AuthenticationException;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class EvidenceOverview extends Page
|
||||||
|
{
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Evidence Overview';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.monitoring.evidence-overview';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public array $rows = [];
|
||||||
|
|
||||||
|
public ?int $tenantFilter = null;
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'The overview header exposes a clear-filters action when a tenant prefilter is active.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The overview exposes a single drill-down link per row without a More menu.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The overview does not expose bulk actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains the current scope and offers a clear-filters CTA.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
throw new AuthenticationException;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceContext = app(WorkspaceContext::class);
|
||||||
|
$workspace = $workspaceContext->currentWorkspaceForMemberOrFail($user, request());
|
||||||
|
$workspaceId = (int) $workspace->getKey();
|
||||||
|
|
||||||
|
$accessibleTenants = $user->tenants()
|
||||||
|
->where('tenants.workspace_id', $workspaceId)
|
||||||
|
->orderBy('tenants.name')
|
||||||
|
->get()
|
||||||
|
->filter(fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId && $user->can('evidence.view', $tenant))
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$this->tenantFilter = is_numeric(request()->query('tenant_id')) ? (int) request()->query('tenant_id') : null;
|
||||||
|
|
||||||
|
$tenantIds = $accessibleTenants->pluck('id')->map(static fn (mixed $id): int => (int) $id)->all();
|
||||||
|
|
||||||
|
$query = EvidenceSnapshot::query()
|
||||||
|
->with('tenant')
|
||||||
|
->where('workspace_id', $workspaceId)
|
||||||
|
->whereIn('tenant_id', $tenantIds)
|
||||||
|
->where('status', 'active')
|
||||||
|
->latest('generated_at');
|
||||||
|
|
||||||
|
if ($this->tenantFilter !== null) {
|
||||||
|
$query->where('tenant_id', $this->tenantFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
$snapshots = $query->get()->unique('tenant_id')->values();
|
||||||
|
|
||||||
|
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot): array {
|
||||||
|
$truth = $this->snapshotTruth($snapshot);
|
||||||
|
$freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
|
||||||
|
'tenant_id' => (int) $snapshot->tenant_id,
|
||||||
|
'snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'completeness_state' => (string) $snapshot->completeness_state,
|
||||||
|
'generated_at' => $snapshot->generated_at?->toDateTimeString(),
|
||||||
|
'missing_dimensions' => (int) (($snapshot->summary['missing_dimensions'] ?? 0)),
|
||||||
|
'stale_dimensions' => (int) (($snapshot->summary['stale_dimensions'] ?? 0)),
|
||||||
|
'artifact_truth' => [
|
||||||
|
'label' => $truth->primaryLabel,
|
||||||
|
'color' => $truth->primaryBadgeSpec()->color,
|
||||||
|
'icon' => $truth->primaryBadgeSpec()->icon,
|
||||||
|
'explanation' => $truth->primaryExplanation,
|
||||||
|
],
|
||||||
|
'freshness' => [
|
||||||
|
'label' => $freshnessSpec->label,
|
||||||
|
'color' => $freshnessSpec->color,
|
||||||
|
'icon' => $freshnessSpec->icon,
|
||||||
|
],
|
||||||
|
'next_step' => $truth->nextStepText(),
|
||||||
|
'view_url' => $snapshot->tenant
|
||||||
|
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
|
||||||
|
: null,
|
||||||
|
];
|
||||||
|
})->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action>
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Action::make('clear_filters')
|
||||||
|
->label('Clear filters')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (): bool => $this->tenantFilter !== null)
|
||||||
|
->url(route('admin.evidence.overview')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function snapshotTruth(EvidenceSnapshot $snapshot, bool $fresh = false): ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? $presenter->forEvidenceSnapshotFresh($snapshot)
|
||||||
|
: $presenter->forEvidenceSnapshot($snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
539
app/Filament/Pages/Monitoring/FindingExceptionsQueue.php
Normal file
539
app/Filament/Pages/Monitoring/FindingExceptionsQueue.php
Normal file
@ -0,0 +1,539 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\Monitoring;
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingExceptionResource;
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Models\FindingException;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
|
use App\Services\Findings\FindingExceptionService;
|
||||||
|
use App\Services\Findings\FindingRiskGovernanceResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class FindingExceptionsQueue extends Page implements HasTable
|
||||||
|
{
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
public ?int $selectedFindingExceptionId = null;
|
||||||
|
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-exclamation';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Finding exceptions';
|
||||||
|
|
||||||
|
protected static ?string $slug = 'finding-exceptions/queue';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Finding Exceptions Queue';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.monitoring.finding-exceptions-queue';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<int, Tenant>|null
|
||||||
|
*/
|
||||||
|
private ?array $authorizedTenants = null;
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
||||||
|
->withDefaults(new ActionSurfaceDefaults(
|
||||||
|
moreGroupLabel: 'More',
|
||||||
|
exportIsDefaultBulkActionForReadOnly: false,
|
||||||
|
))
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep workspace approval scope visible and expose selected exception review actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Exception decisions are reviewed one record at a time in v1 and do not expose bulk actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state explains when the approval queue is empty and keeps navigation back to tenant findings available.')
|
||||||
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Selected exception detail exposes approve, reject, and related-record navigation actions in the page header.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canAccess(): bool
|
||||||
|
{
|
||||||
|
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
if (! is_int($workspaceId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var WorkspaceCapabilityResolver $resolver */
|
||||||
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->isMember($user, $workspace)
|
||||||
|
&& $resolver->can($user, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->selectedFindingExceptionId = is_numeric(request()->query('exception')) ? (int) request()->query('exception') : null;
|
||||||
|
$this->mountInteractsWithTable();
|
||||||
|
$this->applyRequestedTenantPrefilter();
|
||||||
|
|
||||||
|
if ($this->selectedFindingExceptionId !== null) {
|
||||||
|
$this->selectedFindingException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
$actions = app(OperateHubShell::class)->headerActions(
|
||||||
|
scopeActionName: 'operate_hub_scope_finding_exceptions',
|
||||||
|
returnActionName: 'operate_hub_return_finding_exceptions',
|
||||||
|
);
|
||||||
|
|
||||||
|
$actions[] = Action::make('clear_filters')
|
||||||
|
->label('Clear filters')
|
||||||
|
->icon('heroicon-o-x-mark')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (): bool => $this->hasActiveQueueFilters())
|
||||||
|
->action(function (): void {
|
||||||
|
$this->removeTableFilter('tenant_id');
|
||||||
|
$this->removeTableFilter('status');
|
||||||
|
$this->removeTableFilter('current_validity_state');
|
||||||
|
$this->selectedFindingExceptionId = null;
|
||||||
|
$this->resetTable();
|
||||||
|
});
|
||||||
|
|
||||||
|
$actions[] = Action::make('view_tenant_register')
|
||||||
|
->label('View tenant register')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (): bool => $this->filteredTenant() instanceof Tenant)
|
||||||
|
->url(function (): ?string {
|
||||||
|
$tenant = $this->filteredTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return FindingExceptionResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||||
|
});
|
||||||
|
|
||||||
|
$actions[] = Action::make('clear_selected_exception')
|
||||||
|
->label('Close details')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
||||||
|
->action(function (): void {
|
||||||
|
$this->selectedFindingExceptionId = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
$actions[] = Action::make('open_selected_exception')
|
||||||
|
->label('Open tenant detail')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
||||||
|
->url(fn (): ?string => $this->selectedExceptionUrl());
|
||||||
|
|
||||||
|
$actions[] = Action::make('open_selected_finding')
|
||||||
|
->label('Open finding')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
||||||
|
->url(fn (): ?string => $this->selectedFindingUrl());
|
||||||
|
|
||||||
|
$actions[] = Action::make('approve_selected_exception')
|
||||||
|
->label('Approve exception')
|
||||||
|
->color('success')
|
||||||
|
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false)
|
||||||
|
->requiresConfirmation()
|
||||||
|
->form([
|
||||||
|
DateTimePicker::make('effective_from')
|
||||||
|
->label('Effective from')
|
||||||
|
->required()
|
||||||
|
->seconds(false),
|
||||||
|
DateTimePicker::make('expires_at')
|
||||||
|
->label('Expires at')
|
||||||
|
->required()
|
||||||
|
->seconds(false),
|
||||||
|
Textarea::make('approval_reason')
|
||||||
|
->label('Approval reason')
|
||||||
|
->rows(3)
|
||||||
|
->maxLength(2000),
|
||||||
|
])
|
||||||
|
->action(function (array $data, FindingExceptionService $service): void {
|
||||||
|
$record = $this->selectedFindingException();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $record instanceof FindingException || ! $user instanceof User) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$wasRenewalRequest = $record->isPendingRenewal();
|
||||||
|
$updated = $service->approve($record, $user, $data);
|
||||||
|
$this->selectedFindingExceptionId = (int) $updated->getKey();
|
||||||
|
$this->resetTable();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title($wasRenewalRequest ? 'Exception renewed' : 'Exception approved')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
});
|
||||||
|
|
||||||
|
$actions[] = Action::make('reject_selected_exception')
|
||||||
|
->label('Reject exception')
|
||||||
|
->color('danger')
|
||||||
|
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false)
|
||||||
|
->requiresConfirmation()
|
||||||
|
->form([
|
||||||
|
Textarea::make('rejection_reason')
|
||||||
|
->label('Rejection reason')
|
||||||
|
->rows(3)
|
||||||
|
->required()
|
||||||
|
->maxLength(2000),
|
||||||
|
])
|
||||||
|
->action(function (array $data, FindingExceptionService $service): void {
|
||||||
|
$record = $this->selectedFindingException();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $record instanceof FindingException || ! $user instanceof User) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$wasRenewalRequest = $record->isPendingRenewal();
|
||||||
|
$updated = $service->reject($record, $user, $data);
|
||||||
|
$this->selectedFindingExceptionId = (int) $updated->getKey();
|
||||||
|
$this->resetTable();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title($wasRenewalRequest ? 'Renewal rejected' : 'Exception rejected')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
});
|
||||||
|
|
||||||
|
return $actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->query(fn (): Builder => $this->queueBaseQuery())
|
||||||
|
->defaultSort('requested_at', 'asc')
|
||||||
|
->paginated(TablePaginationProfiles::customPage())
|
||||||
|
->persistFiltersInSession()
|
||||||
|
->persistSearchInSession()
|
||||||
|
->persistSortInSession()
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('status')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus)),
|
||||||
|
TextColumn::make('current_validity_state')
|
||||||
|
->label('Validity')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity)),
|
||||||
|
TextColumn::make('tenant.name')
|
||||||
|
->label('Tenant')
|
||||||
|
->searchable(),
|
||||||
|
TextColumn::make('finding_summary')
|
||||||
|
->label('Finding')
|
||||||
|
->state(fn (FindingException $record): string => $record->finding?->resolvedSubjectDisplayName() ?: 'Finding #'.$record->finding_id)
|
||||||
|
->searchable(),
|
||||||
|
TextColumn::make('governance_warning')
|
||||||
|
->label('Governance warning')
|
||||||
|
->state(fn (FindingException $record): ?string => $this->governanceWarning($record))
|
||||||
|
->color(fn (FindingException $record): string => $this->governanceWarningColor($record))
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('requester.name')
|
||||||
|
->label('Requested by')
|
||||||
|
->placeholder('—'),
|
||||||
|
TextColumn::make('owner.name')
|
||||||
|
->label('Owner')
|
||||||
|
->placeholder('—'),
|
||||||
|
TextColumn::make('review_due_at')
|
||||||
|
->label('Review due')
|
||||||
|
->dateTime()
|
||||||
|
->placeholder('—')
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('expires_at')
|
||||||
|
->label('Expires')
|
||||||
|
->dateTime()
|
||||||
|
->placeholder('—')
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('requested_at')
|
||||||
|
->label('Requested')
|
||||||
|
->dateTime()
|
||||||
|
->sortable(),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
SelectFilter::make('tenant_id')
|
||||||
|
->label('Tenant')
|
||||||
|
->options(fn (): array => $this->tenantFilterOptions())
|
||||||
|
->searchable(),
|
||||||
|
SelectFilter::make('status')
|
||||||
|
->options(FilterOptionCatalog::findingExceptionStatuses()),
|
||||||
|
SelectFilter::make('current_validity_state')
|
||||||
|
->label('Validity')
|
||||||
|
->options(FilterOptionCatalog::findingExceptionValidityStates()),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Action::make('inspect_exception')
|
||||||
|
->label('Inspect exception')
|
||||||
|
->icon('heroicon-o-eye')
|
||||||
|
->color('gray')
|
||||||
|
->action(function (FindingException $record): void {
|
||||||
|
$this->selectedFindingExceptionId = (int) $record->getKey();
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->bulkActions([])
|
||||||
|
->emptyStateHeading('No exceptions match this queue')
|
||||||
|
->emptyStateDescription('Adjust the current tenant or lifecycle filters to review governed exceptions in this workspace.')
|
||||||
|
->emptyStateIcon('heroicon-o-shield-check')
|
||||||
|
->emptyStateActions([
|
||||||
|
Action::make('clear_filters')
|
||||||
|
->label('Clear filters')
|
||||||
|
->icon('heroicon-o-x-mark')
|
||||||
|
->color('gray')
|
||||||
|
->action(function (): void {
|
||||||
|
$this->removeTableFilter('tenant_id');
|
||||||
|
$this->removeTableFilter('status');
|
||||||
|
$this->removeTableFilter('current_validity_state');
|
||||||
|
$this->selectedFindingExceptionId = null;
|
||||||
|
$this->resetTable();
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function selectedFindingException(): ?FindingException
|
||||||
|
{
|
||||||
|
if (! is_int($this->selectedFindingExceptionId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record = $this->queueBaseQuery()
|
||||||
|
->whereKey($this->selectedFindingExceptionId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $record instanceof FindingException) {
|
||||||
|
throw new NotFoundHttpException;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function selectedExceptionUrl(): ?string
|
||||||
|
{
|
||||||
|
$record = $this->selectedFindingException();
|
||||||
|
|
||||||
|
if (! $record instanceof FindingException || ! $record->tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function selectedFindingUrl(): ?string
|
||||||
|
{
|
||||||
|
$record = $this->selectedFindingException();
|
||||||
|
|
||||||
|
if (! $record instanceof FindingException || ! $record->finding || ! $record->tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, Tenant>
|
||||||
|
*/
|
||||||
|
public function authorizedTenants(): array
|
||||||
|
{
|
||||||
|
if ($this->authorizedTenants !== null) {
|
||||||
|
return $this->authorizedTenants;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return $this->authorizedTenants = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
if (! is_int($workspaceId)) {
|
||||||
|
return $this->authorizedTenants = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenants = $user->tenants()
|
||||||
|
->where('tenants.workspace_id', $workspaceId)
|
||||||
|
->orderBy('tenants.name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $this->authorizedTenants = $tenants
|
||||||
|
->filter(fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId)
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function queueBaseQuery(): Builder
|
||||||
|
{
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
$tenantIds = array_values(array_map(
|
||||||
|
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
|
||||||
|
$this->authorizedTenants(),
|
||||||
|
));
|
||||||
|
|
||||||
|
return FindingException::query()
|
||||||
|
->with([
|
||||||
|
'tenant',
|
||||||
|
'requester',
|
||||||
|
'owner',
|
||||||
|
'approver',
|
||||||
|
'finding' => fn ($query) => $query->withSubjectDisplayName(),
|
||||||
|
'decisions.actor',
|
||||||
|
'evidenceReferences',
|
||||||
|
])
|
||||||
|
->where('workspace_id', (int) $workspaceId)
|
||||||
|
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function tenantFilterOptions(): array
|
||||||
|
{
|
||||||
|
return Collection::make($this->authorizedTenants())
|
||||||
|
->mapWithKeys(static fn (Tenant $tenant): array => [
|
||||||
|
(string) $tenant->getKey() => $tenant->name,
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyRequestedTenantPrefilter(): void
|
||||||
|
{
|
||||||
|
$requestedTenant = request()->query('tenant');
|
||||||
|
|
||||||
|
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->authorizedTenants() as $tenant) {
|
||||||
|
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
||||||
|
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function filteredTenant(): ?Tenant
|
||||||
|
{
|
||||||
|
$tenantId = $this->currentTenantFilterId();
|
||||||
|
|
||||||
|
if (! is_int($tenantId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->authorizedTenants() as $tenant) {
|
||||||
|
if ((int) $tenant->getKey() === $tenantId) {
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function currentTenantFilterId(): ?int
|
||||||
|
{
|
||||||
|
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
|
||||||
|
|
||||||
|
if (! is_numeric($tenantFilter)) {
|
||||||
|
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_numeric($tenantFilter) ? (int) $tenantFilter : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasActiveQueueFilters(): bool
|
||||||
|
{
|
||||||
|
return $this->currentTenantFilterId() !== null
|
||||||
|
|| is_string(data_get($this->tableFilters, 'status.value'))
|
||||||
|
|| is_string(data_get($this->tableFilters, 'current_validity_state.value'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function governanceWarning(FindingException $record): ?string
|
||||||
|
{
|
||||||
|
$finding = $record->relationLoaded('finding')
|
||||||
|
? $record->finding
|
||||||
|
: $record->finding()->withSubjectDisplayName()->first();
|
||||||
|
|
||||||
|
if (! $finding instanceof \App\Models\Finding) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(FindingRiskGovernanceResolver::class)->resolveWarningMessage($finding, $record);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function governanceWarningColor(FindingException $record): string
|
||||||
|
{
|
||||||
|
if ((string) $record->current_validity_state === FindingException::VALIDITY_EXPIRING) {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
$finding = $record->relationLoaded('finding')
|
||||||
|
? $record->finding
|
||||||
|
: $record->finding()->withSubjectDisplayName()->first();
|
||||||
|
|
||||||
|
if ($finding instanceof \App\Models\Finding && $record->requiresFreshDecisionForFinding($finding)) {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'danger';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,9 +8,12 @@
|
|||||||
use App\Filament\Widgets\Operations\OperationsKpiHeader;
|
use App\Filament\Widgets\Operations\OperationsKpiHeader;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||||
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\Operations\OperationLifecyclePolicy;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -31,6 +34,11 @@ class Operations extends Page implements HasForms, HasTable
|
|||||||
|
|
||||||
public string $activeTab = 'all';
|
public string $activeTab = 'all';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public ?array $navigationContextPayload = null;
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
|
||||||
@ -44,6 +52,14 @@ class Operations extends Page implements HasForms, HasTable
|
|||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
|
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
|
||||||
|
|
||||||
|
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||||
|
$this->getTableFiltersSessionKey(),
|
||||||
|
['type', 'initiator_name'],
|
||||||
|
request(),
|
||||||
|
);
|
||||||
|
|
||||||
$this->mountInteractsWithTable();
|
$this->mountInteractsWithTable();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,6 +76,7 @@ protected function getHeaderWidgets(): array
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
$operateHubShell = app(OperateHubShell::class);
|
$operateHubShell = app(OperateHubShell::class);
|
||||||
|
$navigationContext = $this->navigationContext();
|
||||||
|
|
||||||
$actions = [
|
$actions = [
|
||||||
Action::make('operate_hub_scope_operations')
|
Action::make('operate_hub_scope_operations')
|
||||||
@ -70,13 +87,21 @@ protected function getHeaderActions(): array
|
|||||||
|
|
||||||
$activeTenant = $operateHubShell->activeEntitledTenant(request());
|
$activeTenant = $operateHubShell->activeEntitledTenant(request());
|
||||||
|
|
||||||
if ($activeTenant instanceof Tenant) {
|
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||||
|
$actions[] = Action::make('operate_hub_back_to_origin_operations')
|
||||||
|
->label($navigationContext->backLinkLabel)
|
||||||
|
->icon('heroicon-o-arrow-left')
|
||||||
|
->color('gray')
|
||||||
|
->url($navigationContext->backLinkUrl);
|
||||||
|
} elseif ($activeTenant instanceof Tenant) {
|
||||||
$actions[] = Action::make('operate_hub_back_to_tenant_operations')
|
$actions[] = Action::make('operate_hub_back_to_tenant_operations')
|
||||||
->label('Back to '.$activeTenant->name)
|
->label('Back to '.$activeTenant->name)
|
||||||
->icon('heroicon-o-arrow-left')
|
->icon('heroicon-o-arrow-left')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
|
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($activeTenant instanceof Tenant) {
|
||||||
$actions[] = Action::make('operate_hub_show_all_tenants')
|
$actions[] = Action::make('operate_hub_show_all_tenants')
|
||||||
->label('Show all tenants')
|
->label('Show all tenants')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
@ -94,6 +119,17 @@ protected function getHeaderActions(): array
|
|||||||
return $actions;
|
return $actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function navigationContext(): ?CanonicalNavigationContext
|
||||||
|
{
|
||||||
|
if (! is_array($this->navigationContextPayload)) {
|
||||||
|
return CanonicalNavigationContext::fromRequest(request());
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = request()->duplicate(query: ['nav' => $this->navigationContextPayload]);
|
||||||
|
|
||||||
|
return CanonicalNavigationContext::fromRequest($request);
|
||||||
|
}
|
||||||
|
|
||||||
public function updatedActiveTab(): void
|
public function updatedActiveTab(): void
|
||||||
{
|
{
|
||||||
$this->resetPage();
|
$this->resetPage();
|
||||||
@ -104,8 +140,11 @@ public function table(Table $table): Table
|
|||||||
return OperationRunResource::table($table)
|
return OperationRunResource::table($table)
|
||||||
->query(function (): Builder {
|
->query(function (): Builder {
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
|
||||||
|
|
||||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
if (! is_numeric($tenantFilter)) {
|
||||||
|
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
|
||||||
|
}
|
||||||
|
|
||||||
$query = OperationRun::query()
|
$query = OperationRun::query()
|
||||||
->with('user')
|
->with('user')
|
||||||
@ -119,14 +158,76 @@ public function table(Table $table): Table
|
|||||||
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||||
)
|
)
|
||||||
->when(
|
->when(
|
||||||
$activeTenant instanceof Tenant,
|
is_numeric($tenantFilter),
|
||||||
fn (Builder $query): Builder => $query->where('tenant_id', (int) $activeTenant->getKey()),
|
fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenantFilter),
|
||||||
);
|
);
|
||||||
|
|
||||||
return $this->applyActiveTab($query);
|
return $this->applyActiveTab($query);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{likely_stale:int,reconciled:int}
|
||||||
|
*/
|
||||||
|
public function lifecycleVisibilitySummary(): array
|
||||||
|
{
|
||||||
|
$baseQuery = $this->scopedSummaryQuery();
|
||||||
|
|
||||||
|
if (! $baseQuery instanceof Builder) {
|
||||||
|
return [
|
||||||
|
'likely_stale' => 0,
|
||||||
|
'reconciled' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$reconciled = (clone $baseQuery)
|
||||||
|
->whereNotNull('context->reconciliation->reconciled_at')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$policy = app(OperationLifecyclePolicy::class);
|
||||||
|
$likelyStale = (clone $baseQuery)
|
||||||
|
->whereIn('status', [
|
||||||
|
OperationRunStatus::Queued->value,
|
||||||
|
OperationRunStatus::Running->value,
|
||||||
|
])
|
||||||
|
->where(function (Builder $query) use ($policy): void {
|
||||||
|
foreach ($policy->coveredTypeNames() as $type) {
|
||||||
|
$query->orWhere(function (Builder $typeQuery) use ($policy, $type): void {
|
||||||
|
$typeQuery
|
||||||
|
->where('type', $type)
|
||||||
|
->where(function (Builder $stateQuery) use ($policy, $type): void {
|
||||||
|
$stateQuery
|
||||||
|
->where(function (Builder $queuedQuery) use ($policy, $type): void {
|
||||||
|
$queuedQuery
|
||||||
|
->where('status', OperationRunStatus::Queued->value)
|
||||||
|
->whereNull('started_at')
|
||||||
|
->where('created_at', '<=', now()->subSeconds($policy->queuedStaleAfterSeconds($type)));
|
||||||
|
})
|
||||||
|
->orWhere(function (Builder $runningQuery) use ($policy, $type): void {
|
||||||
|
$runningQuery
|
||||||
|
->where('status', OperationRunStatus::Running->value)
|
||||||
|
->where(function (Builder $startedAtQuery) use ($policy, $type): void {
|
||||||
|
$startedAtQuery
|
||||||
|
->where('started_at', '<=', now()->subSeconds($policy->runningStaleAfterSeconds($type)))
|
||||||
|
->orWhere(function (Builder $fallbackQuery) use ($policy, $type): void {
|
||||||
|
$fallbackQuery
|
||||||
|
->whereNull('started_at')
|
||||||
|
->where('created_at', '<=', now()->subSeconds($policy->runningStaleAfterSeconds($type)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'likely_stale' => $likelyStale,
|
||||||
|
'reconciled' => $reconciled,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private function applyActiveTab(Builder $query): Builder
|
private function applyActiveTab(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return match ($this->activeTab) {
|
return match ($this->activeTab) {
|
||||||
@ -134,6 +235,9 @@ private function applyActiveTab(Builder $query): Builder
|
|||||||
OperationRunStatus::Queued->value,
|
OperationRunStatus::Queued->value,
|
||||||
OperationRunStatus::Running->value,
|
OperationRunStatus::Running->value,
|
||||||
]),
|
]),
|
||||||
|
'blocked' => $query
|
||||||
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
|
->where('outcome', OperationRunOutcome::Blocked->value),
|
||||||
'succeeded' => $query
|
'succeeded' => $query
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
->where('outcome', OperationRunOutcome::Succeeded->value),
|
->where('outcome', OperationRunOutcome::Succeeded->value),
|
||||||
@ -146,4 +250,26 @@ private function applyActiveTab(Builder $query): Builder
|
|||||||
default => $query,
|
default => $query,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function scopedSummaryQuery(): ?Builder
|
||||||
|
{
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
if (! $workspaceId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
|
||||||
|
|
||||||
|
if (! is_numeric($tenantFilter)) {
|
||||||
|
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
|
||||||
|
}
|
||||||
|
|
||||||
|
return OperationRun::query()
|
||||||
|
->where('workspace_id', (int) $workspaceId)
|
||||||
|
->when(
|
||||||
|
is_numeric($tenantFilter),
|
||||||
|
fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenantFilter),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,12 +11,21 @@
|
|||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
|
use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
|
||||||
|
use App\Services\Tenants\TenantOperabilityService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
|
use App\Support\OpsUx\RunDetailPolling;
|
||||||
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
use App\Support\RedactionIntegrity;
|
use App\Support\RedactionIntegrity;
|
||||||
|
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||||
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||||
|
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -40,7 +49,10 @@ class TenantlessOperationRunViewer extends Page
|
|||||||
|
|
||||||
public OperationRun $run;
|
public OperationRun $run;
|
||||||
|
|
||||||
public bool $opsUxIsTabHidden = false;
|
/**
|
||||||
|
* @var array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public ?array $navigationContextPayload = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<Action|ActionGroup>
|
* @return array<Action|ActionGroup>
|
||||||
@ -48,6 +60,9 @@ class TenantlessOperationRunViewer extends Page
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
$operateHubShell = app(OperateHubShell::class);
|
$operateHubShell = app(OperateHubShell::class);
|
||||||
|
$navigationContext = $this->navigationContext();
|
||||||
|
$activeTenant = $operateHubShell->activeEntitledTenant(request());
|
||||||
|
$runTenantId = isset($this->run) ? (int) ($this->run->tenant_id ?? 0) : 0;
|
||||||
|
|
||||||
$actions = [
|
$actions = [
|
||||||
Action::make('operate_hub_scope_run_detail')
|
Action::make('operate_hub_scope_run_detail')
|
||||||
@ -56,18 +71,16 @@ protected function getHeaderActions(): array
|
|||||||
->disabled(),
|
->disabled(),
|
||||||
];
|
];
|
||||||
|
|
||||||
$activeTenant = $operateHubShell->activeEntitledTenant(request());
|
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||||
|
$actions[] = Action::make('operate_hub_back_to_origin_run_detail')
|
||||||
if ($activeTenant instanceof Tenant) {
|
->label($navigationContext->backLinkLabel)
|
||||||
|
->color('gray')
|
||||||
|
->url($navigationContext->backLinkUrl);
|
||||||
|
} elseif ($activeTenant instanceof Tenant && (int) $activeTenant->getKey() === $runTenantId) {
|
||||||
$actions[] = Action::make('operate_hub_back_to_tenant_run_detail')
|
$actions[] = Action::make('operate_hub_back_to_tenant_run_detail')
|
||||||
->label('← Back to '.$activeTenant->name)
|
->label('← Back to '.$activeTenant->name)
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
|
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
|
||||||
|
|
||||||
$actions[] = Action::make('operate_hub_show_all_operations')
|
|
||||||
->label('Show all operations')
|
|
||||||
->color('gray')
|
|
||||||
->url(fn (): string => route('admin.operations.index'));
|
|
||||||
} else {
|
} else {
|
||||||
$actions[] = Action::make('operate_hub_back_to_operations')
|
$actions[] = Action::make('operate_hub_back_to_operations')
|
||||||
->label('Back to Operations')
|
->label('Back to Operations')
|
||||||
@ -75,26 +88,26 @@ protected function getHeaderActions(): array
|
|||||||
->url(fn (): string => route('admin.operations.index'));
|
->url(fn (): string => route('admin.operations.index'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($activeTenant instanceof Tenant) {
|
||||||
|
$actions[] = Action::make('operate_hub_show_all_operations')
|
||||||
|
->label('Show all operations')
|
||||||
|
->color('gray')
|
||||||
|
->url(fn (): string => route('admin.operations.index'));
|
||||||
|
}
|
||||||
|
|
||||||
$actions[] = Action::make('refresh')
|
$actions[] = Action::make('refresh')
|
||||||
->label('Refresh')
|
->label('Refresh')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->url(fn (): string => isset($this->run)
|
->url(fn (): string => isset($this->run)
|
||||||
? route('admin.operations.view', ['run' => (int) $this->run->getKey()])
|
? OperationRunLinks::tenantlessView($this->run, $navigationContext)
|
||||||
: route('admin.operations.index'));
|
: route('admin.operations.index'));
|
||||||
|
|
||||||
if (! isset($this->run)) {
|
if (! isset($this->run)) {
|
||||||
return $actions;
|
return $actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = auth()->user();
|
$related = $this->relatedLinks();
|
||||||
$tenant = $this->run->tenant;
|
|
||||||
|
|
||||||
if ($tenant instanceof Tenant && (! $user instanceof User || ! app(CapabilityResolver::class)->isMember($user, $tenant))) {
|
|
||||||
$tenant = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$related = OperationRunLinks::related($this->run, $tenant);
|
|
||||||
|
|
||||||
$relatedActions = [];
|
$relatedActions = [];
|
||||||
|
|
||||||
@ -128,6 +141,7 @@ public function mount(OperationRun $run): void
|
|||||||
$this->authorize('view', $run);
|
$this->authorize('view', $run);
|
||||||
|
|
||||||
$this->run = $run->loadMissing(['workspace', 'tenant', 'user']);
|
$this->run = $run->loadMissing(['workspace', 'tenant', 'user']);
|
||||||
|
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function infolist(Schema $schema): Schema
|
public function infolist(Schema $schema): Schema
|
||||||
@ -147,6 +161,136 @@ public function redactionIntegrityNote(): ?string
|
|||||||
return isset($this->run) ? RedactionIntegrity::noteForRun($this->run) : null;
|
return isset($this->run) ? RedactionIntegrity::noteForRun($this->run) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{tone: string, title: string, body: string}|null
|
||||||
|
*/
|
||||||
|
public function blockedExecutionBanner(): ?array
|
||||||
|
{
|
||||||
|
if (! isset($this->run) || (string) $this->run->outcome !== 'blocked') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$operatorExplanation = $this->governanceOperatorExplanation();
|
||||||
|
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'run_detail');
|
||||||
|
$lines = $operatorExplanation instanceof OperatorExplanationPattern
|
||||||
|
? array_values(array_filter([
|
||||||
|
$operatorExplanation->headline,
|
||||||
|
$operatorExplanation->dominantCauseExplanation,
|
||||||
|
]))
|
||||||
|
: ($reasonEnvelope?->toBodyLines(false) ?? [
|
||||||
|
$this->surfaceFailureDetail() ?? 'The queued run was refused before side effects could begin.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'tone' => 'amber',
|
||||||
|
'title' => 'Blocked by prerequisite',
|
||||||
|
'body' => implode(' ', array_values(array_unique($lines))),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{tone: string, title: string, body: string}|null
|
||||||
|
*/
|
||||||
|
public function lifecycleBanner(): ?array
|
||||||
|
{
|
||||||
|
if (! isset($this->run)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attention = $this->lifecycleAttentionSummary();
|
||||||
|
|
||||||
|
if ($attention === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$detail = $this->surfaceFailureDetail() ?? 'Lifecycle truth needs operator review.';
|
||||||
|
|
||||||
|
return match ($this->run->freshnessState()->value) {
|
||||||
|
'likely_stale' => [
|
||||||
|
'tone' => 'amber',
|
||||||
|
'title' => 'Likely stale run',
|
||||||
|
'body' => $detail,
|
||||||
|
],
|
||||||
|
'reconciled_failed' => [
|
||||||
|
'tone' => 'rose',
|
||||||
|
'title' => 'Automatically reconciled',
|
||||||
|
'body' => $detail,
|
||||||
|
],
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{tone: string, title: string, body: string}|null
|
||||||
|
*/
|
||||||
|
public function canonicalContextBanner(): ?array
|
||||||
|
{
|
||||||
|
if (! isset($this->run)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||||
|
$runTenant = $this->run->tenant;
|
||||||
|
|
||||||
|
if (! $runTenant instanceof Tenant) {
|
||||||
|
return [
|
||||||
|
'tone' => 'slate',
|
||||||
|
'title' => 'Workspace-level run',
|
||||||
|
'body' => $activeTenant instanceof Tenant
|
||||||
|
? 'This canonical workspace view is not tied to the current tenant context ('.$activeTenant->name.').'
|
||||||
|
: 'This canonical workspace view is not tied to any tenant.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$messages = ['Run tenant: '.$runTenant->name.'.'];
|
||||||
|
$tone = 'sky';
|
||||||
|
$title = null;
|
||||||
|
|
||||||
|
if ($activeTenant instanceof Tenant && ! $activeTenant->is($runTenant)) {
|
||||||
|
$title = 'Current tenant context differs from this run';
|
||||||
|
array_unshift($messages, 'Current tenant context: '.$activeTenant->name.'.');
|
||||||
|
$messages[] = 'This canonical workspace view remains valid without switching tenant context.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$referencedTenant = ReferencedTenantLifecyclePresentation::forOperationRun($runTenant);
|
||||||
|
|
||||||
|
if ($selectorAvailabilityMessage = $referencedTenant->selectorAvailabilityMessage()) {
|
||||||
|
$title ??= 'Run tenant is not available in the current tenant selector';
|
||||||
|
$tone = 'amber';
|
||||||
|
$messages[] = $selectorAvailabilityMessage;
|
||||||
|
|
||||||
|
if ($referencedTenant->contextNote !== null) {
|
||||||
|
$messages[] = $referencedTenant->contextNote;
|
||||||
|
}
|
||||||
|
} elseif (! $activeTenant instanceof Tenant) {
|
||||||
|
$title ??= 'Canonical workspace view';
|
||||||
|
$messages[] = 'No tenant context is currently selected.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($title === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'tone' => $tone,
|
||||||
|
'title' => $title,
|
||||||
|
'body' => implode(' ', $messages),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pollInterval(): ?string
|
||||||
|
{
|
||||||
|
if (! isset($this->run)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filled($this->mountedActions ?? null)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return RunDetailPolling::interval($this->run);
|
||||||
|
}
|
||||||
|
|
||||||
public function content(Schema $schema): Schema
|
public function content(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema->schema([
|
return $schema->schema([
|
||||||
@ -228,6 +372,17 @@ private function resumeCaptureAction(): Action
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function navigationContext(): ?CanonicalNavigationContext
|
||||||
|
{
|
||||||
|
if (! is_array($this->navigationContextPayload)) {
|
||||||
|
return CanonicalNavigationContext::fromRequest(request());
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = request()->duplicate(query: ['nav' => $this->navigationContextPayload]);
|
||||||
|
|
||||||
|
return CanonicalNavigationContext::fromRequest($request);
|
||||||
|
}
|
||||||
|
|
||||||
private function canResumeCapture(): bool
|
private function canResumeCapture(): bool
|
||||||
{
|
{
|
||||||
if (! isset($this->run)) {
|
if (! isset($this->run)) {
|
||||||
@ -269,4 +424,77 @@ private function canResumeCapture(): bool
|
|||||||
return $resolver->isMember($user, $workspace)
|
return $resolver->isMember($user, $workspace)
|
||||||
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function relatedLinksTenant(): ?Tenant
|
||||||
|
{
|
||||||
|
if (! isset($this->run)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
$tenant = $this->run->tenant;
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! app(CapabilityResolver::class)->isMember($user, $tenant)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(TenantOperabilityService::class)->outcomeFor(
|
||||||
|
tenant: $tenant,
|
||||||
|
question: TenantOperabilityQuestion::SelectorEligibility,
|
||||||
|
actor: $user,
|
||||||
|
workspaceId: (int) ($this->run->workspace_id ?? 0),
|
||||||
|
lane: TenantInteractionLane::StandardActiveOperating,
|
||||||
|
)->allowed ? $tenant : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function governanceOperatorExplanation(): ?OperatorExplanationPattern
|
||||||
|
{
|
||||||
|
if (! isset($this->run) || ! $this->run->supportsOperatorExplanation()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return OperationUxPresenter::governanceOperatorExplanation($this->run);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function relatedLinks(bool $fresh = false): array
|
||||||
|
{
|
||||||
|
if (! isset($this->run)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(RelatedNavigationResolver::class);
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? $resolver->operationLinksFresh($this->run, $this->relatedLinksTenant())
|
||||||
|
: $resolver->operationLinks($this->run, $this->relatedLinksTenant());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function lifecycleAttentionSummary(bool $fresh = false): ?string
|
||||||
|
{
|
||||||
|
if (! isset($this->run)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? OperationUxPresenter::lifecycleAttentionSummaryFresh($this->run)
|
||||||
|
: OperationUxPresenter::lifecycleAttentionSummary($this->run);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function surfaceFailureDetail(bool $fresh = false): ?string
|
||||||
|
{
|
||||||
|
if (! isset($this->run)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? OperationUxPresenter::surfaceFailureDetailFresh($this->run)
|
||||||
|
: OperationUxPresenter::surfaceFailureDetail($this->run);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
338
app/Filament/Pages/Reviews/ReviewRegister.php
Normal file
338
app/Filament/Pages/Reviews/ReviewRegister.php
Normal file
@ -0,0 +1,338 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\Reviews;
|
||||||
|
|
||||||
|
use App\Filament\Resources\TenantReviewResource;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantReview;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\TenantReviews\TenantReviewRegisterService;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||||
|
use App\Support\Filament\FilterPresets;
|
||||||
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
|
use App\Support\TenantReviewCompletenessState;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class ReviewRegister extends Page implements HasTable
|
||||||
|
{
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-magnifying-glass';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Reporting';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Reviews';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Review Register';
|
||||||
|
|
||||||
|
protected static ?string $slug = 'reviews';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.reviews.review-register';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<int, Tenant>|null
|
||||||
|
*/
|
||||||
|
private ?array $authorizedTenants = null;
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions provide a single Clear filters action for the canonical review register.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The review register does not expose bulk actions in the first slice.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state keeps exactly one Clear filters CTA.')
|
||||||
|
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation returns to the tenant-scoped review detail rather than opening an inline canonical detail panel.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->authorizePageAccess();
|
||||||
|
|
||||||
|
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||||
|
$this->getTableFiltersSessionKey(),
|
||||||
|
['status', 'published_state', 'completeness_state'],
|
||||||
|
request(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->applyRequestedTenantPrefilter();
|
||||||
|
$this->mountInteractsWithTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Action::make('clear_filters')
|
||||||
|
->label('Clear filters')
|
||||||
|
->icon('heroicon-o-x-mark')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (): bool => $this->hasActiveFilters())
|
||||||
|
->action(function (): void {
|
||||||
|
$this->resetTable();
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->query(fn (): Builder => $this->registerQuery())
|
||||||
|
->defaultSort('generated_at', 'desc')
|
||||||
|
->paginated(TablePaginationProfiles::customPage())
|
||||||
|
->persistFiltersInSession()
|
||||||
|
->persistSearchInSession()
|
||||||
|
->persistSortInSession()
|
||||||
|
->recordUrl(fn (TenantReview $record): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $record], $record->tenant, 'tenant'))
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('tenant.name')->label('Tenant')->searchable(),
|
||||||
|
TextColumn::make('status')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
|
||||||
|
TextColumn::make('artifact_truth')
|
||||||
|
->label('Artifact truth')
|
||||||
|
->badge()
|
||||||
|
->getStateUsing(fn (TenantReview $record): string => $this->reviewTruth($record)->primaryLabel)
|
||||||
|
->color(fn (TenantReview $record): string => $this->reviewTruth($record)->primaryBadgeSpec()->color)
|
||||||
|
->icon(fn (TenantReview $record): ?string => $this->reviewTruth($record)->primaryBadgeSpec()->icon)
|
||||||
|
->iconColor(fn (TenantReview $record): ?string => $this->reviewTruth($record)->primaryBadgeSpec()->iconColor)
|
||||||
|
->description(fn (TenantReview $record): ?string => $this->reviewTruth($record)->operatorExplanation?->headline ?? $this->reviewTruth($record)->primaryExplanation)
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('completeness_state')
|
||||||
|
->label('Completeness')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
||||||
|
TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
|
||||||
|
TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
||||||
|
TextColumn::make('publication_truth')
|
||||||
|
->label('Publication')
|
||||||
|
->badge()
|
||||||
|
->getStateUsing(fn (TenantReview $record): string => BadgeCatalog::spec(
|
||||||
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
|
$this->reviewTruth($record)->publicationReadiness ?? 'internal_only',
|
||||||
|
)->label)
|
||||||
|
->color(fn (TenantReview $record): string => BadgeCatalog::spec(
|
||||||
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
|
$this->reviewTruth($record)->publicationReadiness ?? 'internal_only',
|
||||||
|
)->color)
|
||||||
|
->icon(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
||||||
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
|
$this->reviewTruth($record)->publicationReadiness ?? 'internal_only',
|
||||||
|
)->icon)
|
||||||
|
->iconColor(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
||||||
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
|
$this->reviewTruth($record)->publicationReadiness ?? 'internal_only',
|
||||||
|
)->iconColor),
|
||||||
|
TextColumn::make('artifact_next_step')
|
||||||
|
->label('Next step')
|
||||||
|
->getStateUsing(fn (TenantReview $record): string => $this->reviewTruth($record)->operatorExplanation?->nextActionText ?? $this->reviewTruth($record)->nextStepText())
|
||||||
|
->wrap(),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
SelectFilter::make('tenant_id')
|
||||||
|
->label('Tenant')
|
||||||
|
->options(fn (): array => $this->tenantFilterOptions())
|
||||||
|
->default(fn (): ?string => $this->defaultTenantFilter())
|
||||||
|
->searchable(),
|
||||||
|
SelectFilter::make('status')
|
||||||
|
->options([
|
||||||
|
'draft' => 'Draft',
|
||||||
|
'ready' => 'Ready',
|
||||||
|
'published' => 'Published',
|
||||||
|
'archived' => 'Archived',
|
||||||
|
'superseded' => 'Superseded',
|
||||||
|
'failed' => 'Failed',
|
||||||
|
]),
|
||||||
|
SelectFilter::make('completeness_state')
|
||||||
|
->label('Completeness')
|
||||||
|
->options(BadgeCatalog::options(BadgeDomain::TenantReviewCompleteness, TenantReviewCompletenessState::values())),
|
||||||
|
SelectFilter::make('published_state')
|
||||||
|
->label('Published state')
|
||||||
|
->options([
|
||||||
|
'published' => 'Published',
|
||||||
|
'unpublished' => 'Not published',
|
||||||
|
])
|
||||||
|
->query(function (Builder $query, array $data): Builder {
|
||||||
|
return match ($data['value'] ?? null) {
|
||||||
|
'published' => $query->whereNotNull('published_at'),
|
||||||
|
'unpublished' => $query->whereNull('published_at'),
|
||||||
|
default => $query,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Action::make('view_review')
|
||||||
|
->label('View review')
|
||||||
|
->url(fn (TenantReview $record): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $record], $record->tenant, 'tenant')),
|
||||||
|
Action::make('export_executive_pack')
|
||||||
|
->label('Export executive pack')
|
||||||
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
|
->visible(fn (TenantReview $record): bool => auth()->user() instanceof User
|
||||||
|
&& auth()->user()->can(Capabilities::TENANT_REVIEW_MANAGE, $record->tenant)
|
||||||
|
&& in_array($record->status, ['ready', 'published'], true))
|
||||||
|
->action(fn (TenantReview $record): mixed => TenantReviewResource::executeExport($record)),
|
||||||
|
])
|
||||||
|
->bulkActions([])
|
||||||
|
->emptyStateHeading('No review records match this view')
|
||||||
|
->emptyStateDescription('Clear the current filters to return to the full review register for your entitled tenants.')
|
||||||
|
->emptyStateActions([
|
||||||
|
Action::make('clear_filters_empty')
|
||||||
|
->label('Clear filters')
|
||||||
|
->icon('heroicon-o-x-mark')
|
||||||
|
->color('gray')
|
||||||
|
->action(fn (): mixed => $this->resetTable()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, Tenant>
|
||||||
|
*/
|
||||||
|
public function authorizedTenants(): array
|
||||||
|
{
|
||||||
|
if ($this->authorizedTenants !== null) {
|
||||||
|
return $this->authorizedTenants;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
$workspace = $this->workspace();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||||
|
return $this->authorizedTenants = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->authorizedTenants = app(TenantReviewRegisterService::class)->authorizedTenants($user, $workspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizePageAccess(): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
$workspace = $this->workspace();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
throw new NotFoundHttpException;
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = app(TenantReviewRegisterService::class);
|
||||||
|
|
||||||
|
if (! $service->canAccessWorkspace($user, $workspace)) {
|
||||||
|
throw new NotFoundHttpException;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->authorizedTenants() === []) {
|
||||||
|
throw new NotFoundHttpException;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function registerQuery(): Builder
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
$workspace = $this->workspace();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||||
|
return TenantReview::query()->whereRaw('1 = 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(TenantReviewRegisterService::class)->query($user, $workspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function tenantFilterOptions(): array
|
||||||
|
{
|
||||||
|
return collect($this->authorizedTenants())
|
||||||
|
->mapWithKeys(static fn (Tenant $tenant): array => [
|
||||||
|
(string) $tenant->getKey() => $tenant->name,
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function defaultTenantFilter(): ?string
|
||||||
|
{
|
||||||
|
$tenantId = app(WorkspaceContext::class)->lastTenantId(request());
|
||||||
|
|
||||||
|
return is_int($tenantId) && array_key_exists($tenantId, $this->authorizedTenants())
|
||||||
|
? (string) $tenantId
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyRequestedTenantPrefilter(): void
|
||||||
|
{
|
||||||
|
$requestedTenant = request()->query('tenant');
|
||||||
|
|
||||||
|
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->authorizedTenants() as $tenant) {
|
||||||
|
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
||||||
|
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasActiveFilters(): bool
|
||||||
|
{
|
||||||
|
$filters = array_filter((array) $this->tableFilters);
|
||||||
|
|
||||||
|
return $filters !== [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function workspace(): ?Workspace
|
||||||
|
{
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
return is_numeric($workspaceId)
|
||||||
|
? Workspace::query()->whereKey((int) $workspaceId)->first()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reviewTruth(TenantReview $record, bool $fresh = false): ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? $presenter->forTenantReviewFresh($record)
|
||||||
|
: $presenter->forTenantReview($record);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Pages;
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Models\TenantMembership;
|
use App\Models\TenantMembership;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Auth\TenantDiagnosticsService;
|
use App\Services\Auth\TenantDiagnosticsService;
|
||||||
@ -17,6 +17,8 @@
|
|||||||
|
|
||||||
class TenantDiagnostics extends Page
|
class TenantDiagnostics extends Page
|
||||||
{
|
{
|
||||||
|
use ResolvesPanelTenantContext;
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
protected static ?string $slug = 'diagnostics';
|
protected static ?string $slug = 'diagnostics';
|
||||||
@ -29,7 +31,7 @@ class TenantDiagnostics extends Page
|
|||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
|
||||||
$tenantId = (int) $tenant->getKey();
|
$tenantId = (int) $tenant->getKey();
|
||||||
|
|
||||||
$this->missingOwner = ! TenantMembership::query()
|
$this->missingOwner = ! TenantMembership::query()
|
||||||
@ -80,7 +82,7 @@ protected function getHeaderActions(): array
|
|||||||
|
|
||||||
public function bootstrapOwner(): void
|
public function bootstrapOwner(): void
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User) {
|
||||||
@ -94,7 +96,7 @@ public function bootstrapOwner(): void
|
|||||||
|
|
||||||
public function mergeDuplicateMemberships(): void
|
public function mergeDuplicateMemberships(): void
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User) {
|
||||||
|
|||||||
@ -12,6 +12,8 @@
|
|||||||
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
|
use Livewire\Attributes\Locked;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
class TenantRequiredPermissions extends Page
|
class TenantRequiredPermissions extends Page
|
||||||
{
|
{
|
||||||
@ -41,7 +43,8 @@ class TenantRequiredPermissions extends Page
|
|||||||
*/
|
*/
|
||||||
public array $viewModel = [];
|
public array $viewModel = [];
|
||||||
|
|
||||||
public ?Tenant $scopedTenant = null;
|
#[Locked]
|
||||||
|
public ?int $scopedTenantId = null;
|
||||||
|
|
||||||
public static function canAccess(): bool
|
public static function canAccess(): bool
|
||||||
{
|
{
|
||||||
@ -50,7 +53,7 @@ public static function canAccess(): bool
|
|||||||
|
|
||||||
public function currentTenant(): ?Tenant
|
public function currentTenant(): ?Tenant
|
||||||
{
|
{
|
||||||
return $this->scopedTenant;
|
return $this->trustedScopedTenant();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
@ -61,7 +64,9 @@ public function mount(): void
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->scopedTenant = $tenant;
|
$this->scopedTenantId = (int) $tenant->getKey();
|
||||||
|
$this->heading = $tenant->getFilamentName();
|
||||||
|
$this->subheading = 'Required permissions';
|
||||||
|
|
||||||
$queryFeatures = request()->query('features', $this->features);
|
$queryFeatures = request()->query('features', $this->features);
|
||||||
|
|
||||||
@ -141,7 +146,7 @@ public function resetFilters(): void
|
|||||||
|
|
||||||
private function refreshViewModel(): void
|
private function refreshViewModel(): void
|
||||||
{
|
{
|
||||||
$tenant = $this->scopedTenant;
|
$tenant = $this->trustedScopedTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
$this->viewModel = [];
|
$this->viewModel = [];
|
||||||
@ -170,7 +175,7 @@ private function refreshViewModel(): void
|
|||||||
|
|
||||||
public function reRunVerificationUrl(): string
|
public function reRunVerificationUrl(): string
|
||||||
{
|
{
|
||||||
$tenant = $this->scopedTenant;
|
$tenant = $this->trustedScopedTenant();
|
||||||
|
|
||||||
if ($tenant instanceof Tenant) {
|
if ($tenant instanceof Tenant) {
|
||||||
return TenantResource::getUrl('view', ['record' => $tenant]);
|
return TenantResource::getUrl('view', ['record' => $tenant]);
|
||||||
@ -181,7 +186,7 @@ public function reRunVerificationUrl(): string
|
|||||||
|
|
||||||
public function manageProviderConnectionUrl(): ?string
|
public function manageProviderConnectionUrl(): ?string
|
||||||
{
|
{
|
||||||
$tenant = $this->scopedTenant;
|
$tenant = $this->trustedScopedTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return null;
|
return null;
|
||||||
@ -232,4 +237,47 @@ private static function hasScopedTenantAccess(?Tenant $tenant): bool
|
|||||||
|
|
||||||
return $user->canAccessTenant($tenant);
|
return $user->canAccessTenant($tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function trustedScopedTenant(): ?Tenant
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceContext = app(WorkspaceContext::class);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$workspace = $workspaceContext->currentWorkspaceForMemberOrFail($user, request());
|
||||||
|
} catch (NotFoundHttpException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$routeTenant = static::resolveScopedTenant();
|
||||||
|
|
||||||
|
if ($routeTenant instanceof Tenant) {
|
||||||
|
try {
|
||||||
|
return $workspaceContext->ensureTenantAccessibleInCurrentWorkspace($routeTenant, $user, request());
|
||||||
|
} catch (NotFoundHttpException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->scopedTenantId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::query()->withTrashed()->whereKey($this->scopedTenantId)->first();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $workspaceContext->ensureTenantAccessibleInCurrentWorkspace($tenant, $user, request());
|
||||||
|
} catch (NotFoundHttpException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
89
app/Filament/Pages/WorkspaceOverview.php
Normal file
89
app/Filament/Pages/WorkspaceOverview.php
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use App\Support\Workspaces\WorkspaceOverviewBuilder;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Navigation\NavigationItem;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class WorkspaceOverview extends Page
|
||||||
|
{
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static ?string $title = 'Overview';
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-home';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = null;
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.workspace-overview';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, mixed>
|
||||||
|
*/
|
||||||
|
public array $overview = [];
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||||
|
->exempt(ActionSurfaceSlot::ListHeader, 'Workspace overview is a singleton landing page with no page-header actions.')
|
||||||
|
->exempt(ActionSurfaceSlot::InspectAffordance, 'Workspace overview is already the canonical landing surface for the active workspace.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Workspace overview does not render record rows with secondary actions.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Workspace overview does not expose bulk actions.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListEmptyState, 'Workspace overview redirects or renders overview content instead of a list-style empty state.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(WorkspaceOverviewBuilder $builder): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
if (! is_int($workspaceId)) {
|
||||||
|
$this->redirect('/admin/choose-workspace');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var WorkspaceCapabilityResolver $resolver */
|
||||||
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $workspace)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->overview = $builder->build($workspace, $user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function navigationItem(): NavigationItem
|
||||||
|
{
|
||||||
|
return NavigationItem::make('Overview')
|
||||||
|
->url(fn (): string => route('admin.home'))
|
||||||
|
->icon('heroicon-o-home')
|
||||||
|
->sort(-100)
|
||||||
|
->isActiveWhen(fn (): bool => request()->routeIs('admin.home'));
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -5,10 +5,14 @@
|
|||||||
namespace App\Filament\Pages\Workspaces;
|
namespace App\Filament\Pages\Workspaces;
|
||||||
|
|
||||||
use App\Filament\Pages\ChooseTenant;
|
use App\Filament\Pages\ChooseTenant;
|
||||||
use App\Filament\Pages\TenantDashboard;
|
use App\Filament\Resources\TenantResource;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Tenants\TenantOperabilityService;
|
||||||
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
@ -54,11 +58,25 @@ public function getTenants(): Collection
|
|||||||
return Tenant::query()->whereRaw('1 = 0')->get();
|
return Tenant::query()->whereRaw('1 = 0')->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
return $user->tenants()
|
$tenantIds = $user->tenantMemberships()
|
||||||
|
->pluck('tenant_id');
|
||||||
|
|
||||||
|
return Tenant::query()
|
||||||
|
->withTrashed()
|
||||||
|
->whereIn('id', $tenantIds)
|
||||||
->where('workspace_id', $this->workspace->getKey())
|
->where('workspace_id', $this->workspace->getKey())
|
||||||
->where('status', 'active')
|
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get();
|
->get()
|
||||||
|
->filter(function (Tenant $tenant) use ($user): bool {
|
||||||
|
return app(TenantOperabilityService::class)->outcomeFor(
|
||||||
|
tenant: $tenant,
|
||||||
|
question: TenantOperabilityQuestion::AdministrativeDiscoverability,
|
||||||
|
actor: $user,
|
||||||
|
workspaceId: app(WorkspaceContext::class)->currentWorkspaceId(request()),
|
||||||
|
lane: TenantInteractionLane::AdministrativeManagement,
|
||||||
|
)->allowed;
|
||||||
|
})
|
||||||
|
->values();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function goToChooseTenant(): void
|
public function goToChooseTenant(): void
|
||||||
@ -75,7 +93,7 @@ public function openTenant(int $tenantId): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
$tenant = Tenant::query()
|
$tenant = Tenant::query()
|
||||||
->where('status', 'active')
|
->withTrashed()
|
||||||
->where('workspace_id', $this->workspace->getKey())
|
->where('workspace_id', $this->workspace->getKey())
|
||||||
->whereKey($tenantId)
|
->whereKey($tenantId)
|
||||||
->first();
|
->first();
|
||||||
@ -88,6 +106,6 @@ public function openTenant(int $tenantId): void
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
$this->redirect(TenantResource::getUrl('view', ['record' => $tenant]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,9 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
|
use App\Support\Filament\FilterPresets;
|
||||||
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
@ -111,6 +114,7 @@ public static function getEloquentQuery(): Builder
|
|||||||
{
|
{
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
return parent::getEloquentQuery()
|
||||||
->with(['tenant', 'rule', 'destination'])
|
->with(['tenant', 'rule', 'destination'])
|
||||||
@ -134,8 +138,8 @@ public static function getEloquentQuery(): Builder
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
->when(
|
->when(
|
||||||
Filament::getTenant() instanceof Tenant,
|
$activeTenant instanceof Tenant,
|
||||||
fn (Builder $query): Builder => $query->where('tenant_id', (int) Filament::getTenant()->getKey()),
|
fn (Builder $query): Builder => $query->where('tenant_id', (int) $activeTenant->getKey()),
|
||||||
)
|
)
|
||||||
->latest('id');
|
->latest('id');
|
||||||
}
|
}
|
||||||
@ -213,13 +217,18 @@ public static function table(Table $table): Table
|
|||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
->defaultSort('id', 'desc')
|
->defaultSort('id', 'desc')
|
||||||
|
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
|
||||||
|
->persistFiltersInSession()
|
||||||
|
->persistSearchInSession()
|
||||||
|
->persistSortInSession()
|
||||||
->recordUrl(fn (AlertDelivery $record): ?string => static::canView($record)
|
->recordUrl(fn (AlertDelivery $record): ?string => static::canView($record)
|
||||||
? static::getUrl('view', ['record' => $record])
|
? static::getUrl('view', ['record' => $record])
|
||||||
: null)
|
: null)
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('created_at')
|
TextColumn::make('created_at')
|
||||||
->label('Created')
|
->label('Created')
|
||||||
->since(),
|
->since()
|
||||||
|
->sortable(),
|
||||||
TextColumn::make('tenant.name')
|
TextColumn::make('tenant.name')
|
||||||
->label('Tenant')
|
->label('Tenant')
|
||||||
->searchable(),
|
->searchable(),
|
||||||
@ -234,7 +243,8 @@ public static function table(Table $table): Table
|
|||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::AlertDeliveryStatus))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::AlertDeliveryStatus))
|
||||||
->color(BadgeRenderer::color(BadgeDomain::AlertDeliveryStatus))
|
->color(BadgeRenderer::color(BadgeDomain::AlertDeliveryStatus))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::AlertDeliveryStatus)),
|
->icon(BadgeRenderer::icon(BadgeDomain::AlertDeliveryStatus))
|
||||||
|
->sortable(),
|
||||||
TextColumn::make('rule.name')
|
TextColumn::make('rule.name')
|
||||||
->label('Rule')
|
->label('Rule')
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
@ -242,18 +252,51 @@ public static function table(Table $table): Table
|
|||||||
->label('Destination')
|
->label('Destination')
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
TextColumn::make('attempt_count')
|
TextColumn::make('attempt_count')
|
||||||
->label('Attempts'),
|
->label('Attempts')
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
|
SelectFilter::make('tenant_id')
|
||||||
|
->label('Tenant')
|
||||||
|
->options(function (): array {
|
||||||
|
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||||
|
|
||||||
|
if ($activeTenant instanceof Tenant) {
|
||||||
|
return [
|
||||||
|
(string) $activeTenant->getKey() => $activeTenant->getFilamentName(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
|
||||||
|
->mapWithKeys(static fn (Tenant $tenant): array => [
|
||||||
|
(string) $tenant->getKey() => $tenant->getFilamentName(),
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
})
|
||||||
|
->default(function (): ?string {
|
||||||
|
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||||
|
|
||||||
|
if (! $activeTenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
if ($workspaceId === null || (int) $activeTenant->workspace_id !== (int) $workspaceId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $activeTenant->getKey();
|
||||||
|
})
|
||||||
|
->searchable(),
|
||||||
SelectFilter::make('status')
|
SelectFilter::make('status')
|
||||||
->options([
|
->options(FilterOptionCatalog::alertDeliveryStatuses()),
|
||||||
AlertDelivery::STATUS_QUEUED => 'Queued',
|
|
||||||
AlertDelivery::STATUS_DEFERRED => 'Deferred',
|
|
||||||
AlertDelivery::STATUS_SENT => 'Sent',
|
|
||||||
AlertDelivery::STATUS_FAILED => 'Failed',
|
|
||||||
AlertDelivery::STATUS_SUPPRESSED => 'Suppressed',
|
|
||||||
AlertDelivery::STATUS_CANCELED => 'Canceled',
|
|
||||||
]),
|
|
||||||
SelectFilter::make('event_type')
|
SelectFilter::make('event_type')
|
||||||
->label('Event type')
|
->label('Event type')
|
||||||
->options(function (): array {
|
->options(function (): array {
|
||||||
@ -277,6 +320,7 @@ public static function table(Table $table): Table
|
|||||||
->pluck('name', 'id')
|
->pluck('name', 'id')
|
||||||
->all();
|
->all();
|
||||||
}),
|
}),
|
||||||
|
FilterPresets::dateRange('created_at', 'Created', 'created_at'),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
ViewAction::make()->label('View'),
|
ViewAction::make()->label('View'),
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
namespace App\Filament\Resources\AlertDeliveryResource\Pages;
|
namespace App\Filament\Resources\AlertDeliveryResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\AlertDeliveryResource;
|
use App\Filament\Resources\AlertDeliveryResource;
|
||||||
|
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
@ -12,6 +13,13 @@ class ListAlertDeliveries extends ListRecords
|
|||||||
{
|
{
|
||||||
protected static string $resource = AlertDeliveryResource::class;
|
protected static string $resource = AlertDeliveryResource::class;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
app(CanonicalAdminTenantFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request());
|
||||||
|
|
||||||
|
parent::mount();
|
||||||
|
}
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return app(OperateHubShell::class)->headerActions(
|
return app(OperateHubShell::class)->headerActions(
|
||||||
|
|||||||
@ -171,12 +171,14 @@ public static function table(Table $table): Table
|
|||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
->defaultSort('name')
|
->defaultSort('name')
|
||||||
|
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
|
||||||
->recordUrl(fn (AlertDestination $record): ?string => static::canEdit($record)
|
->recordUrl(fn (AlertDestination $record): ?string => static::canEdit($record)
|
||||||
? static::getUrl('edit', ['record' => $record])
|
? static::getUrl('edit', ['record' => $record])
|
||||||
: static::getUrl('view', ['record' => $record]))
|
: static::getUrl('view', ['record' => $record]))
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('name')
|
TextColumn::make('name')
|
||||||
->searchable(),
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
TextColumn::make('type')
|
TextColumn::make('type')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(fn (?string $state): string => self::typeLabel((string) $state)),
|
->formatStateUsing(fn (?string $state): string => self::typeLabel((string) $state)),
|
||||||
@ -258,7 +260,10 @@ public static function table(Table $table): Table
|
|||||||
\Filament\Actions\CreateAction::make()
|
\Filament\Actions\CreateAction::make()
|
||||||
->label('Create target')
|
->label('Create target')
|
||||||
->disabled(fn (): bool => ! static::canCreate()),
|
->disabled(fn (): bool => ! static::canCreate()),
|
||||||
]);
|
])
|
||||||
|
->emptyStateHeading('No alert destinations')
|
||||||
|
->emptyStateDescription('Create a destination so alert rules have somewhere to deliver notifications.')
|
||||||
|
->emptyStateIcon('heroicon-o-paper-airplane');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
|
|||||||
@ -222,12 +222,14 @@ public static function table(Table $table): Table
|
|||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
->defaultSort('name')
|
->defaultSort('name')
|
||||||
|
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
|
||||||
->recordUrl(fn (AlertRule $record): ?string => static::canEdit($record)
|
->recordUrl(fn (AlertRule $record): ?string => static::canEdit($record)
|
||||||
? static::getUrl('edit', ['record' => $record])
|
? static::getUrl('edit', ['record' => $record])
|
||||||
: null)
|
: null)
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('name')
|
TextColumn::make('name')
|
||||||
->searchable(),
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
TextColumn::make('event_type')
|
TextColumn::make('event_type')
|
||||||
->label('Event')
|
->label('Event')
|
||||||
->badge()
|
->badge()
|
||||||
@ -311,7 +313,10 @@ public static function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
BulkActionGroup::make([])->label('More'),
|
BulkActionGroup::make([])->label('More'),
|
||||||
]);
|
])
|
||||||
|
->emptyStateHeading('No alert rules')
|
||||||
|
->emptyStateDescription('Create a rule to route notifications when monitored events fire.')
|
||||||
|
->emptyStateIcon('heroicon-o-bell');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Exceptions\InvalidPolicyTypeException;
|
use App\Exceptions\InvalidPolicyTypeException;
|
||||||
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||||
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Resources\BackupScheduleResource\Pages;
|
use App\Filament\Resources\BackupScheduleResource\Pages;
|
||||||
use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleOperationRunsRelationManager;
|
use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleOperationRunsRelationManager;
|
||||||
use App\Jobs\RunBackupScheduleJob;
|
use App\Jobs\RunBackupScheduleJob;
|
||||||
@ -20,6 +22,7 @@
|
|||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Badges\TagBadgeDomain;
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
use App\Support\Badges\TagBadgeRenderer;
|
use App\Support\Badges\TagBadgeRenderer;
|
||||||
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
@ -37,6 +40,7 @@
|
|||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
use Filament\Actions\CreateAction;
|
use Filament\Actions\CreateAction;
|
||||||
use Filament\Actions\EditAction;
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Filament\Forms\Components\CheckboxList;
|
use Filament\Forms\Components\CheckboxList;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
@ -61,6 +65,9 @@
|
|||||||
|
|
||||||
class BackupScheduleResource extends Resource
|
class BackupScheduleResource extends Resource
|
||||||
{
|
{
|
||||||
|
use InteractsWithTenantOwnedRecords;
|
||||||
|
use ResolvesPanelTenantContext;
|
||||||
|
|
||||||
protected static ?string $model = BackupSchedule::class;
|
protected static ?string $model = BackupSchedule::class;
|
||||||
|
|
||||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||||
@ -69,9 +76,18 @@ class BackupScheduleResource extends Resource
|
|||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
||||||
|
|
||||||
|
public static function shouldRegisterNavigation(): bool
|
||||||
|
{
|
||||||
|
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::shouldRegisterNavigation();
|
||||||
|
}
|
||||||
|
|
||||||
public static function canViewAny(): bool
|
public static function canViewAny(): bool
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -87,7 +103,7 @@ public static function canViewAny(): bool
|
|||||||
|
|
||||||
public static function canView(Model $record): bool
|
public static function canView(Model $record): bool
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -111,7 +127,7 @@ public static function canView(Model $record): bool
|
|||||||
|
|
||||||
public static function canCreate(): bool
|
public static function canCreate(): bool
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -127,7 +143,7 @@ public static function canCreate(): bool
|
|||||||
|
|
||||||
public static function canEdit(Model $record): bool
|
public static function canEdit(Model $record): bool
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -143,7 +159,7 @@ public static function canEdit(Model $record): bool
|
|||||||
|
|
||||||
public static function canDelete(Model $record): bool
|
public static function canDelete(Model $record): bool
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -159,7 +175,7 @@ public static function canDelete(Model $record): bool
|
|||||||
|
|
||||||
public static function canDeleteAny(): bool
|
public static function canDeleteAny(): bool
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -268,6 +284,10 @@ public static function table(Table $table): Table
|
|||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
->defaultSort('next_run_at', 'asc')
|
->defaultSort('next_run_at', 'asc')
|
||||||
|
->paginated(TablePaginationProfiles::resource())
|
||||||
|
->persistFiltersInSession()
|
||||||
|
->persistSearchInSession()
|
||||||
|
->persistSortInSession()
|
||||||
->recordUrl(fn (BackupSchedule $record): ?string => static::canEdit($record)
|
->recordUrl(fn (BackupSchedule $record): ?string => static::canEdit($record)
|
||||||
? static::getUrl('edit', ['record' => $record])
|
? static::getUrl('edit', ['record' => $record])
|
||||||
: null)
|
: null)
|
||||||
@ -283,6 +303,7 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
TextColumn::make('name')
|
TextColumn::make('name')
|
||||||
->searchable()
|
->searchable()
|
||||||
|
->sortable()
|
||||||
->label('Schedule'),
|
->label('Schedule'),
|
||||||
|
|
||||||
TextColumn::make('frequency')
|
TextColumn::make('frequency')
|
||||||
@ -296,7 +317,8 @@ public static function table(Table $table): Table
|
|||||||
->formatStateUsing(fn (?string $state): ?string => $state ? trim($state) : null),
|
->formatStateUsing(fn (?string $state): ?string => $state ? trim($state) : null),
|
||||||
|
|
||||||
TextColumn::make('timezone')
|
TextColumn::make('timezone')
|
||||||
->label('Timezone'),
|
->label('Timezone')
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
|
||||||
TextColumn::make('policy_types')
|
TextColumn::make('policy_types')
|
||||||
->label('Policy types')
|
->label('Policy types')
|
||||||
@ -305,7 +327,8 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
TextColumn::make('retention_keep_last')
|
TextColumn::make('retention_keep_last')
|
||||||
->label('Retention')
|
->label('Retention')
|
||||||
->suffix(' sets'),
|
->suffix(' sets')
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
|
||||||
TextColumn::make('last_run_status')
|
TextColumn::make('last_run_status')
|
||||||
->label('Last run status')
|
->label('Last run status')
|
||||||
@ -347,7 +370,8 @@ public static function table(Table $table): Table
|
|||||||
$spec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome);
|
$spec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome);
|
||||||
|
|
||||||
return $spec->iconColor ?? $spec->color;
|
return $spec->iconColor ?? $spec->color;
|
||||||
}),
|
})
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
|
||||||
TextColumn::make('last_run_at')
|
TextColumn::make('last_run_at')
|
||||||
->label('Last run')
|
->label('Last run')
|
||||||
@ -412,7 +436,7 @@ public static function table(Table $table): Table
|
|||||||
->color('success')
|
->color('success')
|
||||||
->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
|
->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
|
||||||
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
@ -483,7 +507,7 @@ public static function table(Table $table): Table
|
|||||||
->color('warning')
|
->color('warning')
|
||||||
->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
|
->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
|
||||||
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
@ -559,6 +583,8 @@ public static function table(Table $table): Table
|
|||||||
->color('danger')
|
->color('danger')
|
||||||
->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
|
->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
|
||||||
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
||||||
|
$record = static::resolveProtectedScheduleRecordOrFail($record);
|
||||||
|
|
||||||
Gate::authorize('delete', $record);
|
Gate::authorize('delete', $record);
|
||||||
|
|
||||||
if ($record->trashed()) {
|
if ($record->trashed()) {
|
||||||
@ -600,6 +626,8 @@ public static function table(Table $table): Table
|
|||||||
->color('success')
|
->color('success')
|
||||||
->visible(fn (BackupSchedule $record): bool => $record->trashed())
|
->visible(fn (BackupSchedule $record): bool => $record->trashed())
|
||||||
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
||||||
|
$record = static::resolveProtectedScheduleRecordOrFail($record);
|
||||||
|
|
||||||
Gate::authorize('restore', $record);
|
Gate::authorize('restore', $record);
|
||||||
|
|
||||||
if (! $record->trashed()) {
|
if (! $record->trashed()) {
|
||||||
@ -640,6 +668,8 @@ public static function table(Table $table): Table
|
|||||||
->color('danger')
|
->color('danger')
|
||||||
->visible(fn (BackupSchedule $record): bool => $record->trashed())
|
->visible(fn (BackupSchedule $record): bool => $record->trashed())
|
||||||
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
||||||
|
$record = static::resolveProtectedScheduleRecordOrFail($record);
|
||||||
|
|
||||||
Gate::authorize('forceDelete', $record);
|
Gate::authorize('forceDelete', $record);
|
||||||
|
|
||||||
if (! $record->trashed()) {
|
if (! $record->trashed()) {
|
||||||
@ -697,7 +727,7 @@ public static function table(Table $table): Table
|
|||||||
->icon('heroicon-o-play')
|
->icon('heroicon-o-play')
|
||||||
->color('success')
|
->color('success')
|
||||||
->action(function (Collection $records, HasTable $livewire): void {
|
->action(function (Collection $records, HasTable $livewire): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
@ -712,7 +742,7 @@ public static function table(Table $table): Table
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$userId = auth()->id();
|
$userId = auth()->id();
|
||||||
$user = $userId ? User::query()->find($userId) : null;
|
$user = $userId ? User::query()->find($userId) : null;
|
||||||
/** @var OperationRunService $operationRunService */
|
/** @var OperationRunService $operationRunService */
|
||||||
@ -794,7 +824,7 @@ public static function table(Table $table): Table
|
|||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('warning')
|
->color('warning')
|
||||||
->action(function (Collection $records, HasTable $livewire): void {
|
->action(function (Collection $records, HasTable $livewire): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
@ -809,7 +839,7 @@ public static function table(Table $table): Table
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$userId = auth()->id();
|
$userId = auth()->id();
|
||||||
$user = $userId ? User::query()->find($userId) : null;
|
$user = $userId ? User::query()->find($userId) : null;
|
||||||
/** @var OperationRunService $operationRunService */
|
/** @var OperationRunService $operationRunService */
|
||||||
@ -897,17 +927,32 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
$tenantId = Tenant::currentOrFail()->getKey();
|
return static::getTenantOwnedEloquentQuery()
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->orderByDesc('is_enabled')
|
->orderByDesc('is_enabled')
|
||||||
->orderBy('next_run_at');
|
->orderBy('next_run_at');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getRecordRouteBindingEloquentQuery(): Builder
|
public static function getRecordRouteBindingEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
return static::getEloquentQuery()->withTrashed();
|
return static::scopeTenantOwnedQuery(parent::getEloquentQuery()->withTrashed())
|
||||||
|
->orderByDesc('is_enabled')
|
||||||
|
->orderBy('next_run_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function resolveScopedRecordOrFail(int|string $key): Model
|
||||||
|
{
|
||||||
|
return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->withTrashed());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function resolveProtectedScheduleRecordOrFail(BackupSchedule|int|string $record): BackupSchedule
|
||||||
|
{
|
||||||
|
$resolvedRecord = static::resolveScopedRecordOrFail($record instanceof Model ? $record->getKey() : $record);
|
||||||
|
|
||||||
|
if (! $resolvedRecord instanceof BackupSchedule) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolvedRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getRelations(): array
|
public static function getRelations(): array
|
||||||
@ -1018,7 +1063,7 @@ public static function ensurePolicyTypes(array $data): array
|
|||||||
|
|
||||||
public static function assignTenant(array $data): array
|
public static function assignTenant(array $data): array
|
||||||
{
|
{
|
||||||
$data['tenant_id'] = Tenant::currentOrFail()->getKey();
|
$data['tenant_id'] = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
use App\Filament\Resources\BackupScheduleResource;
|
use App\Filament\Resources\BackupScheduleResource;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
|
||||||
|
|
||||||
class EditBackupSchedule extends EditRecord
|
class EditBackupSchedule extends EditRecord
|
||||||
{
|
{
|
||||||
@ -13,15 +12,7 @@ class EditBackupSchedule extends EditRecord
|
|||||||
|
|
||||||
protected function resolveRecord(int|string $key): Model
|
protected function resolveRecord(int|string $key): Model
|
||||||
{
|
{
|
||||||
$record = BackupScheduleResource::getEloquentQuery()
|
return BackupScheduleResource::resolveScopedRecordOrFail($key);
|
||||||
->withTrashed()
|
|
||||||
->find($key);
|
|
||||||
|
|
||||||
if ($record === null) {
|
|
||||||
throw (new ModelNotFoundException)->setModel(BackupScheduleResource::getModel(), [$key]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $record;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function mutateFormDataBeforeSave(array $data): array
|
protected function mutateFormDataBeforeSave(array $data): array
|
||||||
|
|||||||
@ -3,12 +3,38 @@
|
|||||||
namespace App\Filament\Resources\BackupScheduleResource\Pages;
|
namespace App\Filament\Resources\BackupScheduleResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\BackupScheduleResource;
|
use App\Filament\Resources\BackupScheduleResource;
|
||||||
|
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
|
|
||||||
class ListBackupSchedules extends ListRecords
|
class ListBackupSchedules extends ListRecords
|
||||||
{
|
{
|
||||||
protected static string $resource = BackupScheduleResource::class;
|
protected static string $resource = BackupScheduleResource::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $arguments
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
|
||||||
|
{
|
||||||
|
if (($context['table'] ?? false) === true && filled($context['recordKey'] ?? null) && in_array($name, ['archive', 'restore', 'forceDelete'], true)) {
|
||||||
|
try {
|
||||||
|
BackupScheduleResource::resolveScopedRecordOrFail($context['recordKey']);
|
||||||
|
} catch (ModelNotFoundException) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::mountAction($name, $arguments, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->syncCanonicalAdminTenantFilterState();
|
||||||
|
|
||||||
|
parent::mount();
|
||||||
|
}
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@ -17,8 +43,25 @@ protected function getHeaderActions(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function getTableEmptyStateActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
BackupScheduleResource::makeCreateAction(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private function tableHasRecords(): bool
|
private function tableHasRecords(): bool
|
||||||
{
|
{
|
||||||
return $this->getTableRecords()->count() > 0;
|
return $this->getTableRecords()->count() > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function syncCanonicalAdminTenantFilterState(): void
|
||||||
|
{
|
||||||
|
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||||
|
$this->getTableFiltersSessionKey(),
|
||||||
|
tenantSensitiveFilters: [],
|
||||||
|
request: request(),
|
||||||
|
tenantFilterName: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use Closure;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\RelationManagers\RelationManager;
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
@ -24,6 +25,19 @@ class BackupScheduleOperationRunsRelationManager extends RelationManager
|
|||||||
|
|
||||||
protected static ?string $title = 'Executions';
|
protected static ?string $title = 'Executions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $arguments
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
|
||||||
|
{
|
||||||
|
if (($context['table'] ?? false) === true && $name === 'view' && filled($context['recordKey'] ?? null)) {
|
||||||
|
$this->resolveOwnerScopedOperationRun($context['recordKey']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::mountAction($name, $arguments, $context);
|
||||||
|
}
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||||
@ -39,14 +53,16 @@ public function table(Table $table): Table
|
|||||||
return $table
|
return $table
|
||||||
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey()))
|
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey()))
|
||||||
->defaultSort('created_at', 'desc')
|
->defaultSort('created_at', 'desc')
|
||||||
|
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('created_at')
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
->label('Enqueued')
|
->label('Enqueued')
|
||||||
->dateTime(),
|
->dateTime()
|
||||||
|
->sortable(),
|
||||||
|
|
||||||
Tables\Columns\TextColumn::make('type')
|
Tables\Columns\TextColumn::make('type')
|
||||||
->label('Type')
|
->label('Type')
|
||||||
->formatStateUsing([OperationCatalog::class, 'label']),
|
->formatStateUsing(Closure::fromCallable([self::class, 'formatOperationType'])),
|
||||||
|
|
||||||
Tables\Columns\TextColumn::make('status')
|
Tables\Columns\TextColumn::make('status')
|
||||||
->badge()
|
->badge()
|
||||||
@ -85,12 +101,43 @@ public function table(Table $table): Table
|
|||||||
->label('View')
|
->label('View')
|
||||||
->icon('heroicon-o-eye')
|
->icon('heroicon-o-eye')
|
||||||
->url(function (OperationRun $record): string {
|
->url(function (OperationRun $record): string {
|
||||||
|
$record = $this->resolveOwnerScopedOperationRun($record);
|
||||||
$tenant = Tenant::currentOrFail();
|
$tenant = Tenant::currentOrFail();
|
||||||
|
|
||||||
return OperationRunLinks::view($record, $tenant);
|
return OperationRunLinks::view($record, $tenant);
|
||||||
})
|
})
|
||||||
->openUrlInNewTab(true),
|
->openUrlInNewTab(true),
|
||||||
])
|
])
|
||||||
->bulkActions([]);
|
->bulkActions([])
|
||||||
|
->emptyStateHeading('No schedule runs yet')
|
||||||
|
->emptyStateDescription('Operation history will appear here after this schedule has been enqueued.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveOwnerScopedOperationRun(mixed $record): OperationRun
|
||||||
|
{
|
||||||
|
$recordId = $record instanceof OperationRun
|
||||||
|
? (int) $record->getKey()
|
||||||
|
: (is_numeric($record) ? (int) $record : 0);
|
||||||
|
|
||||||
|
if ($recordId <= 0) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolvedRecord = $this->getOwnerRecord()
|
||||||
|
->operationRuns()
|
||||||
|
->where('tenant_id', Tenant::currentOrFail()->getKey())
|
||||||
|
->whereKey($recordId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $resolvedRecord instanceof OperationRun) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolvedRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function formatOperationType(?string $state): string
|
||||||
|
{
|
||||||
|
return OperationCatalog::label($state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||||
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Resources\BackupSetResource\Pages;
|
use App\Filament\Resources\BackupSetResource\Pages;
|
||||||
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
|
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
|
||||||
use App\Jobs\BulkBackupSetDeleteJob;
|
use App\Jobs\BulkBackupSetDeleteJob;
|
||||||
@ -18,9 +20,21 @@
|
|||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||||
|
use App\Support\Navigation\RelatedContextEntry;
|
||||||
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder;
|
||||||
|
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
|
||||||
|
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
||||||
|
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
@ -38,10 +52,14 @@
|
|||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class BackupSetResource extends Resource
|
class BackupSetResource extends Resource
|
||||||
{
|
{
|
||||||
|
use InteractsWithTenantOwnedRecords;
|
||||||
|
use ResolvesPanelTenantContext;
|
||||||
|
|
||||||
protected static ?string $model = BackupSet::class;
|
protected static ?string $model = BackupSet::class;
|
||||||
|
|
||||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||||
@ -50,9 +68,29 @@ class BackupSetResource extends Resource
|
|||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
||||||
|
|
||||||
|
public static function shouldRegisterNavigation(): bool
|
||||||
|
{
|
||||||
|
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::shouldRegisterNavigation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Create backup set is available in the list header.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Restore, archive, and force delete remain grouped in the More menu on table rows.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk archive, restore, and force delete stay grouped under More.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Create backup set remains available from the empty state.')
|
||||||
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Primary related navigation and grouped mutation actions render in the view header.');
|
||||||
|
}
|
||||||
|
|
||||||
public static function canViewAny(): bool
|
public static function canViewAny(): bool
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
@ -68,7 +106,7 @@ public static function canViewAny(): bool
|
|||||||
|
|
||||||
public static function canCreate(): bool
|
public static function canCreate(): bool
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
@ -84,13 +122,12 @@ public static function canCreate(): bool
|
|||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
$tenant = Filament::getTenant();
|
return static::getTenantOwnedEloquentQuery();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return parent::getEloquentQuery()->where('tenant_id', (int) $tenant->getKey());
|
public static function resolveScopedRecordOrFail(int|string $key): \Illuminate\Database\Eloquent\Model
|
||||||
|
{
|
||||||
|
return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->withTrashed());
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
@ -119,18 +156,25 @@ public static function makeCreateAction(): Actions\CreateAction
|
|||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
|
->defaultSort('created_at', 'desc')
|
||||||
|
->paginated(TablePaginationProfiles::resource())
|
||||||
|
->persistFiltersInSession()
|
||||||
|
->persistSearchInSession()
|
||||||
|
->persistSortInSession()
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('name')->searchable(),
|
Tables\Columns\TextColumn::make('name')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('status')
|
Tables\Columns\TextColumn::make('status')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BackupSetStatus))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BackupSetStatus))
|
||||||
->color(BadgeRenderer::color(BadgeDomain::BackupSetStatus))
|
->color(BadgeRenderer::color(BadgeDomain::BackupSetStatus))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::BackupSetStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::BackupSetStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupSetStatus)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupSetStatus)),
|
||||||
Tables\Columns\TextColumn::make('item_count')->label('Items'),
|
Tables\Columns\TextColumn::make('item_count')->label('Items')->numeric()->sortable(),
|
||||||
Tables\Columns\TextColumn::make('created_by')->label('Created by'),
|
Tables\Columns\TextColumn::make('created_by')->label('Created by')->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('completed_at')->dateTime()->since(),
|
Tables\Columns\TextColumn::make('completed_at')->label('Completed')->dateTime()->since()->sortable(),
|
||||||
Tables\Columns\TextColumn::make('created_at')->dateTime()->since(),
|
Tables\Columns\TextColumn::make('created_at')->label('Captured')->dateTime()->since()->sortable()->toggleable(isToggledHiddenByDefault: true),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
Tables\Filters\TrashedFilter::make()
|
Tables\Filters\TrashedFilter::make()
|
||||||
@ -139,10 +183,9 @@ public static function table(Table $table): Table
|
|||||||
->trueLabel('All')
|
->trueLabel('All')
|
||||||
->falseLabel('Archived'),
|
->falseLabel('Archived'),
|
||||||
])
|
])
|
||||||
|
->recordUrl(fn (BackupSet $record): string => static::getUrl('view', ['record' => $record]))
|
||||||
->actions([
|
->actions([
|
||||||
Actions\ViewAction::make()
|
static::primaryRelatedAction(),
|
||||||
->url(fn (BackupSet $record) => static::getUrl('view', ['record' => $record]))
|
|
||||||
->openUrlInNewTab(false),
|
|
||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('restore')
|
Actions\Action::make('restore')
|
||||||
@ -152,7 +195,7 @@ public static function table(Table $table): Table
|
|||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (BackupSet $record): bool => $record->trashed())
|
->visible(fn (BackupSet $record): bool => $record->trashed())
|
||||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||||
$tenant = Filament::getTenant();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
$record->restore();
|
$record->restore();
|
||||||
$record->items()->withTrashed()->restore();
|
$record->items()->withTrashed()->restore();
|
||||||
@ -185,7 +228,7 @@ public static function table(Table $table): Table
|
|||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (BackupSet $record): bool => ! $record->trashed())
|
->visible(fn (BackupSet $record): bool => ! $record->trashed())
|
||||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||||
$tenant = Filament::getTenant();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
$record->delete();
|
$record->delete();
|
||||||
|
|
||||||
@ -217,7 +260,7 @@ public static function table(Table $table): Table
|
|||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (BackupSet $record): bool => $record->trashed())
|
->visible(fn (BackupSet $record): bool => $record->trashed())
|
||||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||||
$tenant = Filament::getTenant();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if ($record->restoreRuns()->withTrashed()->exists()) {
|
if ($record->restoreRuns()->withTrashed()->exists()) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
@ -287,7 +330,7 @@ public static function table(Table $table): Table
|
|||||||
return [];
|
return [];
|
||||||
})
|
})
|
||||||
->action(function (Collection $records) {
|
->action(function (Collection $records) {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
$ids = $records->pluck('id')->toArray();
|
||||||
@ -357,7 +400,7 @@ public static function table(Table $table): Table
|
|||||||
->modalHeading(fn (Collection $records) => "Restore {$records->count()} backup sets?")
|
->modalHeading(fn (Collection $records) => "Restore {$records->count()} backup sets?")
|
||||||
->modalDescription('Archived backup sets will be restored back to the active list. Active backup sets will be skipped.')
|
->modalDescription('Archived backup sets will be restored back to the active list. Active backup sets will be skipped.')
|
||||||
->action(function (Collection $records) {
|
->action(function (Collection $records) {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
$ids = $records->pluck('id')->toArray();
|
||||||
@ -442,7 +485,7 @@ public static function table(Table $table): Table
|
|||||||
return [];
|
return [];
|
||||||
})
|
})
|
||||||
->action(function (Collection $records) {
|
->action(function (Collection $records) {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
$ids = $records->pluck('id')->toArray();
|
||||||
@ -508,21 +551,11 @@ public static function infolist(Schema $schema): Schema
|
|||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
->schema([
|
->schema([
|
||||||
Infolists\Components\TextEntry::make('name'),
|
Infolists\Components\ViewEntry::make('enterprise_detail')
|
||||||
Infolists\Components\TextEntry::make('status')
|
->label('')
|
||||||
->badge()
|
->view('filament.infolists.entries.enterprise-detail.layout')
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BackupSetStatus))
|
->state(fn (BackupSet $record): array => static::enterpriseDetailPage($record)->toArray())
|
||||||
->color(BadgeRenderer::color(BadgeDomain::BackupSetStatus))
|
->columnSpanFull(),
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::BackupSetStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupSetStatus)),
|
|
||||||
Infolists\Components\TextEntry::make('item_count')->label('Items'),
|
|
||||||
Infolists\Components\TextEntry::make('created_by')->label('Created by'),
|
|
||||||
Infolists\Components\TextEntry::make('completed_at')->dateTime(),
|
|
||||||
Infolists\Components\TextEntry::make('metadata')
|
|
||||||
->label('Metadata')
|
|
||||||
->formatStateUsing(fn ($state) => json_encode($state ?? [], JSON_PRETTY_PRINT))
|
|
||||||
->copyable()
|
|
||||||
->copyMessage('Metadata copied'),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -560,13 +593,49 @@ private static function typeMeta(?string $type): array
|
|||||||
->firstWhere('type', $type) ?? [];
|
->firstWhere('type', $type) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{
|
||||||
|
* key: string,
|
||||||
|
* label: string,
|
||||||
|
* value: string,
|
||||||
|
* secondaryValue: ?string,
|
||||||
|
* targetUrl: ?string,
|
||||||
|
* targetKind: string,
|
||||||
|
* availability: string,
|
||||||
|
* unavailableReason: ?string,
|
||||||
|
* contextBadge: ?string,
|
||||||
|
* priority: int,
|
||||||
|
* actionLabel: string
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public static function relatedContextEntries(BackupSet $record): array
|
||||||
|
{
|
||||||
|
return app(RelatedNavigationResolver::class)
|
||||||
|
->detailEntries(CrossResourceNavigationMatrix::SOURCE_BACKUP_SET, $record);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function primaryRelatedAction(): Actions\Action
|
||||||
|
{
|
||||||
|
return Actions\Action::make('primary_drill_down')
|
||||||
|
->label(fn (BackupSet $record): string => static::primaryRelatedEntry($record)?->actionLabel ?? 'Open related record')
|
||||||
|
->url(fn (BackupSet $record): ?string => static::primaryRelatedEntry($record)?->targetUrl)
|
||||||
|
->hidden(fn (BackupSet $record): bool => ! (static::primaryRelatedEntry($record)?->isAvailable() ?? false))
|
||||||
|
->color('gray');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function primaryRelatedEntry(BackupSet $record): ?RelatedContextEntry
|
||||||
|
{
|
||||||
|
return app(RelatedNavigationResolver::class)
|
||||||
|
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_BACKUP_SET, $record);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a backup set via the domain service instead of direct model mass-assignment.
|
* Create a backup set via the domain service instead of direct model mass-assignment.
|
||||||
*/
|
*/
|
||||||
public static function createBackupSet(array $data): BackupSet
|
public static function createBackupSet(array $data): BackupSet
|
||||||
{
|
{
|
||||||
/** @var Tenant $tenant */
|
/** @var Tenant $tenant */
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
/** @var BackupService $service */
|
/** @var BackupService $service */
|
||||||
$service = app(BackupService::class);
|
$service = app(BackupService::class);
|
||||||
@ -581,4 +650,94 @@ public static function createBackupSet(array $data): BackupSet
|
|||||||
includeScopeTags: $data['include_scope_tags'] ?? false,
|
includeScopeTags: $data['include_scope_tags'] ?? false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function enterpriseDetailPage(BackupSet $record): EnterpriseDetailPageData
|
||||||
|
{
|
||||||
|
$factory = new EnterpriseDetailSectionFactory;
|
||||||
|
$statusSpec = BadgeRenderer::spec(BadgeDomain::BackupSetStatus, $record->status);
|
||||||
|
$metadata = is_array($record->metadata) ? $record->metadata : [];
|
||||||
|
$metadataKeyCount = count($metadata);
|
||||||
|
$relatedContext = static::relatedContextEntries($record);
|
||||||
|
$isArchived = $record->trashed();
|
||||||
|
|
||||||
|
return EnterpriseDetailBuilder::make('backup_set', 'tenant')
|
||||||
|
->header(new SummaryHeaderData(
|
||||||
|
title: (string) $record->name,
|
||||||
|
subtitle: 'Backup set #'.$record->getKey(),
|
||||||
|
statusBadges: [
|
||||||
|
$factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor),
|
||||||
|
$factory->statusBadge($isArchived ? 'Archived' : 'Active', $isArchived ? 'warning' : 'success'),
|
||||||
|
],
|
||||||
|
keyFacts: [
|
||||||
|
$factory->keyFact('Items', $record->item_count),
|
||||||
|
$factory->keyFact('Created by', $record->created_by),
|
||||||
|
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
|
||||||
|
$factory->keyFact('Captured', static::formatDetailTimestamp($record->created_at)),
|
||||||
|
],
|
||||||
|
descriptionHint: 'Lifecycle status, recovery readiness, and related operations stay ahead of raw backup metadata.',
|
||||||
|
))
|
||||||
|
->addSection(
|
||||||
|
$factory->factsSection(
|
||||||
|
id: 'lifecycle_overview',
|
||||||
|
kind: 'core_details',
|
||||||
|
title: 'Lifecycle overview',
|
||||||
|
items: [
|
||||||
|
$factory->keyFact('Status', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
|
||||||
|
$factory->keyFact('Items', $record->item_count),
|
||||||
|
$factory->keyFact('Created by', $record->created_by),
|
||||||
|
$factory->keyFact('Archived', $isArchived),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
$factory->viewSection(
|
||||||
|
id: 'related_context',
|
||||||
|
kind: 'related_context',
|
||||||
|
title: 'Related context',
|
||||||
|
view: 'filament.infolists.entries.related-context',
|
||||||
|
viewData: ['entries' => $relatedContext],
|
||||||
|
emptyState: $factory->emptyState('No related context is available for this record.'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
->addSupportingCard(
|
||||||
|
$factory->supportingFactsCard(
|
||||||
|
kind: 'status',
|
||||||
|
title: 'Recovery readiness',
|
||||||
|
items: [
|
||||||
|
$factory->keyFact('Backup state', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
|
||||||
|
$factory->keyFact('Archived', $isArchived),
|
||||||
|
$factory->keyFact('Metadata keys', $metadataKeyCount),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
$factory->supportingFactsCard(
|
||||||
|
kind: 'timestamps',
|
||||||
|
title: 'Timing',
|
||||||
|
items: [
|
||||||
|
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
|
||||||
|
$factory->keyFact('Captured', static::formatDetailTimestamp($record->created_at)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
->addTechnicalSection(
|
||||||
|
$factory->technicalDetail(
|
||||||
|
title: 'Technical detail',
|
||||||
|
entries: [
|
||||||
|
$factory->keyFact('Metadata keys', $metadataKeyCount),
|
||||||
|
$factory->keyFact('Archived', $isArchived),
|
||||||
|
],
|
||||||
|
description: 'Low-signal backup metadata stays available here without taking over the recovery workflow.',
|
||||||
|
view: $metadata !== [] ? 'filament.infolists.entries.snapshot-json' : null,
|
||||||
|
viewData: ['payload' => $metadata],
|
||||||
|
emptyState: $factory->emptyState('No backup metadata was recorded for this backup set.'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
->build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function formatDetailTimestamp(mixed $value): string
|
||||||
|
{
|
||||||
|
if (! $value instanceof Carbon) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value->toDayDateTimeString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,12 +3,24 @@
|
|||||||
namespace App\Filament\Resources\BackupSetResource\Pages;
|
namespace App\Filament\Resources\BackupSetResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\BackupSetResource;
|
use App\Filament\Resources\BackupSetResource;
|
||||||
|
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
class ListBackupSets extends ListRecords
|
class ListBackupSets extends ListRecords
|
||||||
{
|
{
|
||||||
protected static string $resource = BackupSetResource::class;
|
protected static string $resource = BackupSetResource::class;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||||
|
$this->getTableFiltersSessionKey(),
|
||||||
|
request: request(),
|
||||||
|
tenantFilterName: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
parent::mount();
|
||||||
|
}
|
||||||
|
|
||||||
private function tableHasRecords(): bool
|
private function tableHasRecords(): bool
|
||||||
{
|
{
|
||||||
return $this->getTableRecords()->count() > 0;
|
return $this->getTableRecords()->count() > 0;
|
||||||
@ -21,4 +33,11 @@ protected function getHeaderActions(): array
|
|||||||
->visible(fn (): bool => $this->tableHasRecords()),
|
->visible(fn (): bool => $this->tableHasRecords()),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function getTableEmptyStateActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
BackupSetResource::makeCreateAction(),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,194 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Filament\Resources\BackupSetResource\Pages;
|
namespace App\Filament\Resources\BackupSetResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Resources\BackupSetResource;
|
use App\Filament\Resources\BackupSetResource;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Intune\AuditLogger;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||||
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class ViewBackupSet extends ViewRecord
|
class ViewBackupSet extends ViewRecord
|
||||||
{
|
{
|
||||||
|
use ResolvesPanelTenantContext;
|
||||||
|
|
||||||
protected static string $resource = BackupSetResource::class;
|
protected static string $resource = BackupSetResource::class;
|
||||||
|
|
||||||
|
protected function resolveRecord(int|string $key): Model
|
||||||
|
{
|
||||||
|
return BackupSetResource::resolveScopedRecordOrFail($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
$actions = [
|
||||||
|
Action::make('primary_related')
|
||||||
|
->label(fn (): string => app(RelatedNavigationResolver::class)
|
||||||
|
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_BACKUP_SET, $this->getRecord())?->actionLabel ?? 'Open related record')
|
||||||
|
->url(fn (): ?string => app(RelatedNavigationResolver::class)
|
||||||
|
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_BACKUP_SET, $this->getRecord())?->targetUrl)
|
||||||
|
->hidden(fn (): bool => ! (app(RelatedNavigationResolver::class)
|
||||||
|
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_BACKUP_SET, $this->getRecord())?->isAvailable() ?? false))
|
||||||
|
->color('gray'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$mutationActions = [
|
||||||
|
$this->restoreAction(),
|
||||||
|
$this->archiveAction(),
|
||||||
|
$this->forceDeleteAction(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$actions[] = ActionGroup::make($mutationActions)
|
||||||
|
->label('More')
|
||||||
|
->icon('heroicon-o-ellipsis-vertical')
|
||||||
|
->color('gray');
|
||||||
|
|
||||||
|
return $actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function restoreAction(): Action
|
||||||
|
{
|
||||||
|
$action = Action::make('restore')
|
||||||
|
->label('Restore')
|
||||||
|
->color('success')
|
||||||
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (): bool => $this->getRecord() instanceof BackupSet && $this->getRecord()->trashed())
|
||||||
|
->action(function (AuditLogger $auditLogger): void {
|
||||||
|
/** @var BackupSet $record */
|
||||||
|
$record = $this->getRecord();
|
||||||
|
|
||||||
|
$record->restore();
|
||||||
|
$record->items()->withTrashed()->restore();
|
||||||
|
|
||||||
|
if ($record->tenant instanceof Tenant) {
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $record->tenant,
|
||||||
|
action: 'backup.restored',
|
||||||
|
resourceType: 'backup_set',
|
||||||
|
resourceId: (string) $record->getKey(),
|
||||||
|
status: 'success',
|
||||||
|
context: ['metadata' => ['name' => $record->name]],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Backup set restored')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
$this->redirect(BackupSetResource::getUrl('view', ['record' => $record], tenant: static::resolveTenantContextForCurrentPanel()), navigate: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
UiEnforcement::forAction($action)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->apply();
|
||||||
|
|
||||||
|
return $action;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function archiveAction(): Action
|
||||||
|
{
|
||||||
|
$action = Action::make('archive')
|
||||||
|
->label('Archive')
|
||||||
|
->color('danger')
|
||||||
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (): bool => $this->getRecord() instanceof BackupSet && ! $this->getRecord()->trashed())
|
||||||
|
->action(function (AuditLogger $auditLogger): void {
|
||||||
|
/** @var BackupSet $record */
|
||||||
|
$record = $this->getRecord();
|
||||||
|
|
||||||
|
$record->delete();
|
||||||
|
|
||||||
|
if ($record->tenant instanceof Tenant) {
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $record->tenant,
|
||||||
|
action: 'backup.deleted',
|
||||||
|
resourceType: 'backup_set',
|
||||||
|
resourceId: (string) $record->getKey(),
|
||||||
|
status: 'success',
|
||||||
|
context: ['metadata' => ['name' => $record->name]],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Backup set archived')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
$this->redirect(BackupSetResource::getUrl('index', tenant: static::resolveTenantContextForCurrentPanel()), navigate: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
UiEnforcement::forAction($action)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->apply();
|
||||||
|
|
||||||
|
return $action;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function forceDeleteAction(): Action
|
||||||
|
{
|
||||||
|
$action = Action::make('forceDelete')
|
||||||
|
->label('Force delete')
|
||||||
|
->color('danger')
|
||||||
|
->icon('heroicon-o-trash')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (): bool => $this->getRecord() instanceof BackupSet && $this->getRecord()->trashed())
|
||||||
|
->action(function (AuditLogger $auditLogger): void {
|
||||||
|
/** @var BackupSet $record */
|
||||||
|
$record = $this->getRecord();
|
||||||
|
|
||||||
|
if ($record->restoreRuns()->withTrashed()->exists()) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Cannot force delete backup set')
|
||||||
|
->body('Backup sets referenced by restore runs cannot be removed.')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record->tenant instanceof Tenant) {
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $record->tenant,
|
||||||
|
action: 'backup.force_deleted',
|
||||||
|
resourceType: 'backup_set',
|
||||||
|
resourceId: (string) $record->getKey(),
|
||||||
|
status: 'success',
|
||||||
|
context: ['metadata' => ['name' => $record->name]],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->items()->withTrashed()->forceDelete();
|
||||||
|
$record->forceDelete();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Backup set permanently deleted')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
$this->redirect(BackupSetResource::getUrl('index', tenant: static::resolveTenantContextForCurrentPanel()), navigate: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
UiEnforcement::forAction($action)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
|
->apply();
|
||||||
|
|
||||||
|
return $action;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
namespace App\Filament\Resources\BackupSetResource\RelationManagers;
|
namespace App\Filament\Resources\BackupSetResource\RelationManagers;
|
||||||
|
|
||||||
use App\Filament\Resources\PolicyResource;
|
use App\Filament\Resources\PolicyResource;
|
||||||
|
use App\Filament\Resources\PolicyVersionResource;
|
||||||
use App\Jobs\RemovePoliciesFromBackupSetJob;
|
use App\Jobs\RemovePoliciesFromBackupSetJob;
|
||||||
use App\Models\BackupItem;
|
use App\Models\BackupItem;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -13,6 +14,9 @@
|
|||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Badges\TagBadgeDomain;
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
use App\Support\Badges\TagBadgeRenderer;
|
use App\Support\Badges\TagBadgeRenderer;
|
||||||
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
@ -20,6 +24,7 @@
|
|||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\RelationManagers\RelationManager;
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@ -38,6 +43,27 @@ public function closeAddPoliciesModal(): void
|
|||||||
$this->unmountAction();
|
$this->unmountAction();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $arguments
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
|
||||||
|
{
|
||||||
|
if (($context['table'] ?? false) === true) {
|
||||||
|
$backupSet = $this->getOwnerRecord();
|
||||||
|
|
||||||
|
if ($name === 'remove' && filled($context['recordKey'] ?? null)) {
|
||||||
|
$this->resolveOwnerScopedBackupItemId($backupSet, $context['recordKey']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($name === 'bulk_remove' && ($context['bulk'] ?? false) === true) {
|
||||||
|
$this->resolveOwnerScopedBackupItemIdsFromKeys($backupSet, $this->selectedTableRecords);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::mountAction($name, $arguments, $context);
|
||||||
|
}
|
||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
$refreshTable = Actions\Action::make('refreshTable')
|
$refreshTable = Actions\Action::make('refreshTable')
|
||||||
@ -72,7 +98,7 @@ public function table(Table $table): Table
|
|||||||
->color('danger')
|
->color('danger')
|
||||||
->icon('heroicon-o-x-mark')
|
->icon('heroicon-o-x-mark')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->action(function (BackupItem $record): void {
|
->action(function (mixed $record): void {
|
||||||
$backupSet = $this->getOwnerRecord();
|
$backupSet = $this->getOwnerRecord();
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -89,7 +115,7 @@ public function table(Table $table): Table
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$backupItemIds = [(int) $record->getKey()];
|
$backupItemIds = [$this->resolveOwnerScopedBackupItemId($backupSet, $record)];
|
||||||
|
|
||||||
/** @var OperationRunService $opService */
|
/** @var OperationRunService $opService */
|
||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
@ -168,14 +194,7 @@ public function table(Table $table): Table
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$backupItemIds = $records
|
$backupItemIds = $this->resolveOwnerScopedBackupItemIdsFromKeys($backupSet, $this->selectedTableRecords);
|
||||||
->pluck('id')
|
|
||||||
->map(fn (mixed $value): int => (int) $value)
|
|
||||||
->filter(fn (int $value): bool => $value > 0)
|
|
||||||
->unique()
|
|
||||||
->sort()
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
if ($backupItemIds === []) {
|
if ($backupItemIds === []) {
|
||||||
return;
|
return;
|
||||||
@ -232,7 +251,12 @@ public function table(Table $table): Table
|
|||||||
->apply();
|
->apply();
|
||||||
|
|
||||||
return $table
|
return $table
|
||||||
->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion'))
|
->modifyQueryUsing(fn (Builder $query) => $query->with(['policy', 'policyVersion', 'policyVersion.policy']))
|
||||||
|
->defaultSort('policy.display_name')
|
||||||
|
->paginated(TablePaginationProfiles::relationManager())
|
||||||
|
->persistFiltersInSession()
|
||||||
|
->persistSearchInSession()
|
||||||
|
->persistSortInSession()
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('policy.display_name')
|
Tables\Columns\TextColumn::make('policy.display_name')
|
||||||
->label('Item')
|
->label('Item')
|
||||||
@ -267,7 +291,8 @@ public function table(Table $table): Table
|
|||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRisk)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRisk)),
|
||||||
Tables\Columns\TextColumn::make('policy_identifier')
|
Tables\Columns\TextColumn::make('policy_identifier')
|
||||||
->label('Policy ID')
|
->label('Policy ID')
|
||||||
->copyable(),
|
->copyable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('platform')
|
Tables\Columns\TextColumn::make('platform')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||||
@ -309,11 +334,24 @@ public function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
return '—';
|
return '—';
|
||||||
}),
|
})
|
||||||
Tables\Columns\TextColumn::make('captured_at')->dateTime(),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('created_at')->since(),
|
Tables\Columns\TextColumn::make('captured_at')->dateTime()->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
Tables\Columns\TextColumn::make('created_at')->since()->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
SelectFilter::make('policy_type')
|
||||||
|
->label('Type')
|
||||||
|
->options(FilterOptionCatalog::policyTypes())
|
||||||
|
->searchable(),
|
||||||
|
SelectFilter::make('restore_mode')
|
||||||
|
->label('Restore')
|
||||||
|
->options(static::restoreModeOptions())
|
||||||
|
->query(fn (Builder $query, array $data): Builder => static::applyRestoreModeFilter($query, $data['value'] ?? null)),
|
||||||
|
SelectFilter::make('platform')
|
||||||
|
->options(FilterOptionCatalog::platforms())
|
||||||
|
->searchable(),
|
||||||
])
|
])
|
||||||
->filters([])
|
|
||||||
->headerActions([
|
->headerActions([
|
||||||
$refreshTable,
|
$refreshTable,
|
||||||
$addPolicies,
|
$addPolicies,
|
||||||
@ -321,17 +359,21 @@ public function table(Table $table): Table
|
|||||||
->actions([
|
->actions([
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
Actions\ViewAction::make()
|
Actions\ViewAction::make()
|
||||||
->label('View policy')
|
->label(fn (BackupItem $record): string => $record->policy_version_id ? 'View version' : 'View policy')
|
||||||
->url(function (BackupItem $record): ?string {
|
->url(function (BackupItem $record): ?string {
|
||||||
|
$tenant = $this->getOwnerRecord()->tenant ?? \App\Models\Tenant::current();
|
||||||
|
|
||||||
|
if ($record->policy_version_id) {
|
||||||
|
return PolicyVersionResource::getUrl('view', ['record' => $record->policy_version_id], tenant: $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
if (! $record->policy_id) {
|
if (! $record->policy_id) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = $this->getOwnerRecord()->tenant ?? \App\Models\Tenant::current();
|
|
||||||
|
|
||||||
return PolicyResource::getUrl('view', ['record' => $record->policy_id], tenant: $tenant);
|
return PolicyResource::getUrl('view', ['record' => $record->policy_id], tenant: $tenant);
|
||||||
})
|
})
|
||||||
->hidden(fn (BackupItem $record) => ! $record->policy_id)
|
->hidden(fn (BackupItem $record) => ! $record->policy_version_id && ! $record->policy_id)
|
||||||
->openUrlInNewTab(true),
|
->openUrlInNewTab(true),
|
||||||
$removeItem,
|
$removeItem,
|
||||||
])
|
])
|
||||||
@ -343,6 +385,11 @@ public function table(Table $table): Table
|
|||||||
Actions\BulkActionGroup::make([
|
Actions\BulkActionGroup::make([
|
||||||
$bulkRemove,
|
$bulkRemove,
|
||||||
])->label('More'),
|
])->label('More'),
|
||||||
|
])
|
||||||
|
->emptyStateHeading('No policies in this backup set')
|
||||||
|
->emptyStateDescription('Add policies to capture versions and assignments inside this backup set.')
|
||||||
|
->emptyStateActions([
|
||||||
|
$addPolicies->name('addPoliciesEmpty'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -363,4 +410,106 @@ private static function typeMeta(?string $type): array
|
|||||||
return collect($types)
|
return collect($types)
|
||||||
->firstWhere('type', $type) ?? [];
|
->firstWhere('type', $type) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private static function restoreModeOptions(): array
|
||||||
|
{
|
||||||
|
return collect(InventoryPolicyTypeMeta::all())
|
||||||
|
->pluck('restore')
|
||||||
|
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
||||||
|
->map(fn (string $value): string => trim($value))
|
||||||
|
->unique()
|
||||||
|
->sort()
|
||||||
|
->mapWithKeys(fn (string $value): array => [
|
||||||
|
$value => BadgeRenderer::spec(BadgeDomain::PolicyRestoreMode, $value)->label,
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function applyRestoreModeFilter(Builder $query, mixed $value): Builder
|
||||||
|
{
|
||||||
|
if (! is_string($value) || trim($value) === '') {
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
$types = collect(InventoryPolicyTypeMeta::all())
|
||||||
|
->filter(fn (array $meta): bool => ($meta['restore'] ?? null) === $value)
|
||||||
|
->pluck('type')
|
||||||
|
->filter(fn (mixed $type): bool => is_string($type) && trim($type) !== '')
|
||||||
|
->map(fn (string $type): string => trim($type))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if ($types === []) {
|
||||||
|
return $query->whereRaw('1 = 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->whereIn('policy_type', $types);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveOwnerScopedBackupItemId(\App\Models\BackupSet $backupSet, mixed $record): int
|
||||||
|
{
|
||||||
|
$recordId = $this->normalizeBackupItemKey($record);
|
||||||
|
|
||||||
|
if ($recordId <= 0) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolvedId = $backupSet->items()
|
||||||
|
->where('tenant_id', (int) $backupSet->tenant_id)
|
||||||
|
->whereKey($recordId)
|
||||||
|
->value('id');
|
||||||
|
|
||||||
|
if (! is_numeric($resolvedId) || (int) $resolvedId <= 0) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $resolvedId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, int>
|
||||||
|
*/
|
||||||
|
private function resolveOwnerScopedBackupItemIdsFromKeys(\App\Models\BackupSet $backupSet, array $recordKeys): array
|
||||||
|
{
|
||||||
|
$requestedIds = collect($recordKeys)
|
||||||
|
->map(fn (mixed $record): int => $this->normalizeBackupItemKey($record))
|
||||||
|
->filter(fn (int $value): bool => $value > 0)
|
||||||
|
->unique()
|
||||||
|
->sort()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if ($requestedIds === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolvedIds = $backupSet->items()
|
||||||
|
->where('tenant_id', (int) $backupSet->tenant_id)
|
||||||
|
->whereIn('id', $requestedIds)
|
||||||
|
->pluck('id')
|
||||||
|
->map(fn (mixed $value): int => (int) $value)
|
||||||
|
->filter(fn (int $value): bool => $value > 0)
|
||||||
|
->unique()
|
||||||
|
->sort()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if (count($resolvedIds) !== count($requestedIds)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolvedIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeBackupItemKey(mixed $record): int
|
||||||
|
{
|
||||||
|
if ($record instanceof BackupItem) {
|
||||||
|
return (int) $record->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_numeric($record) ? (int) $record : 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,18 +6,28 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\BaselineProfileResource\Pages;
|
use App\Filament\Resources\BaselineProfileResource\Pages;
|
||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
|
use App\Models\BaselineSnapshot;
|
||||||
|
use App\Models\BaselineTenantAssignment;
|
||||||
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
||||||
use App\Support\Audit\AuditActionId;
|
use App\Support\Audit\AuditActionId;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||||
use App\Support\Baselines\BaselineProfileStatus;
|
use App\Support\Baselines\BaselineProfileStatus;
|
||||||
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||||
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
|
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
@ -287,15 +297,32 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->placeholder('None'),
|
->placeholder('None'),
|
||||||
])
|
])
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
Section::make('Baseline truth')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('current_snapshot_truth')
|
||||||
|
->label('Current snapshot')
|
||||||
|
->state(fn (BaselineProfile $record): string => self::currentSnapshotLabel($record)),
|
||||||
|
TextEntry::make('latest_attempted_snapshot_truth')
|
||||||
|
->label('Latest attempt')
|
||||||
|
->state(fn (BaselineProfile $record): string => self::latestAttemptedSnapshotLabel($record)),
|
||||||
|
TextEntry::make('compare_readiness')
|
||||||
|
->label('Compare readiness')
|
||||||
|
->badge()
|
||||||
|
->state(fn (BaselineProfile $record): string => self::compareReadinessLabel($record))
|
||||||
|
->color(fn (BaselineProfile $record): string => self::compareReadinessColor($record))
|
||||||
|
->icon(fn (BaselineProfile $record): ?string => self::compareReadinessIcon($record)),
|
||||||
|
TextEntry::make('baseline_next_step')
|
||||||
|
->label('Next step')
|
||||||
|
->state(fn (BaselineProfile $record): string => self::profileNextStep($record))
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columns(2)
|
||||||
|
->columnSpanFull(),
|
||||||
Section::make('Metadata')
|
Section::make('Metadata')
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('createdByUser.name')
|
TextEntry::make('createdByUser.name')
|
||||||
->label('Created by')
|
->label('Created by')
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
TextEntry::make('activeSnapshot.captured_at')
|
|
||||||
->label('Last snapshot')
|
|
||||||
->dateTime()
|
|
||||||
->placeholder('No snapshot yet'),
|
|
||||||
TextEntry::make('created_at')
|
TextEntry::make('created_at')
|
||||||
->dateTime(),
|
->dateTime(),
|
||||||
TextEntry::make('updated_at')
|
TextEntry::make('updated_at')
|
||||||
@ -312,6 +339,7 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
return $table
|
return $table
|
||||||
->defaultSort('name')
|
->defaultSort('name')
|
||||||
|
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('name')
|
TextColumn::make('name')
|
||||||
->searchable()
|
->searchable()
|
||||||
@ -350,15 +378,39 @@ public static function table(Table $table): Table
|
|||||||
TextColumn::make('version_label')
|
TextColumn::make('version_label')
|
||||||
->label('Version')
|
->label('Version')
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
TextColumn::make('activeSnapshot.captured_at')
|
TextColumn::make('tenant_assignments_count')
|
||||||
->label('Last snapshot')
|
->label('Assigned tenants')
|
||||||
->dateTime()
|
->counts('tenantAssignments'),
|
||||||
->placeholder('No snapshot'),
|
TextColumn::make('current_snapshot_truth')
|
||||||
|
->label('Current snapshot')
|
||||||
|
->getStateUsing(fn (BaselineProfile $record): string => self::currentSnapshotLabel($record))
|
||||||
|
->description(fn (BaselineProfile $record): ?string => self::currentSnapshotDescription($record))
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('latest_attempted_snapshot_truth')
|
||||||
|
->label('Latest attempt')
|
||||||
|
->getStateUsing(fn (BaselineProfile $record): string => self::latestAttemptedSnapshotLabel($record))
|
||||||
|
->description(fn (BaselineProfile $record): ?string => self::latestAttemptedSnapshotDescription($record))
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('compare_readiness')
|
||||||
|
->label('Compare readiness')
|
||||||
|
->badge()
|
||||||
|
->getStateUsing(fn (BaselineProfile $record): string => self::compareReadinessLabel($record))
|
||||||
|
->color(fn (BaselineProfile $record): string => self::compareReadinessColor($record))
|
||||||
|
->icon(fn (BaselineProfile $record): ?string => self::compareReadinessIcon($record))
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('baseline_next_step')
|
||||||
|
->label('Next step')
|
||||||
|
->getStateUsing(fn (BaselineProfile $record): string => self::profileNextStep($record))
|
||||||
|
->wrap(),
|
||||||
TextColumn::make('created_at')
|
TextColumn::make('created_at')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
])
|
])
|
||||||
|
->filters([
|
||||||
|
\Filament\Tables\Filters\SelectFilter::make('status')
|
||||||
|
->options(FilterOptionCatalog::baselineProfileStatuses()),
|
||||||
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view')
|
Action::make('view')
|
||||||
->label('View')
|
->label('View')
|
||||||
@ -423,10 +475,10 @@ public static function policyTypeOptions(): array
|
|||||||
*/
|
*/
|
||||||
public static function foundationTypeOptions(): array
|
public static function foundationTypeOptions(): array
|
||||||
{
|
{
|
||||||
return collect(InventoryPolicyTypeMeta::foundations())
|
return collect(InventoryPolicyTypeMeta::baselineSupportedFoundations())
|
||||||
->filter(fn (array $row): bool => filled($row['type'] ?? null))
|
->filter(fn (array $row): bool => filled($row['type'] ?? null))
|
||||||
->mapWithKeys(fn (array $row): array => [
|
->mapWithKeys(fn (array $row): array => [
|
||||||
(string) $row['type'] => (string) ($row['label'] ?? $row['type']),
|
(string) $row['type'] => InventoryPolicyTypeMeta::baselineCompareLabel((string) $row['type']) ?? (string) ($row['label'] ?? $row['type']),
|
||||||
])
|
])
|
||||||
->sort()
|
->sort()
|
||||||
->all();
|
->all();
|
||||||
@ -536,4 +588,167 @@ private static function archiveTableAction(?Workspace $workspace): Action
|
|||||||
|
|
||||||
return $action;
|
return $action;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function currentSnapshotLabel(BaselineProfile $profile): string
|
||||||
|
{
|
||||||
|
$snapshot = self::effectiveSnapshot($profile);
|
||||||
|
|
||||||
|
if (! $snapshot instanceof BaselineSnapshot) {
|
||||||
|
return 'No complete snapshot';
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::snapshotReference($snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function currentSnapshotDescription(BaselineProfile $profile): ?string
|
||||||
|
{
|
||||||
|
$snapshot = self::effectiveSnapshot($profile);
|
||||||
|
|
||||||
|
if (! $snapshot instanceof BaselineSnapshot) {
|
||||||
|
return self::compareAvailabilityEnvelope($profile)?->shortExplanation;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $snapshot->captured_at?->toDayDateTimeString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function latestAttemptedSnapshotLabel(BaselineProfile $profile): string
|
||||||
|
{
|
||||||
|
$latestAttempt = self::latestAttemptedSnapshot($profile);
|
||||||
|
|
||||||
|
if (! $latestAttempt instanceof BaselineSnapshot) {
|
||||||
|
return 'No capture attempts yet';
|
||||||
|
}
|
||||||
|
|
||||||
|
$effectiveSnapshot = self::effectiveSnapshot($profile);
|
||||||
|
|
||||||
|
if ($effectiveSnapshot instanceof BaselineSnapshot && (int) $effectiveSnapshot->getKey() === (int) $latestAttempt->getKey()) {
|
||||||
|
return 'Matches current snapshot';
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::snapshotReference($latestAttempt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function latestAttemptedSnapshotDescription(BaselineProfile $profile): ?string
|
||||||
|
{
|
||||||
|
$latestAttempt = self::latestAttemptedSnapshot($profile);
|
||||||
|
|
||||||
|
if (! $latestAttempt instanceof BaselineSnapshot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effectiveSnapshot = self::effectiveSnapshot($profile);
|
||||||
|
|
||||||
|
if ($effectiveSnapshot instanceof BaselineSnapshot && (int) $effectiveSnapshot->getKey() === (int) $latestAttempt->getKey()) {
|
||||||
|
return 'No newer attempt is pending.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $latestAttempt->captured_at?->toDayDateTimeString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function compareReadinessLabel(BaselineProfile $profile): string
|
||||||
|
{
|
||||||
|
return self::compareAvailabilityEnvelope($profile)?->operatorLabel ?? 'Ready';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function compareReadinessColor(BaselineProfile $profile): string
|
||||||
|
{
|
||||||
|
return match (self::compareAvailabilityReason($profile)) {
|
||||||
|
null => 'success',
|
||||||
|
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'gray',
|
||||||
|
default => 'warning',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function compareReadinessIcon(BaselineProfile $profile): ?string
|
||||||
|
{
|
||||||
|
return match (self::compareAvailabilityReason($profile)) {
|
||||||
|
null => 'heroicon-m-check-badge',
|
||||||
|
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'heroicon-m-pause-circle',
|
||||||
|
default => 'heroicon-m-exclamation-triangle',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function profileNextStep(BaselineProfile $profile): string
|
||||||
|
{
|
||||||
|
return self::compareAvailabilityEnvelope($profile)?->guidanceText() ?? 'No action needed.';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function effectiveSnapshot(BaselineProfile $profile): ?BaselineSnapshot
|
||||||
|
{
|
||||||
|
return app(BaselineSnapshotTruthResolver::class)->resolveEffectiveSnapshot($profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function latestAttemptedSnapshot(BaselineProfile $profile): ?BaselineSnapshot
|
||||||
|
{
|
||||||
|
return app(BaselineSnapshotTruthResolver::class)->resolveLatestAttemptedSnapshot($profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function compareAvailabilityReason(BaselineProfile $profile): ?string
|
||||||
|
{
|
||||||
|
$status = $profile->status instanceof BaselineProfileStatus
|
||||||
|
? $profile->status
|
||||||
|
: BaselineProfileStatus::tryFrom((string) $profile->status);
|
||||||
|
|
||||||
|
if ($status !== BaselineProfileStatus::Active) {
|
||||||
|
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolution = app(BaselineSnapshotTruthResolver::class)->resolveCompareSnapshot($profile);
|
||||||
|
$reasonCode = $resolution['reason_code'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($reasonCode) && trim($reasonCode) !== '') {
|
||||||
|
return trim($reasonCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! self::hasEligibleCompareTarget($profile)) {
|
||||||
|
return BaselineReasonCodes::COMPARE_NO_ELIGIBLE_TARGET;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function compareAvailabilityEnvelope(BaselineProfile $profile): ?ReasonResolutionEnvelope
|
||||||
|
{
|
||||||
|
$reasonCode = self::compareAvailabilityReason($profile);
|
||||||
|
|
||||||
|
if (! is_string($reasonCode)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(ReasonPresenter::class)->forArtifactTruth($reasonCode, 'artifact_truth');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function snapshotReference(BaselineSnapshot $snapshot): string
|
||||||
|
{
|
||||||
|
$lifecycleLabel = BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value)->label;
|
||||||
|
|
||||||
|
return sprintf('Snapshot #%d (%s)', (int) $snapshot->getKey(), $lifecycleLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function hasEligibleCompareTarget(BaselineProfile $profile): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantIds = BaselineTenantAssignment::query()
|
||||||
|
->where('workspace_id', (int) $profile->workspace_id)
|
||||||
|
->where('baseline_profile_id', (int) $profile->getKey())
|
||||||
|
->pluck('tenant_id')
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if ($tenantIds === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return Tenant::query()
|
||||||
|
->where('workspace_id', (int) $profile->workspace_id)
|
||||||
|
->whereIn('id', $tenantIds)
|
||||||
|
->get(['id'])
|
||||||
|
->contains(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Support\Audit\AuditActionId;
|
use App\Support\Audit\AuditActionId;
|
||||||
use App\Support\Baselines\BaselineProfileStatus;
|
use App\Support\Baselines\BaselineProfileStatus;
|
||||||
|
use App\Support\Baselines\BaselineScope;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
@ -30,12 +31,7 @@ protected function mutateFormDataBeforeCreate(array $data): array
|
|||||||
$data['created_by_user_id'] = $user instanceof User ? $user->getKey() : null;
|
$data['created_by_user_id'] = $user instanceof User ? $user->getKey() : null;
|
||||||
|
|
||||||
if (isset($data['scope_jsonb'])) {
|
if (isset($data['scope_jsonb'])) {
|
||||||
$policyTypes = $data['scope_jsonb']['policy_types'] ?? [];
|
$data['scope_jsonb'] = BaselineScope::fromJsonb(is_array($data['scope_jsonb']) ? $data['scope_jsonb'] : null)->toJsonb();
|
||||||
$foundationTypes = $data['scope_jsonb']['foundation_types'] ?? [];
|
|
||||||
$data['scope_jsonb'] = [
|
|
||||||
'policy_types' => is_array($policyTypes) ? array_values(array_filter($policyTypes, 'is_string')) : [],
|
|
||||||
'foundation_types' => is_array($foundationTypes) ? array_values(array_filter($foundationTypes, 'is_string')) : [],
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
use App\Support\Audit\AuditActionId;
|
use App\Support\Audit\AuditActionId;
|
||||||
use App\Support\Baselines\BaselineProfileStatus;
|
use App\Support\Baselines\BaselineProfileStatus;
|
||||||
|
use App\Support\Baselines\BaselineScope;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
@ -51,12 +52,7 @@ protected function mutateFormDataBeforeSave(array $data): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isset($data['scope_jsonb'])) {
|
if (isset($data['scope_jsonb'])) {
|
||||||
$policyTypes = $data['scope_jsonb']['policy_types'] ?? [];
|
$data['scope_jsonb'] = BaselineScope::fromJsonb(is_array($data['scope_jsonb']) ? $data['scope_jsonb'] : null)->toJsonb();
|
||||||
$foundationTypes = $data['scope_jsonb']['foundation_types'] ?? [];
|
|
||||||
$data['scope_jsonb'] = [
|
|
||||||
'policy_types' => is_array($policyTypes) ? array_values(array_filter($policyTypes, 'is_string')) : [],
|
|
||||||
'foundation_types' => is_array($foundationTypes) ? array_values(array_filter($foundationTypes, 'is_string')) : [],
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
|
|||||||
@ -21,4 +21,13 @@ protected function getHeaderActions(): array
|
|||||||
->visible(fn (): bool => $this->getTableRecords()->count() > 0),
|
->visible(fn (): bool => $this->getTableRecords()->count() > 0),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function getTableEmptyStateActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CreateAction::make()
|
||||||
|
->label('Create baseline profile')
|
||||||
|
->disabled(fn (): bool => ! BaselineProfileResource::canCreate()),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,9 @@
|
|||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||||
|
use App\Support\Navigation\RelatedContextEntry;
|
||||||
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
@ -34,6 +37,11 @@ class ViewBaselineProfile extends ViewRecord
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
Action::make('view_active_snapshot')
|
||||||
|
->label(fn (): string => $this->activeSnapshotEntry()?->actionLabel ?? 'View snapshot')
|
||||||
|
->url(fn (): ?string => $this->activeSnapshotEntry()?->targetUrl)
|
||||||
|
->hidden(fn (): bool => ! ($this->activeSnapshotEntry()?->isAvailable() ?? false))
|
||||||
|
->color('gray'),
|
||||||
$this->captureAction(),
|
$this->captureAction(),
|
||||||
$this->compareNowAction(),
|
$this->compareNowAction(),
|
||||||
EditAction::make()
|
EditAction::make()
|
||||||
@ -41,6 +49,12 @@ protected function getHeaderActions(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function activeSnapshotEntry(): ?RelatedContextEntry
|
||||||
|
{
|
||||||
|
return app(RelatedNavigationResolver::class)
|
||||||
|
->headerEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_PROFILE, $this->getRecord())[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
private function captureAction(): Action
|
private function captureAction(): Action
|
||||||
{
|
{
|
||||||
/** @var BaselineProfile $profile */
|
/** @var BaselineProfile $profile */
|
||||||
@ -169,7 +183,7 @@ private function compareNowAction(): Action
|
|||||||
|
|
||||||
$modalDescription = $captureMode === BaselineCaptureMode::FullContent
|
$modalDescription = $captureMode === BaselineCaptureMode::FullContent
|
||||||
? 'Select the target tenant. This will refresh content evidence on demand (redacted) before comparing.'
|
? 'Select the target tenant. This will refresh content evidence on demand (redacted) before comparing.'
|
||||||
: 'Select the target tenant to compare its current inventory against the active baseline snapshot.';
|
: 'Select the target tenant to compare its current inventory against the effective current baseline snapshot.';
|
||||||
|
|
||||||
return Action::make('compareNow')
|
return Action::make('compareNow')
|
||||||
->label($label)
|
->label($label)
|
||||||
@ -184,7 +198,7 @@ private function compareNowAction(): Action
|
|||||||
->required()
|
->required()
|
||||||
->searchable(),
|
->searchable(),
|
||||||
])
|
])
|
||||||
->disabled(fn (): bool => $this->getEligibleCompareTenantOptions() === [])
|
->disabled(fn (): bool => $this->getEligibleCompareTenantOptions() === [] || ! $this->profileHasConsumableSnapshot())
|
||||||
->action(function (array $data): void {
|
->action(function (array $data): void {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -242,7 +256,11 @@ private function compareNowAction(): Action
|
|||||||
$message = match ($reasonCode) {
|
$message = match ($reasonCode) {
|
||||||
BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
|
BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
|
||||||
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.',
|
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.',
|
||||||
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT => 'This baseline profile has no active snapshot.',
|
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
|
||||||
|
BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'This baseline profile has no complete snapshot available for compare yet.',
|
||||||
|
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
|
||||||
|
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
|
||||||
|
BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete snapshot is current. Compare uses the latest complete baseline only.',
|
||||||
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
|
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -381,4 +399,12 @@ private function hasManageCapability(): bool
|
|||||||
return $resolver->isMember($user, $workspace)
|
return $resolver->isMember($user, $workspace)
|
||||||
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function profileHasConsumableSnapshot(): bool
|
||||||
|
{
|
||||||
|
/** @var BaselineProfile $profile */
|
||||||
|
$profile = $this->getRecord();
|
||||||
|
|
||||||
|
return $profile->resolveCurrentConsumableSnapshot() !== null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,6 +29,11 @@ class BaselineTenantAssignmentsRelationManager extends RelationManager
|
|||||||
|
|
||||||
protected static ?string $title = 'Tenant assignments';
|
protected static ?string $title = 'Tenant assignments';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<int, array{baseline_profile_id:int, baseline_profile_name:string}>|null
|
||||||
|
*/
|
||||||
|
protected ?array $tenantAssignmentSummaries = null;
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||||
@ -42,6 +47,8 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
|
->defaultSort('created_at', 'desc')
|
||||||
|
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('tenant.name')
|
Tables\Columns\TextColumn::make('tenant.name')
|
||||||
->label('Tenant')
|
->label('Tenant')
|
||||||
@ -77,7 +84,8 @@ private function assignTenantAction(): Action
|
|||||||
->form([
|
->form([
|
||||||
Select::make('tenant_id')
|
Select::make('tenant_id')
|
||||||
->label('Tenant')
|
->label('Tenant')
|
||||||
->options(fn (): array => $this->getAvailableTenantOptions())
|
->options(fn (): array => $this->getTenantOptions())
|
||||||
|
->disableOptionWhen(fn (string $value): bool => array_key_exists((int) $value, $this->getTenantAssignmentSummaries()))
|
||||||
->required()
|
->required()
|
||||||
->searchable(),
|
->searchable(),
|
||||||
])
|
])
|
||||||
@ -103,9 +111,13 @@ private function assignTenantAction(): Action
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($existing instanceof BaselineTenantAssignment) {
|
if ($existing instanceof BaselineTenantAssignment) {
|
||||||
|
$assignedBaselineName = $this->getAssignedBaselineNameForTenant($tenantId);
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Tenant already assigned')
|
->title('Tenant already assigned')
|
||||||
->body('This tenant already has a baseline assignment in this workspace.')
|
->body($assignedBaselineName === null
|
||||||
|
? 'This tenant already has a baseline assignment in this workspace.'
|
||||||
|
: "This tenant is already assigned to baseline: {$assignedBaselineName}.")
|
||||||
->warning()
|
->warning()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
@ -125,6 +137,8 @@ private function assignTenantAction(): Action
|
|||||||
->title('Tenant assigned')
|
->title('Tenant assigned')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
|
$this->forgetTenantAssignmentSummaries();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -161,31 +175,99 @@ private function removeAssignmentAction(): Action
|
|||||||
->title('Assignment removed')
|
->title('Assignment removed')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
|
$this->forgetTenantAssignmentSummaries();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, string>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
private function getAvailableTenantOptions(): array
|
private function getTenantOptions(): array
|
||||||
{
|
{
|
||||||
/** @var BaselineProfile $profile */
|
/** @var BaselineProfile $profile */
|
||||||
$profile = $this->getOwnerRecord();
|
$profile = $this->getOwnerRecord();
|
||||||
|
|
||||||
$assignedTenantIds = BaselineTenantAssignment::query()
|
$assignmentSummaries = $this->getTenantAssignmentSummaries();
|
||||||
|
|
||||||
|
return Tenant::query()
|
||||||
->where('workspace_id', $profile->workspace_id)
|
->where('workspace_id', $profile->workspace_id)
|
||||||
->pluck('tenant_id')
|
->orderBy('name')
|
||||||
|
->get(['id', 'name'])
|
||||||
|
->mapWithKeys(function (Tenant $tenant) use ($assignmentSummaries): array {
|
||||||
|
$tenantId = (int) $tenant->getKey();
|
||||||
|
$assignmentSummary = $assignmentSummaries[$tenantId] ?? null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
$tenantId => $this->formatTenantOptionLabel($tenant, $assignmentSummary),
|
||||||
|
];
|
||||||
|
})
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
$query = Tenant::query()
|
|
||||||
->where('workspace_id', $profile->workspace_id)
|
|
||||||
->orderBy('name');
|
|
||||||
|
|
||||||
if (! empty($assignedTenantIds)) {
|
|
||||||
$query->whereNotIn('id', $assignedTenantIds);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $query->pluck('name', 'id')->all();
|
/**
|
||||||
|
* @return array<int, array{baseline_profile_id:int, baseline_profile_name:string}>
|
||||||
|
*/
|
||||||
|
private function getTenantAssignmentSummaries(): array
|
||||||
|
{
|
||||||
|
if (is_array($this->tenantAssignmentSummaries)) {
|
||||||
|
return $this->tenantAssignmentSummaries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var BaselineProfile $profile */
|
||||||
|
$profile = $this->getOwnerRecord();
|
||||||
|
|
||||||
|
$this->tenantAssignmentSummaries = BaselineTenantAssignment::query()
|
||||||
|
->where('workspace_id', (int) $profile->workspace_id)
|
||||||
|
->with('baselineProfile:id,name')
|
||||||
|
->get(['tenant_id', 'baseline_profile_id'])
|
||||||
|
->mapWithKeys(function (BaselineTenantAssignment $assignment): array {
|
||||||
|
$baselineProfile = $assignment->baselineProfile;
|
||||||
|
|
||||||
|
return [
|
||||||
|
(int) $assignment->tenant_id => [
|
||||||
|
'baseline_profile_id' => (int) $assignment->baseline_profile_id,
|
||||||
|
'baseline_profile_name' => $baselineProfile instanceof BaselineProfile
|
||||||
|
? (string) $baselineProfile->name
|
||||||
|
: 'another baseline profile',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return $this->tenantAssignmentSummaries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{baseline_profile_id:int, baseline_profile_name:string}|null $assignmentSummary
|
||||||
|
*/
|
||||||
|
private function formatTenantOptionLabel(
|
||||||
|
Tenant $tenant,
|
||||||
|
?array $assignmentSummary,
|
||||||
|
): string {
|
||||||
|
$tenantName = (string) $tenant->name;
|
||||||
|
|
||||||
|
if ($assignmentSummary === null) {
|
||||||
|
return $tenantName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "{$tenantName} (assigned to baseline: {$assignmentSummary['baseline_profile_name']})";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getAssignedBaselineNameForTenant(int $tenantId): ?string
|
||||||
|
{
|
||||||
|
$assignmentSummary = $this->getTenantAssignmentSummaries()[$tenantId] ?? null;
|
||||||
|
|
||||||
|
if ($assignmentSummary === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $assignmentSummary['baseline_profile_name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function forgetTenantAssignmentSummaries(): void
|
||||||
|
{
|
||||||
|
$this->tenantAssignmentSummaries = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function auditAssignment(
|
private function auditAssignment(
|
||||||
|
|||||||
@ -5,24 +5,34 @@
|
|||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Filament\Resources\BaselineSnapshotResource\Pages;
|
use App\Filament\Resources\BaselineSnapshotResource\Pages;
|
||||||
|
use App\Models\BaselineProfile;
|
||||||
use App\Models\BaselineSnapshot;
|
use App\Models\BaselineSnapshot;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
||||||
|
use App\Services\Baselines\SnapshotRendering\FidelityState;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||||
|
use App\Support\Filament\FilterPresets;
|
||||||
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||||
|
use App\Support\Navigation\RelatedContextEntry;
|
||||||
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\ViewAction;
|
use Filament\Actions\Action;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Infolists\Components\TextEntry;
|
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@ -94,18 +104,29 @@ public static function canDelete(Model $record): bool
|
|||||||
|
|
||||||
public static function canView(Model $record): bool
|
public static function canView(Model $record): bool
|
||||||
{
|
{
|
||||||
return self::canViewAny();
|
if (! $record instanceof BaselineSnapshot) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = self::resolveWorkspace();
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::canViewAny()
|
||||||
|
&& (int) $record->workspace_id === (int) $workspace->getKey();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
->exempt(ActionSurfaceSlot::ListHeader, 'Snapshots are created by capture runs; no list-header actions.')
|
->exempt(ActionSurfaceSlot::ListHeader, 'Snapshots are created by capture runs; no list-header actions.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Snapshots are immutable; no row actions besides view.')
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Snapshots are immutable; rows navigate directly to the detail page.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Snapshots are immutable; no bulk actions.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Snapshots are immutable; no bulk actions.')
|
||||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; snapshots appear after baseline captures.')
|
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; snapshots appear after baseline captures.')
|
||||||
->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational and currently has no header actions.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Primary related navigation is surfaced in the view header.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
@ -133,6 +154,10 @@ public static function table(Table $table): Table
|
|||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
->defaultSort('captured_at', 'desc')
|
->defaultSort('captured_at', 'desc')
|
||||||
|
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
|
||||||
|
->persistFiltersInSession()
|
||||||
|
->persistSearchInSession()
|
||||||
|
->persistSortInSession()
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('id')
|
TextColumn::make('id')
|
||||||
->label('Snapshot')
|
->label('Snapshot')
|
||||||
@ -141,69 +166,87 @@ public static function table(Table $table): Table
|
|||||||
TextColumn::make('baselineProfile.name')
|
TextColumn::make('baselineProfile.name')
|
||||||
->label('Baseline')
|
->label('Baseline')
|
||||||
->wrap()
|
->wrap()
|
||||||
|
->searchable()
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
TextColumn::make('captured_at')
|
TextColumn::make('captured_at')
|
||||||
->label('Captured')
|
->label('Captured')
|
||||||
->since()
|
->since()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
|
TextColumn::make('artifact_truth')
|
||||||
|
->label('Artifact truth')
|
||||||
|
->badge()
|
||||||
|
->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->primaryLabel)
|
||||||
|
->color(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->primaryBadgeSpec()->color)
|
||||||
|
->icon(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
||||||
|
->iconColor(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
||||||
|
->description(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->operatorExplanation?->headline ?? self::truthEnvelope($record)->primaryExplanation)
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('lifecycle_state')
|
||||||
|
->label('Lifecycle')
|
||||||
|
->badge()
|
||||||
|
->getStateUsing(static fn (BaselineSnapshot $record): string => self::lifecycleSpec($record)->label)
|
||||||
|
->color(static fn (BaselineSnapshot $record): string => self::lifecycleSpec($record)->color)
|
||||||
|
->icon(static fn (BaselineSnapshot $record): ?string => self::lifecycleSpec($record)->icon)
|
||||||
|
->iconColor(static fn (BaselineSnapshot $record): ?string => self::lifecycleSpec($record)->iconColor)
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('current_truth')
|
||||||
|
->label('Current truth')
|
||||||
|
->badge()
|
||||||
|
->getStateUsing(static fn (BaselineSnapshot $record): string => self::currentTruthLabel($record))
|
||||||
|
->color(static fn (BaselineSnapshot $record): string => self::currentTruthColor($record))
|
||||||
|
->icon(static fn (BaselineSnapshot $record): ?string => self::currentTruthIcon($record))
|
||||||
|
->description(static fn (BaselineSnapshot $record): ?string => self::currentTruthDescription($record))
|
||||||
|
->wrap(),
|
||||||
TextColumn::make('fidelity_summary')
|
TextColumn::make('fidelity_summary')
|
||||||
->label('Fidelity')
|
->label('Fidelity')
|
||||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record))
|
->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record))
|
||||||
->wrap(),
|
->wrap(),
|
||||||
TextColumn::make('snapshot_state')
|
TextColumn::make('artifact_next_step')
|
||||||
->label('State')
|
->label('Next step')
|
||||||
->badge()
|
->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->operatorExplanation?->nextActionText ?? self::truthEnvelope($record)->nextStepText())
|
||||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::stateLabel($record))
|
->wrap(),
|
||||||
->color(static fn (BaselineSnapshot $record): string => self::hasGaps($record) ? 'warning' : 'success'),
|
])
|
||||||
|
->recordUrl(static fn (BaselineSnapshot $record): ?string => static::canView($record)
|
||||||
|
? static::getUrl('view', ['record' => $record])
|
||||||
|
: null)
|
||||||
|
->filters([
|
||||||
|
SelectFilter::make('baseline_profile_id')
|
||||||
|
->label('Baseline')
|
||||||
|
->options(static::baselineProfileOptions())
|
||||||
|
->searchable(),
|
||||||
|
SelectFilter::make('lifecycle_state')
|
||||||
|
->label('Lifecycle')
|
||||||
|
->options(static::lifecycleOptions())
|
||||||
|
->query(fn (Builder $query, array $data): Builder => static::applyLifecycleFilter($query, $data['value'] ?? null)),
|
||||||
|
FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
ViewAction::make()->label('View'),
|
static::primaryRelatedAction(),
|
||||||
])
|
])
|
||||||
->bulkActions([]);
|
->bulkActions([])
|
||||||
|
->emptyStateHeading('No baseline snapshots')
|
||||||
|
->emptyStateDescription('Capture a baseline snapshot to review evidence fidelity and compare tenants over time.')
|
||||||
|
->emptyStateIcon('heroicon-o-camera');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function infolist(Schema $schema): Schema
|
public static function infolist(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema
|
return $schema;
|
||||||
->schema([
|
}
|
||||||
Section::make('Snapshot')
|
|
||||||
->schema([
|
private static function primaryRelatedAction(): Action
|
||||||
TextEntry::make('id')
|
{
|
||||||
->label('Snapshot')
|
return Action::make('primary_drill_down')
|
||||||
->formatStateUsing(static fn (?int $state): string => $state ? '#'.$state : '—'),
|
->label(fn (BaselineSnapshot $record): string => static::primaryRelatedEntry($record)?->actionLabel ?? 'Open related record')
|
||||||
TextEntry::make('baselineProfile.name')
|
->url(fn (BaselineSnapshot $record): ?string => static::primaryRelatedEntry($record)?->targetUrl)
|
||||||
->label('Baseline'),
|
->hidden(fn (BaselineSnapshot $record): bool => ! (static::primaryRelatedEntry($record)?->isAvailable() ?? false))
|
||||||
TextEntry::make('captured_at')
|
->color('gray');
|
||||||
->label('Captured')
|
}
|
||||||
->dateTime(),
|
|
||||||
TextEntry::make('snapshot_state')
|
private static function primaryRelatedEntry(BaselineSnapshot $record): ?RelatedContextEntry
|
||||||
->label('State')
|
{
|
||||||
->badge()
|
return app(RelatedNavigationResolver::class)
|
||||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::stateLabel($record))
|
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_BASELINE_SNAPSHOT, $record);
|
||||||
->color(static fn (BaselineSnapshot $record): string => self::hasGaps($record) ? 'warning' : 'success'),
|
|
||||||
TextEntry::make('fidelity_summary')
|
|
||||||
->label('Fidelity')
|
|
||||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record)),
|
|
||||||
TextEntry::make('evidence_gaps')
|
|
||||||
->label('Evidence gaps')
|
|
||||||
->getStateUsing(static fn (BaselineSnapshot $record): int => self::gapsCount($record)),
|
|
||||||
TextEntry::make('snapshot_identity_hash')
|
|
||||||
->label('Identity hash')
|
|
||||||
->copyable()
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columns(2)
|
|
||||||
->columnSpanFull(),
|
|
||||||
Section::make('Summary')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('summary_jsonb')
|
|
||||||
->label('')
|
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
|
||||||
->state(static fn (BaselineSnapshot $record): array => is_array($record->summary_jsonb) ? $record->summary_jsonb : [])
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columnSpanFull(),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
@ -214,7 +257,33 @@ public static function getPages(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function resolveWorkspace(): ?Workspace
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private static function baselineProfileOptions(): array
|
||||||
|
{
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
if ($workspaceId === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return BaselineProfile::query()
|
||||||
|
->where('workspace_id', (int) $workspaceId)
|
||||||
|
->orderBy('name')
|
||||||
|
->pluck('name', 'id')
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private static function lifecycleOptions(): array
|
||||||
|
{
|
||||||
|
return BadgeCatalog::options(BadgeDomain::BaselineSnapshotLifecycle, BaselineSnapshotLifecycleState::values());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function resolveWorkspace(): ?Workspace
|
||||||
{
|
{
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
@ -249,7 +318,13 @@ private static function fidelitySummary(BaselineSnapshot $snapshot): string
|
|||||||
{
|
{
|
||||||
$counts = self::fidelityCounts($snapshot);
|
$counts = self::fidelityCounts($snapshot);
|
||||||
|
|
||||||
return sprintf('Content %d, Meta %d', (int) ($counts['content'] ?? 0), (int) ($counts['meta'] ?? 0));
|
return sprintf(
|
||||||
|
'%s %d, %s %d',
|
||||||
|
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotFidelity, FidelityState::Full->value)->label,
|
||||||
|
(int) ($counts['content'] ?? 0),
|
||||||
|
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotFidelity, FidelityState::ReferenceOnly->value)->label,
|
||||||
|
(int) ($counts['meta'] ?? 0),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function gapsCount(BaselineSnapshot $snapshot): int
|
private static function gapsCount(BaselineSnapshot $snapshot): int
|
||||||
@ -257,6 +332,17 @@ private static function gapsCount(BaselineSnapshot $snapshot): int
|
|||||||
$summary = self::summary($snapshot);
|
$summary = self::summary($snapshot);
|
||||||
$gaps = $summary['gaps'] ?? null;
|
$gaps = $summary['gaps'] ?? null;
|
||||||
$gaps = is_array($gaps) ? $gaps : [];
|
$gaps = is_array($gaps) ? $gaps : [];
|
||||||
|
$byReason = is_array($gaps['by_reason'] ?? null) ? $gaps['by_reason'] : [];
|
||||||
|
|
||||||
|
if ($byReason !== []) {
|
||||||
|
return array_sum(array_map(
|
||||||
|
static fn (mixed $count, string $reason): int => in_array($reason, ['meta_fallback'], true) || ! is_numeric($count)
|
||||||
|
? 0
|
||||||
|
: (int) $count,
|
||||||
|
$byReason,
|
||||||
|
array_keys($byReason),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
$count = $gaps['count'] ?? 0;
|
$count = $gaps['count'] ?? 0;
|
||||||
|
|
||||||
@ -268,8 +354,90 @@ private static function hasGaps(BaselineSnapshot $snapshot): bool
|
|||||||
return self::gapsCount($snapshot) > 0;
|
return self::gapsCount($snapshot) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function stateLabel(BaselineSnapshot $snapshot): string
|
private static function lifecycleSpec(BaselineSnapshot $snapshot): \App\Support\Badges\BadgeSpec
|
||||||
{
|
{
|
||||||
return self::hasGaps($snapshot) ? 'Captured with gaps' : 'Complete';
|
return BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function applyLifecycleFilter(Builder $query, mixed $value): Builder
|
||||||
|
{
|
||||||
|
if (! is_string($value) || trim($value) === '') {
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->where('lifecycle_state', trim($value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function gapCountExpression(Builder $query): string
|
||||||
|
{
|
||||||
|
return match ($query->getConnection()->getDriverName()) {
|
||||||
|
'sqlite' => "MAX(0, COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.count') AS INTEGER), 0) - COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.meta_fallback') AS INTEGER), 0))",
|
||||||
|
'pgsql' => "GREATEST(0, COALESCE((summary_jsonb #>> '{gaps,count}')::int, 0) - COALESCE((summary_jsonb #>> '{gaps,by_reason,meta_fallback}')::int, 0))",
|
||||||
|
default => "GREATEST(0, COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.count') AS SIGNED), 0) - COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.meta_fallback') AS SIGNED), 0))",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function gapSpec(BaselineSnapshot $snapshot): \App\Support\Badges\BadgeSpec
|
||||||
|
{
|
||||||
|
return BadgeCatalog::spec(
|
||||||
|
BadgeDomain::BaselineSnapshotGapStatus,
|
||||||
|
self::hasGaps($snapshot) ? 'gaps_present' : 'clear',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function truthEnvelope(BaselineSnapshot $snapshot, bool $fresh = false): ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? $presenter->forBaselineSnapshotFresh($snapshot)
|
||||||
|
: $presenter->forBaselineSnapshot($snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function currentTruthLabel(BaselineSnapshot $snapshot): string
|
||||||
|
{
|
||||||
|
return match (self::currentTruthState($snapshot)) {
|
||||||
|
'current' => 'Current baseline',
|
||||||
|
'historical' => 'Historical trace',
|
||||||
|
default => 'Not compare input',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function currentTruthDescription(BaselineSnapshot $snapshot): ?string
|
||||||
|
{
|
||||||
|
return match (self::currentTruthState($snapshot)) {
|
||||||
|
'current' => 'Compare resolves to this snapshot as the current baseline truth.',
|
||||||
|
'historical' => 'A newer complete snapshot is now the current baseline truth for this profile.',
|
||||||
|
default => self::truthEnvelope($snapshot)->primaryExplanation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function currentTruthColor(BaselineSnapshot $snapshot): string
|
||||||
|
{
|
||||||
|
return match (self::currentTruthState($snapshot)) {
|
||||||
|
'current' => 'success',
|
||||||
|
'historical' => 'gray',
|
||||||
|
default => 'warning',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function currentTruthIcon(BaselineSnapshot $snapshot): ?string
|
||||||
|
{
|
||||||
|
return match (self::currentTruthState($snapshot)) {
|
||||||
|
'current' => 'heroicon-m-check-badge',
|
||||||
|
'historical' => 'heroicon-m-clock',
|
||||||
|
default => 'heroicon-m-exclamation-triangle',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function currentTruthState(BaselineSnapshot $snapshot): string
|
||||||
|
{
|
||||||
|
if (! $snapshot->isConsumable()) {
|
||||||
|
return 'unusable';
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(BaselineSnapshotTruthResolver::class)->isHistoricallySuperseded($snapshot)
|
||||||
|
? 'historical'
|
||||||
|
: 'current';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,12 +5,37 @@
|
|||||||
namespace App\Filament\Resources\BaselineSnapshotResource\Pages;
|
namespace App\Filament\Resources\BaselineSnapshotResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\BaselineSnapshotResource;
|
use App\Filament\Resources\BaselineSnapshotResource;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
class ListBaselineSnapshots extends ListRecords
|
class ListBaselineSnapshots extends ListRecords
|
||||||
{
|
{
|
||||||
protected static string $resource = BaselineSnapshotResource::class;
|
protected static string $resource = BaselineSnapshotResource::class;
|
||||||
|
|
||||||
|
protected function authorizeAccess(): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
$workspace = BaselineSnapshotResource::resolveWorkspace();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var WorkspaceCapabilityResolver $resolver */
|
||||||
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $workspace)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@ -5,14 +5,99 @@
|
|||||||
namespace App\Filament\Resources\BaselineSnapshotResource\Pages;
|
namespace App\Filament\Resources\BaselineSnapshotResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\BaselineSnapshotResource;
|
use App\Filament\Resources\BaselineSnapshotResource;
|
||||||
|
use App\Models\BaselineSnapshot;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
|
use App\Services\Baselines\SnapshotRendering\BaselineSnapshotPresenter;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||||
|
use App\Support\Navigation\RelatedContextEntry;
|
||||||
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
class ViewBaselineSnapshot extends ViewRecord
|
class ViewBaselineSnapshot extends ViewRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = BaselineSnapshotResource::class;
|
protected static string $resource = BaselineSnapshotResource::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, mixed>
|
||||||
|
*/
|
||||||
|
public array $enterpriseDetail = [];
|
||||||
|
|
||||||
|
public function mount(int|string $record): void
|
||||||
|
{
|
||||||
|
parent::mount($record);
|
||||||
|
|
||||||
|
$snapshot = $this->getRecord();
|
||||||
|
|
||||||
|
if ($snapshot instanceof BaselineSnapshot) {
|
||||||
|
$snapshot->loadMissing(['baselineProfile', 'items']);
|
||||||
|
|
||||||
|
$relatedContext = app(RelatedNavigationResolver::class)
|
||||||
|
->detailEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_SNAPSHOT, $snapshot);
|
||||||
|
|
||||||
|
$this->enterpriseDetail = app(BaselineSnapshotPresenter::class)
|
||||||
|
->presentEnterpriseDetail($snapshot, $relatedContext)
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeAccess(): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
$snapshot = $this->getRecord();
|
||||||
|
$workspace = BaselineSnapshotResource::resolveWorkspace();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $snapshot instanceof BaselineSnapshot || ! $workspace instanceof Workspace) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $snapshot->workspace_id !== (int) $workspace->getKey()) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var WorkspaceCapabilityResolver $resolver */
|
||||||
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $workspace)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function infolist(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->schema([
|
||||||
|
ViewEntry::make('enterprise_detail')
|
||||||
|
->label('')
|
||||||
|
->view('filament.infolists.entries.enterprise-detail.layout')
|
||||||
|
->state(fn (): array => $this->enterpriseDetail)
|
||||||
|
->columnSpanFull(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [];
|
return [
|
||||||
|
Action::make('primary_related')
|
||||||
|
->label(fn (): string => $this->primaryRelatedEntry()?->actionLabel ?? 'Open related record')
|
||||||
|
->url(fn (): ?string => $this->primaryRelatedEntry()?->targetUrl)
|
||||||
|
->hidden(fn (): bool => ! ($this->primaryRelatedEntry()?->isAvailable() ?? false))
|
||||||
|
->color('gray'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function primaryRelatedEntry(): ?RelatedContextEntry
|
||||||
|
{
|
||||||
|
return app(RelatedNavigationResolver::class)
|
||||||
|
->headerEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_SNAPSHOT, $this->getRecord())[0] ?? null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,47 +2,74 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||||
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
|
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
||||||
use App\Filament\Resources\EntraGroupResource\Pages;
|
use App\Filament\Resources\EntraGroupResource\Pages;
|
||||||
use App\Models\EntraGroup;
|
use App\Models\EntraGroup;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder;
|
||||||
|
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
|
||||||
|
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
||||||
|
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class EntraGroupResource extends Resource
|
class EntraGroupResource extends Resource
|
||||||
{
|
{
|
||||||
|
use InteractsWithTenantOwnedRecords;
|
||||||
|
use ResolvesPanelTenantContext;
|
||||||
|
use ScopesGlobalSearchToTenant;
|
||||||
|
|
||||||
protected static bool $isScopedToTenant = false;
|
protected static bool $isScopedToTenant = false;
|
||||||
|
|
||||||
protected static ?string $model = EntraGroup::class;
|
protected static ?string $model = EntraGroup::class;
|
||||||
|
|
||||||
|
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||||
|
|
||||||
|
protected static ?string $recordTitleAttribute = 'display_name';
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-user-group';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-user-group';
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Directory';
|
protected static string|UnitEnum|null $navigationGroup = 'Directory';
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Groups';
|
protected static ?string $navigationLabel = 'Groups';
|
||||||
|
|
||||||
|
public static function shouldRegisterNavigation(): bool
|
||||||
|
{
|
||||||
|
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::shouldRegisterNavigation();
|
||||||
|
}
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
->exempt(ActionSurfaceSlot::ListHeader, 'Directory groups list intentionally has no header actions; sync is started from the sync-runs surface.')
|
->exempt(ActionSurfaceSlot::ListHeader, 'Directory groups list intentionally has no header actions; sync is started from the sync-runs surface.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'No secondary row actions are provided on this read-only list.')
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'No secondary row actions are provided on this read-only list.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for directory groups.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for directory groups.')
|
||||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; groups appear after sync.');
|
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; groups appear after sync.')
|
||||||
|
->exempt(ActionSurfaceSlot::DetailHeader, 'No canonical related destination exists for directory groups yet.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
@ -54,40 +81,10 @@ public static function infolist(Schema $schema): Schema
|
|||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
->schema([
|
->schema([
|
||||||
Section::make('Group')
|
ViewEntry::make('enterprise_detail')
|
||||||
->schema([
|
|
||||||
TextEntry::make('display_name')->label('Name'),
|
|
||||||
TextEntry::make('entra_id')->label('Entra ID')->copyable(),
|
|
||||||
TextEntry::make('type')
|
|
||||||
->badge()
|
|
||||||
->state(fn (EntraGroup $record): string => static::groupTypeLabel(static::groupType($record))),
|
|
||||||
TextEntry::make('security_enabled')
|
|
||||||
->label('Security')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BooleanEnabled))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::BooleanEnabled))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::BooleanEnabled))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BooleanEnabled)),
|
|
||||||
TextEntry::make('mail_enabled')
|
|
||||||
->label('Mail')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BooleanEnabled))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::BooleanEnabled))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::BooleanEnabled))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BooleanEnabled)),
|
|
||||||
TextEntry::make('last_seen_at')->label('Last seen')->dateTime()->placeholder('—'),
|
|
||||||
])
|
|
||||||
->columns(2)
|
|
||||||
->columnSpanFull(),
|
|
||||||
|
|
||||||
Section::make('Raw groupTypes')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('group_types')
|
|
||||||
->label('')
|
->label('')
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
->view('filament.infolists.entries.enterprise-detail.layout')
|
||||||
->state(fn (EntraGroup $record) => $record->group_types ?? [])
|
->state(fn (EntraGroup $record): array => static::enterpriseDetailPage($record)->toArray())
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -96,22 +93,22 @@ public static function table(Table $table): Table
|
|||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
->defaultSort('display_name')
|
->defaultSort('display_name')
|
||||||
->modifyQueryUsing(function (Builder $query): Builder {
|
->paginated(TablePaginationProfiles::resource())
|
||||||
$tenantId = Tenant::current()?->getKey();
|
->persistFiltersInSession()
|
||||||
|
->persistSearchInSession()
|
||||||
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
|
->persistSortInSession()
|
||||||
})
|
|
||||||
->recordUrl(static fn (EntraGroup $record): ?string => static::canView($record)
|
->recordUrl(static fn (EntraGroup $record): ?string => static::canView($record)
|
||||||
? static::getUrl('view', ['record' => $record])
|
? static::scopedUrl('view', ['record' => $record], static::panelTenantContext())
|
||||||
: null)
|
: null)
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('display_name')
|
Tables\Columns\TextColumn::make('display_name')
|
||||||
->label('Name')
|
->label('Name')
|
||||||
->searchable(),
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('entra_id')
|
Tables\Columns\TextColumn::make('entra_id')
|
||||||
->label('Entra ID')
|
->label('Entra ID')
|
||||||
->copyable()
|
->copyable()
|
||||||
->toggleable(),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('type')
|
Tables\Columns\TextColumn::make('type')
|
||||||
->label('Type')
|
->label('Type')
|
||||||
->badge()
|
->badge()
|
||||||
@ -147,7 +144,6 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
return $query->where('last_seen_at', '>=', $cutoff);
|
return $query->where('last_seen_at', '>=', $cutoff);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
SelectFilter::make('group_type')
|
SelectFilter::make('group_type')
|
||||||
->label('Type')
|
->label('Type')
|
||||||
->options([
|
->options([
|
||||||
@ -189,12 +185,30 @@ public static function table(Table $table): Table
|
|||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
->actions([])
|
->actions([])
|
||||||
->bulkActions([]);
|
->bulkActions([])
|
||||||
|
->emptyStateHeading('No groups cached yet')
|
||||||
|
->emptyStateDescription('Sync groups for the current tenant to browse directory data here.')
|
||||||
|
->emptyStateIcon('heroicon-o-user-group');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
return parent::getEloquentQuery()->latest('id');
|
return static::getTenantOwnedEloquentQuery()
|
||||||
|
->latest('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function resolveScopedRecordOrFail(int|string $key): Model
|
||||||
|
{
|
||||||
|
return static::resolveTenantOwnedRecordOrFail($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getGlobalSearchResultUrl(Model $record): string
|
||||||
|
{
|
||||||
|
$tenant = $record instanceof EntraGroup && $record->tenant instanceof Tenant
|
||||||
|
? $record->tenant
|
||||||
|
: static::panelTenantContext();
|
||||||
|
|
||||||
|
return static::scopedUrl('view', ['record' => $record], $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
@ -205,6 +219,20 @@ public static function getPages(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $parameters
|
||||||
|
*/
|
||||||
|
public static function scopedUrl(
|
||||||
|
string $page = 'index',
|
||||||
|
array $parameters = [],
|
||||||
|
?Tenant $tenant = null,
|
||||||
|
?string $panel = null,
|
||||||
|
): string {
|
||||||
|
$panelId = $panel ?? Filament::getCurrentOrDefaultPanel()?->getId() ?? 'admin';
|
||||||
|
|
||||||
|
return static::getUrl($page, $parameters, panel: $panelId, tenant: $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
private static function groupType(EntraGroup $record): string
|
private static function groupType(EntraGroup $record): string
|
||||||
{
|
{
|
||||||
$groupTypes = $record->group_types;
|
$groupTypes = $record->group_types;
|
||||||
@ -243,4 +271,105 @@ private static function groupTypeColor(string $type): string
|
|||||||
default => 'gray',
|
default => 'gray',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function enterpriseDetailPage(EntraGroup $record): EnterpriseDetailPageData
|
||||||
|
{
|
||||||
|
$factory = new EnterpriseDetailSectionFactory;
|
||||||
|
|
||||||
|
$groupType = static::groupType($record);
|
||||||
|
$groupTypeLabel = static::groupTypeLabel($groupType);
|
||||||
|
$groupTypeBadge = $factory->statusBadge($groupTypeLabel, static::groupTypeColor($groupType));
|
||||||
|
$securityBadge = $factory->statusBadge(
|
||||||
|
BadgeRenderer::label(BadgeDomain::BooleanEnabled)($record->security_enabled),
|
||||||
|
BadgeRenderer::color(BadgeDomain::BooleanEnabled)($record->security_enabled),
|
||||||
|
BadgeRenderer::icon(BadgeDomain::BooleanEnabled)($record->security_enabled),
|
||||||
|
BadgeRenderer::iconColor(BadgeDomain::BooleanEnabled)($record->security_enabled),
|
||||||
|
);
|
||||||
|
$mailBadge = $factory->statusBadge(
|
||||||
|
BadgeRenderer::label(BadgeDomain::BooleanEnabled)($record->mail_enabled),
|
||||||
|
BadgeRenderer::color(BadgeDomain::BooleanEnabled)($record->mail_enabled),
|
||||||
|
BadgeRenderer::icon(BadgeDomain::BooleanEnabled)($record->mail_enabled),
|
||||||
|
BadgeRenderer::iconColor(BadgeDomain::BooleanEnabled)($record->mail_enabled),
|
||||||
|
);
|
||||||
|
|
||||||
|
$technicalPayload = [
|
||||||
|
'entra_id' => $record->entra_id,
|
||||||
|
'group_types' => is_array($record->group_types) ? $record->group_types : [],
|
||||||
|
];
|
||||||
|
|
||||||
|
return EnterpriseDetailBuilder::make('entra_group', 'tenant')
|
||||||
|
->header(new SummaryHeaderData(
|
||||||
|
title: (string) $record->display_name,
|
||||||
|
subtitle: 'Directory group #'.$record->getKey(),
|
||||||
|
statusBadges: [$groupTypeBadge, $securityBadge, $mailBadge],
|
||||||
|
keyFacts: [
|
||||||
|
$factory->keyFact('Type', $groupTypeLabel, badge: $groupTypeBadge),
|
||||||
|
$factory->keyFact('Last seen', static::formatDetailTimestamp($record->last_seen_at)),
|
||||||
|
$factory->keyFact('Security enabled', $record->security_enabled, badge: $securityBadge),
|
||||||
|
$factory->keyFact('Mail enabled', $record->mail_enabled, badge: $mailBadge),
|
||||||
|
],
|
||||||
|
descriptionHint: 'Group identity and classification stay ahead of provider-oriented metadata.',
|
||||||
|
))
|
||||||
|
->addSection(
|
||||||
|
$factory->factsSection(
|
||||||
|
id: 'classification_overview',
|
||||||
|
kind: 'core_details',
|
||||||
|
title: 'Classification overview',
|
||||||
|
items: [
|
||||||
|
$factory->keyFact('Type', $groupTypeLabel, badge: $groupTypeBadge),
|
||||||
|
$factory->keyFact('Security enabled', $record->security_enabled, badge: $securityBadge),
|
||||||
|
$factory->keyFact('Mail enabled', $record->mail_enabled, badge: $mailBadge),
|
||||||
|
$factory->keyFact('Last seen', static::formatDetailTimestamp($record->last_seen_at)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
$factory->viewSection(
|
||||||
|
id: 'related_context',
|
||||||
|
kind: 'related_context',
|
||||||
|
title: 'Related context',
|
||||||
|
view: 'filament.infolists.entries.related-context',
|
||||||
|
viewData: ['entries' => []],
|
||||||
|
emptyState: $factory->emptyState('No related context is available for this record.'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
->addSupportingCard(
|
||||||
|
$factory->supportingFactsCard(
|
||||||
|
kind: 'summary',
|
||||||
|
title: 'Directory identity',
|
||||||
|
items: [
|
||||||
|
$factory->keyFact('Display name', $record->display_name),
|
||||||
|
$factory->keyFact('Entra ID', $record->entra_id),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
$factory->supportingFactsCard(
|
||||||
|
kind: 'timestamps',
|
||||||
|
title: 'Freshness',
|
||||||
|
items: [
|
||||||
|
$factory->keyFact('Last seen', static::formatDetailTimestamp($record->last_seen_at)),
|
||||||
|
$factory->keyFact('Cached group types', count($technicalPayload['group_types'])),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
->addTechnicalSection(
|
||||||
|
$factory->technicalDetail(
|
||||||
|
title: 'Technical detail',
|
||||||
|
entries: [
|
||||||
|
$factory->keyFact('Entra ID', $record->entra_id),
|
||||||
|
$factory->keyFact('Cached group types', count($technicalPayload['group_types'])),
|
||||||
|
],
|
||||||
|
description: 'Provider identifiers and raw group-type arrays stay secondary to group identity and classification.',
|
||||||
|
view: 'filament.infolists.entries.snapshot-json',
|
||||||
|
viewData: ['payload' => $technicalPayload],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
->build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function formatDetailTimestamp(mixed $value): string
|
||||||
|
{
|
||||||
|
if (! $value instanceof Carbon) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value->toDayDateTimeString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,25 +9,47 @@
|
|||||||
use App\Services\Directory\EntraGroupSelection;
|
use App\Services\Directory\EntraGroupSelection;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
class ListEntraGroups extends ListRecords
|
class ListEntraGroups extends ListRecords
|
||||||
{
|
{
|
||||||
protected static string $resource = EntraGroupResource::class;
|
protected static string $resource = EntraGroupResource::class;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||||
|
$this->getTableFiltersSessionKey(),
|
||||||
|
request: request(),
|
||||||
|
tenantFilterName: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
Filament::getCurrentPanel()?->getId() === 'admin'
|
||||||
|
&& ! EntraGroupResource::panelTenantContext() instanceof Tenant
|
||||||
|
) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::mount();
|
||||||
|
}
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
|
$tenant = EntraGroupResource::panelTenantContext();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
Action::make('view_operations')
|
Action::make('view_operations')
|
||||||
->label('Operations')
|
->label('Operations')
|
||||||
->icon('heroicon-o-clock')
|
->icon('heroicon-o-clock')
|
||||||
->url(fn (): string => OperationRunLinks::index(Tenant::current()))
|
->url(fn (): string => OperationRunLinks::index($tenant))
|
||||||
->visible(fn (): bool => (bool) Tenant::current()),
|
->visible(fn (): bool => $tenant instanceof Tenant),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Action::make('sync_groups')
|
Action::make('sync_groups')
|
||||||
->label('Sync Groups')
|
->label('Sync Groups')
|
||||||
@ -35,7 +57,7 @@ protected function getHeaderActions(): array
|
|||||||
->color('primary')
|
->color('primary')
|
||||||
->action(function (): void {
|
->action(function (): void {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$tenant = Tenant::current();
|
$tenant = EntraGroupResource::panelTenantContext();
|
||||||
|
|
||||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -3,9 +3,49 @@
|
|||||||
namespace App\Filament\Resources\EntraGroupResource\Pages;
|
namespace App\Filament\Resources\EntraGroupResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\EntraGroupResource;
|
use App\Filament\Resources\EntraGroupResource;
|
||||||
|
use App\Models\EntraGroup;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class ViewEntraGroup extends ViewRecord
|
class ViewEntraGroup extends ViewRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = EntraGroupResource::class;
|
protected static string $resource = EntraGroupResource::class;
|
||||||
|
|
||||||
|
protected function resolveRecord(int|string $key): Model
|
||||||
|
{
|
||||||
|
return EntraGroupResource::resolveScopedRecordOrFail($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeAccess(): void
|
||||||
|
{
|
||||||
|
$tenant = EntraGroupResource::panelTenantContext();
|
||||||
|
$record = $this->getRecord();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (
|
||||||
|
Filament::getCurrentPanel()?->getId() === 'admin'
|
||||||
|
&& ! $tenant instanceof Tenant
|
||||||
|
) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $tenant instanceof Tenant || ! $record instanceof EntraGroup) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->can('view', $record)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
682
app/Filament/Resources/EvidenceSnapshotResource.php
Normal file
682
app/Filament/Resources/EvidenceSnapshotResource.php
Normal file
@ -0,0 +1,682 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||||
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
|
use App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
||||||
|
use App\Models\EvidenceSnapshot;
|
||||||
|
use App\Models\EvidenceSnapshotItem;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Evidence\EvidenceSnapshotService;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\Evidence\EvidenceCompletenessState;
|
||||||
|
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||||
|
use App\Support\OperationCatalog;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\Rbac\UiTooltips;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Infolists\Components\RepeatableEntry;
|
||||||
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Panel;
|
||||||
|
use Filament\Resources\Pages\PageRegistration;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Routing\Route;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\Route as RouteFacade;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class EvidenceSnapshotResource extends Resource
|
||||||
|
{
|
||||||
|
use InteractsWithTenantOwnedRecords;
|
||||||
|
use ResolvesPanelTenantContext;
|
||||||
|
|
||||||
|
protected static ?string $model = EvidenceSnapshot::class;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'evidence';
|
||||||
|
|
||||||
|
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||||
|
|
||||||
|
protected static bool $isGloballySearchable = false;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Evidence';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 55;
|
||||||
|
|
||||||
|
public static function canViewAny(): bool
|
||||||
|
{
|
||||||
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->can(Capabilities::EVIDENCE_VIEW, $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canView(Model $record): bool
|
||||||
|
{
|
||||||
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($tenant) || ! $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! $record instanceof EvidenceSnapshot
|
||||||
|
|| ((int) $record->tenant_id === (int) $tenant->getKey() && (int) $record->workspace_id === (int) $tenant->workspace_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Create snapshot is available from the list header.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes a Create snapshot CTA.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Evidence snapshots keep only primary View and Expire row actions.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Evidence snapshots do not support bulk actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes Refresh evidence and Expire snapshot actions.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
return static::getTenantOwnedEloquentQuery()->with(['tenant', 'initiator', 'operationRun', 'items']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function resolveScopedRecordOrFail(int|string|null $record): Model
|
||||||
|
{
|
||||||
|
return static::resolveTenantOwnedRecordOrFail($record);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function infolist(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema->schema([
|
||||||
|
Section::make('Artifact truth')
|
||||||
|
->schema([
|
||||||
|
ViewEntry::make('artifact_truth')
|
||||||
|
->hiddenLabel()
|
||||||
|
->view('filament.infolists.entries.governance-artifact-truth')
|
||||||
|
->state(fn (EvidenceSnapshot $record): array => static::truthEnvelope($record)->toArray())
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
|
Section::make('Snapshot')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('status')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceSnapshotStatus))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::EvidenceSnapshotStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceSnapshotStatus))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceSnapshotStatus)),
|
||||||
|
TextEntry::make('completeness_state')
|
||||||
|
->label('Completeness')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness)),
|
||||||
|
TextEntry::make('tenant.name')->label('Tenant'),
|
||||||
|
TextEntry::make('generated_at')->dateTime()->placeholder('—'),
|
||||||
|
TextEntry::make('expires_at')->dateTime()->placeholder('—'),
|
||||||
|
TextEntry::make('operationRun.id')
|
||||||
|
->label('Operation run')
|
||||||
|
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||||
|
->url(fn (EvidenceSnapshot $record): ?string => $record->operation_run_id ? OperationRunLinks::tenantlessView((int) $record->operation_run_id) : null)
|
||||||
|
->openUrlInNewTab(),
|
||||||
|
TextEntry::make('fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono'),
|
||||||
|
TextEntry::make('previous_fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono'),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
Section::make('Summary')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('summary.finding_count')->label('Findings')->placeholder('—'),
|
||||||
|
TextEntry::make('summary.report_count')->label('Reports')->placeholder('—'),
|
||||||
|
TextEntry::make('summary.operation_count')->label('Operations')->placeholder('—'),
|
||||||
|
TextEntry::make('summary.missing_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Missing->value))->placeholder('—'),
|
||||||
|
TextEntry::make('summary.stale_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Stale->value))->placeholder('—'),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
Section::make('Evidence dimensions')
|
||||||
|
->schema([
|
||||||
|
RepeatableEntry::make('items')
|
||||||
|
->hiddenLabel()
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('dimension_key')->label('Dimension')
|
||||||
|
->formatStateUsing(fn (string $state): string => Str::headline($state)),
|
||||||
|
TextEntry::make('state')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness)),
|
||||||
|
TextEntry::make('source_kind')->label('Source')
|
||||||
|
->formatStateUsing(fn (string $state): string => Str::headline($state)),
|
||||||
|
TextEntry::make('freshness_at')->dateTime()->placeholder('—'),
|
||||||
|
ViewEntry::make('summary_payload_highlights')
|
||||||
|
->label('Summary')
|
||||||
|
->view('filament.infolists.entries.evidence-dimension-summary')
|
||||||
|
->state(fn (EvidenceSnapshotItem $record): array => static::dimensionSummaryPresentation($record))
|
||||||
|
->columnSpanFull(),
|
||||||
|
ViewEntry::make('summary_payload_raw')
|
||||||
|
->label('Raw summary JSON')
|
||||||
|
->view('filament.infolists.entries.snapshot-json')
|
||||||
|
->state(fn (EvidenceSnapshotItem $record): array => is_array($record->summary_payload) ? $record->summary_payload : [])
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columns(4),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->defaultSort('created_at', 'desc')
|
||||||
|
->recordUrl(fn (EvidenceSnapshot $record): string => static::getUrl('view', ['record' => $record]))
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('status')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceSnapshotStatus))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::EvidenceSnapshotStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceSnapshotStatus))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceSnapshotStatus))
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('artifact_truth')
|
||||||
|
->label('Artifact truth')
|
||||||
|
->badge()
|
||||||
|
->getStateUsing(fn (EvidenceSnapshot $record): string => static::truthEnvelope($record)->primaryLabel)
|
||||||
|
->color(fn (EvidenceSnapshot $record): string => static::truthEnvelope($record)->primaryBadgeSpec()->color)
|
||||||
|
->icon(fn (EvidenceSnapshot $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
||||||
|
->iconColor(fn (EvidenceSnapshot $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
||||||
|
->description(fn (EvidenceSnapshot $record): ?string => static::truthEnvelope($record)->primaryExplanation)
|
||||||
|
->wrap(),
|
||||||
|
Tables\Columns\TextColumn::make('completeness_state')
|
||||||
|
->label('Completeness')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness))
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('generated_at')->dateTime()->sortable()->placeholder('—'),
|
||||||
|
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
|
||||||
|
Tables\Columns\TextColumn::make('summary.missing_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Missing->value)),
|
||||||
|
Tables\Columns\TextColumn::make('artifact_next_step')
|
||||||
|
->label('Next step')
|
||||||
|
->getStateUsing(fn (EvidenceSnapshot $record): string => static::truthEnvelope($record)->nextStepText())
|
||||||
|
->wrap(),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
Tables\Filters\SelectFilter::make('status')
|
||||||
|
->options(BadgeCatalog::options(BadgeDomain::EvidenceSnapshotStatus, EvidenceSnapshotStatus::values())),
|
||||||
|
Tables\Filters\SelectFilter::make('completeness_state')
|
||||||
|
->options(BadgeCatalog::options(BadgeDomain::EvidenceCompleteness, EvidenceCompletenessState::values())),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_snapshot')
|
||||||
|
->label('View snapshot')
|
||||||
|
->url(fn (EvidenceSnapshot $record): string => static::getUrl('view', ['record' => $record])),
|
||||||
|
UiEnforcement::forTableAction(
|
||||||
|
Actions\Action::make('expire')
|
||||||
|
->label('Expire snapshot')
|
||||||
|
->color('danger')
|
||||||
|
->hidden(fn (EvidenceSnapshot $record): bool => ! static::canExpireRecord($record))
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(function (EvidenceSnapshot $record): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
app(EvidenceSnapshotService::class)->expire($record, $user);
|
||||||
|
static::truthEnvelope($record->refresh(), fresh: true);
|
||||||
|
|
||||||
|
Notification::make()->success()->title('Snapshot expired')->send();
|
||||||
|
}),
|
||||||
|
fn (EvidenceSnapshot $record): EvidenceSnapshot => $record,
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
||||||
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
|
->apply(),
|
||||||
|
])
|
||||||
|
->bulkActions([])
|
||||||
|
->emptyStateHeading('No evidence snapshots yet')
|
||||||
|
->emptyStateDescription('Create the first snapshot to capture immutable evidence for this tenant.')
|
||||||
|
->emptyStateActions([
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('create_first_snapshot')
|
||||||
|
->label('Create first snapshot')
|
||||||
|
->icon('heroicon-o-plus')
|
||||||
|
->action(fn (): mixed => static::executeGeneration([])),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
||||||
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
|
->apply(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListEvidenceSnapshots::route('/'),
|
||||||
|
'view' => new PageRegistration(
|
||||||
|
page: Pages\ViewEvidenceSnapshot::class,
|
||||||
|
route: fn (Panel $panel): Route => RouteFacade::get('/{record}', Pages\ViewEvidenceSnapshot::class)
|
||||||
|
->whereNumber('record')
|
||||||
|
->middleware(Pages\ViewEvidenceSnapshot::getRouteMiddleware($panel))
|
||||||
|
->withoutMiddleware(Pages\ViewEvidenceSnapshot::getWithoutRouteMiddleware($panel)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
||||||
|
*/
|
||||||
|
private static function dimensionSummaryPresentation(EvidenceSnapshotItem $item): array
|
||||||
|
{
|
||||||
|
$payload = is_array($item->summary_payload) ? $item->summary_payload : [];
|
||||||
|
|
||||||
|
return match ($item->dimension_key) {
|
||||||
|
'findings_summary' => static::findingsSummaryPresentation($payload),
|
||||||
|
'permission_posture' => static::permissionPosturePresentation($payload),
|
||||||
|
'entra_admin_roles' => static::entraAdminRolesPresentation($payload),
|
||||||
|
'baseline_drift_posture' => static::baselineDriftPosturePresentation($payload),
|
||||||
|
'operations_summary' => static::operationsSummaryPresentation($payload),
|
||||||
|
default => static::genericSummaryPresentation($payload),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
||||||
|
*/
|
||||||
|
private static function findingsSummaryPresentation(array $payload): array
|
||||||
|
{
|
||||||
|
$count = (int) ($payload['count'] ?? 0);
|
||||||
|
$openCount = (int) ($payload['open_count'] ?? 0);
|
||||||
|
$severityCounts = is_array($payload['severity_counts'] ?? null) ? $payload['severity_counts'] : [];
|
||||||
|
$entries = is_array($payload['entries'] ?? null) ? $payload['entries'] : [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'summary' => sprintf('%d findings, %d open.', $count, $openCount),
|
||||||
|
'highlights' => [
|
||||||
|
['label' => 'Findings', 'value' => (string) $count],
|
||||||
|
['label' => 'Open findings', 'value' => (string) $openCount],
|
||||||
|
['label' => 'Critical', 'value' => (string) ((int) ($severityCounts['critical'] ?? 0))],
|
||||||
|
['label' => 'High', 'value' => (string) ((int) ($severityCounts['high'] ?? 0))],
|
||||||
|
['label' => 'Medium', 'value' => (string) ((int) ($severityCounts['medium'] ?? 0))],
|
||||||
|
['label' => 'Low', 'value' => (string) ((int) ($severityCounts['low'] ?? 0))],
|
||||||
|
],
|
||||||
|
'items' => collect($entries)
|
||||||
|
->map(fn (mixed $entry): ?string => is_array($entry) ? static::findingEntryLabel($entry) : null)
|
||||||
|
->filter()
|
||||||
|
->take(5)
|
||||||
|
->values()
|
||||||
|
->all(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
||||||
|
*/
|
||||||
|
private static function permissionPosturePresentation(array $payload): array
|
||||||
|
{
|
||||||
|
$requiredCount = (int) ($payload['required_count'] ?? 0);
|
||||||
|
$grantedCount = (int) ($payload['granted_count'] ?? 0);
|
||||||
|
$postureScore = $payload['posture_score'] ?? null;
|
||||||
|
$reportPayload = is_array($payload['payload'] ?? null) ? $payload['payload'] : [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'summary' => sprintf('%d of %d required permissions granted.', $grantedCount, $requiredCount),
|
||||||
|
'highlights' => [
|
||||||
|
['label' => 'Granted permissions', 'value' => (string) $grantedCount],
|
||||||
|
['label' => 'Required permissions', 'value' => (string) $requiredCount],
|
||||||
|
['label' => 'Posture score', 'value' => $postureScore === null ? '—' : (string) $postureScore],
|
||||||
|
],
|
||||||
|
'items' => static::namedItemsFromArray(
|
||||||
|
Arr::get($reportPayload, 'missing_permissions', Arr::get($reportPayload, 'missing', [])),
|
||||||
|
'No missing permission details captured.'
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
||||||
|
*/
|
||||||
|
private static function entraAdminRolesPresentation(array $payload): array
|
||||||
|
{
|
||||||
|
$roleCount = (int) ($payload['role_count'] ?? 0);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'summary' => sprintf('%d privileged Entra roles captured.', $roleCount),
|
||||||
|
'highlights' => [
|
||||||
|
['label' => 'Role count', 'value' => (string) $roleCount],
|
||||||
|
],
|
||||||
|
'items' => static::namedItemsFromArray($payload['roles'] ?? [], 'No role details captured.'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
||||||
|
*/
|
||||||
|
private static function baselineDriftPosturePresentation(array $payload): array
|
||||||
|
{
|
||||||
|
$driftCount = (int) ($payload['drift_count'] ?? 0);
|
||||||
|
$openDriftCount = (int) ($payload['open_drift_count'] ?? 0);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'summary' => sprintf('%d drift findings, %d still open.', $driftCount, $openDriftCount),
|
||||||
|
'highlights' => [
|
||||||
|
['label' => 'Drift findings', 'value' => (string) $driftCount],
|
||||||
|
['label' => 'Open drift findings', 'value' => (string) $openDriftCount],
|
||||||
|
],
|
||||||
|
'items' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
||||||
|
*/
|
||||||
|
private static function operationsSummaryPresentation(array $payload): array
|
||||||
|
{
|
||||||
|
$operationCount = (int) ($payload['operation_count'] ?? 0);
|
||||||
|
$failedCount = (int) ($payload['failed_count'] ?? 0);
|
||||||
|
$partialCount = (int) ($payload['partial_count'] ?? 0);
|
||||||
|
$entries = is_array($payload['entries'] ?? null) ? $payload['entries'] : [];
|
||||||
|
$actionSummary = $failedCount === 0 && $partialCount === 0
|
||||||
|
? 'No action needed.'
|
||||||
|
: sprintf('%d execution failures, %d need follow-up.', $failedCount, $partialCount);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'summary' => sprintf('%d operations in the last 30 days. %s', $operationCount, $actionSummary),
|
||||||
|
'highlights' => [
|
||||||
|
['label' => 'Operations', 'value' => (string) $operationCount],
|
||||||
|
['label' => 'Execution failures', 'value' => (string) $failedCount],
|
||||||
|
['label' => 'Needs follow-up', 'value' => (string) $partialCount],
|
||||||
|
],
|
||||||
|
'items' => collect($entries)
|
||||||
|
->map(fn (mixed $entry): ?string => is_array($entry) ? static::operationEntryLabel($entry) : null)
|
||||||
|
->filter()
|
||||||
|
->take(5)
|
||||||
|
->values()
|
||||||
|
->all(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
||||||
|
*/
|
||||||
|
private static function genericSummaryPresentation(array $payload): array
|
||||||
|
{
|
||||||
|
$highlights = collect($payload)
|
||||||
|
->reject(fn (mixed $value, string|int $key): bool => in_array((string) $key, ['entries', 'payload', 'roles'], true) || is_array($value))
|
||||||
|
->take(6)
|
||||||
|
->map(fn (mixed $value, string|int $key): array => [
|
||||||
|
'label' => Str::headline((string) $key),
|
||||||
|
'value' => static::stringifySummaryValue($value),
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'summary' => empty($highlights) ? 'No summary details captured.' : null,
|
||||||
|
'highlights' => $highlights,
|
||||||
|
'items' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private static function namedItemsFromArray(mixed $items, string $emptyFallback): array
|
||||||
|
{
|
||||||
|
if (! is_array($items) || $items === []) {
|
||||||
|
return [$emptyFallback];
|
||||||
|
}
|
||||||
|
|
||||||
|
$labels = collect($items)
|
||||||
|
->map(function (mixed $item): ?string {
|
||||||
|
if (is_string($item)) {
|
||||||
|
return trim($item) !== '' ? $item : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($item)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['display_name', 'displayName', 'name', 'title', 'id'] as $key) {
|
||||||
|
$value = $item[$key] ?? null;
|
||||||
|
|
||||||
|
if (is_string($value) && trim($value) !== '') {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
->filter()
|
||||||
|
->take(5)
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return $labels === [] ? [$emptyFallback] : $labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $entry
|
||||||
|
*/
|
||||||
|
private static function findingEntryLabel(array $entry): ?string
|
||||||
|
{
|
||||||
|
$title = $entry['title'] ?? null;
|
||||||
|
$severity = $entry['severity'] ?? null;
|
||||||
|
$status = $entry['status'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($title) || trim($title) === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = [trim($title)];
|
||||||
|
|
||||||
|
if (is_string($severity) && trim($severity) !== '') {
|
||||||
|
$parts[] = Str::headline($severity);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($status) && trim($status) !== '') {
|
||||||
|
$parts[] = Str::headline($status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(' · ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $entry
|
||||||
|
*/
|
||||||
|
private static function operationEntryLabel(array $entry): ?string
|
||||||
|
{
|
||||||
|
$type = $entry['type'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($type) || trim($type) === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = [static::operationTypeLabel($type)];
|
||||||
|
|
||||||
|
$stateLabel = static::operationEntryStateLabel($entry);
|
||||||
|
|
||||||
|
if ($stateLabel !== null) {
|
||||||
|
$parts[] = $stateLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(' · ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canExpireRecord(EvidenceSnapshot $record): bool
|
||||||
|
{
|
||||||
|
return (string) $record->status !== EvidenceSnapshotStatus::Expired->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function operationTypeLabel(string $type): string
|
||||||
|
{
|
||||||
|
$label = OperationCatalog::label($type);
|
||||||
|
|
||||||
|
return $label === 'Unknown operation' ? 'Operation' : $label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $entry
|
||||||
|
*/
|
||||||
|
private static function operationEntryStateLabel(array $entry): ?string
|
||||||
|
{
|
||||||
|
$status = is_string($entry['status'] ?? null) ? trim((string) $entry['status']) : null;
|
||||||
|
$outcome = is_string($entry['outcome'] ?? null) ? trim((string) $entry['outcome']) : null;
|
||||||
|
|
||||||
|
return match ($status) {
|
||||||
|
OperationRunStatus::Queued->value => static::badgeLabel(BadgeDomain::OperationRunStatus, $status),
|
||||||
|
OperationRunStatus::Running->value => static::badgeLabel(BadgeDomain::OperationRunStatus, $status),
|
||||||
|
OperationRunStatus::Completed->value => match ($outcome) {
|
||||||
|
OperationRunOutcome::Pending->value => static::badgeLabel(BadgeDomain::OperationRunStatus, $status),
|
||||||
|
OperationRunOutcome::Succeeded->value,
|
||||||
|
OperationRunOutcome::PartiallySucceeded->value,
|
||||||
|
OperationRunOutcome::Blocked->value,
|
||||||
|
OperationRunOutcome::Failed->value,
|
||||||
|
OperationRunOutcome::Cancelled->value => static::badgeLabel(BadgeDomain::OperationRunOutcome, $outcome),
|
||||||
|
default => static::badgeLabel(BadgeDomain::OperationRunStatus, $status),
|
||||||
|
},
|
||||||
|
default => $outcome !== null ? static::badgeLabel(BadgeDomain::OperationRunOutcome, $outcome) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function evidenceCompletenessCountLabel(string $state): string
|
||||||
|
{
|
||||||
|
return BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, $state)->label;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function badgeLabel(BadgeDomain $domain, ?string $state): ?string
|
||||||
|
{
|
||||||
|
if ($state === null || trim($state) === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = BadgeCatalog::spec($domain, $state)->label;
|
||||||
|
|
||||||
|
return $label === 'Unknown' ? null : $label;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function truthEnvelope(EvidenceSnapshot $record, bool $fresh = false): ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? $presenter->forEvidenceSnapshotFresh($record)
|
||||||
|
: $presenter->forEvidenceSnapshot($record);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function stringifySummaryValue(mixed $value): string
|
||||||
|
{
|
||||||
|
return match (true) {
|
||||||
|
$value === null => '—',
|
||||||
|
is_bool($value) => $value ? 'Yes' : 'No',
|
||||||
|
is_scalar($value) => (string) $value,
|
||||||
|
default => '—',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public static function executeGeneration(array $data): void
|
||||||
|
{
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
Notification::make()->danger()->title('Unable to create snapshot — missing context.')->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$snapshot = app(EvidenceSnapshotService::class)->generate(
|
||||||
|
tenant: $tenant,
|
||||||
|
user: $user,
|
||||||
|
allowStale: (bool) ($data['allow_stale'] ?? false),
|
||||||
|
);
|
||||||
|
static::truthEnvelope($snapshot->refresh(), fresh: true);
|
||||||
|
|
||||||
|
if (! $snapshot->wasRecentlyCreated) {
|
||||||
|
Notification::make()
|
||||||
|
->success()
|
||||||
|
->title('Snapshot already available')
|
||||||
|
->body('A matching active snapshot already exists. No new run was started.')
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_snapshot')
|
||||||
|
->label('View snapshot')
|
||||||
|
->url(static::getUrl('view', ['record' => $snapshot], tenant: $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->success()
|
||||||
|
->title('Create snapshot queued')
|
||||||
|
->body('The snapshot is being generated in the background.')
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url($snapshot->operation_run_id ? OperationRunLinks::tenantlessView((int) $snapshot->operation_run_id) : static::getUrl('view', ['record' => $snapshot], tenant: $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
|
||||||
|
class ListEvidenceSnapshots extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = EvidenceSnapshotResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('create_snapshot')
|
||||||
|
->label('Create snapshot')
|
||||||
|
->icon('heroicon-o-plus')
|
||||||
|
->action(fn (array $data): mixed => EvidenceSnapshotResource::executeGeneration($data))
|
||||||
|
->form([
|
||||||
|
Section::make('Snapshot options')
|
||||||
|
->schema([
|
||||||
|
Toggle::make('allow_stale')
|
||||||
|
->label('Allow stale dimensions')
|
||||||
|
->default(false),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||||
|
use App\Filament\Resources\ReviewPackResource;
|
||||||
|
use App\Models\ReviewPack;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Evidence\EvidenceSnapshotService;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class ViewEvidenceSnapshot extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = EvidenceSnapshotResource::class;
|
||||||
|
|
||||||
|
protected function resolveRecord(int|string $key): Model
|
||||||
|
{
|
||||||
|
return EvidenceSnapshotResource::resolveScopedRecordOrFail($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->icon('heroicon-o-eye')
|
||||||
|
->color('gray')
|
||||||
|
->url(fn (): ?string => $this->record->operation_run_id ? OperationRunLinks::tenantlessView((int) $this->record->operation_run_id) : null)
|
||||||
|
->hidden(fn (): bool => ! is_numeric($this->record->operation_run_id)),
|
||||||
|
Actions\Action::make('view_review_pack')
|
||||||
|
->label('View review pack')
|
||||||
|
->icon('heroicon-o-document-text')
|
||||||
|
->color('gray')
|
||||||
|
->url(function (): ?string {
|
||||||
|
$pack = $this->latestReviewPack();
|
||||||
|
|
||||||
|
if (! $pack instanceof ReviewPack || ! $pack->tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant);
|
||||||
|
})
|
||||||
|
->hidden(fn (): bool => ! $this->latestReviewPack() instanceof ReviewPack),
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('refresh_snapshot')
|
||||||
|
->label('Refresh evidence')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(function (): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
app(EvidenceSnapshotService::class)->refresh($this->record, $user);
|
||||||
|
|
||||||
|
Notification::make()->success()->title('Refresh evidence queued')->send();
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('expire_snapshot')
|
||||||
|
->label('Expire snapshot')
|
||||||
|
->icon('heroicon-o-x-circle')
|
||||||
|
->color('danger')
|
||||||
|
->hidden(fn (): bool => ! EvidenceSnapshotResource::canExpireRecord($this->record))
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(function (): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
app(EvidenceSnapshotService::class)->expire($this->record, $user);
|
||||||
|
$this->refreshFormData(['status', 'expires_at']);
|
||||||
|
|
||||||
|
Notification::make()->success()->title('Snapshot expired')->send();
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function latestReviewPack(): ?ReviewPack
|
||||||
|
{
|
||||||
|
return $this->record->reviewPacks()
|
||||||
|
->latest('created_at')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
}
|
||||||
630
app/Filament/Resources/FindingExceptionResource.php
Normal file
630
app/Filament/Resources/FindingExceptionResource.php
Normal file
@ -0,0 +1,630 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||||
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
|
use App\Filament\Resources\FindingExceptionResource\Pages;
|
||||||
|
use App\Models\FindingException;
|
||||||
|
use App\Models\FindingExceptionEvidenceReference;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
|
use App\Services\Findings\FindingExceptionService;
|
||||||
|
use App\Services\Findings\FindingRiskGovernanceResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
|
use Filament\Forms\Components\Repeater;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Infolists\Components\RepeatableEntry;
|
||||||
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class FindingExceptionResource extends Resource
|
||||||
|
{
|
||||||
|
use InteractsWithTenantOwnedRecords;
|
||||||
|
use ResolvesPanelTenantContext;
|
||||||
|
|
||||||
|
protected static ?string $model = FindingException::class;
|
||||||
|
|
||||||
|
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||||
|
|
||||||
|
protected static bool $isGloballySearchable = false;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-exclamation';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Risk exceptions';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 60;
|
||||||
|
|
||||||
|
public static function shouldRegisterNavigation(): bool
|
||||||
|
{
|
||||||
|
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::shouldRegisterNavigation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canViewAny(): bool
|
||||||
|
{
|
||||||
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->can(Capabilities::FINDING_EXCEPTION_VIEW, $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canView(Model $record): bool
|
||||||
|
{
|
||||||
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($tenant) || ! $user->can(Capabilities::FINDING_EXCEPTION_VIEW, $tenant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! $record instanceof FindingException
|
||||||
|
|| ((int) $record->tenant_id === (int) $tenant->getKey() && (int) $record->workspace_id === (int) $tenant->workspace_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'List header links back to findings where exception requests originate.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'v1 keeps exception mutations direct and avoids a More menu.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Exception decisions require per-record review and intentionally omit bulk actions in v1.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state explains that new requests start from finding detail.')
|
||||||
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail header exposes linked finding navigation plus state-aware renewal and revocation actions.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
return static::getTenantOwnedEloquentQuery()
|
||||||
|
->with(static::relationshipsForView());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{active: int, expiring: int, expired: int, pending: int, total: int}
|
||||||
|
*/
|
||||||
|
public static function exceptionStatsForCurrentTenant(): array
|
||||||
|
{
|
||||||
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return ['active' => 0, 'expiring' => 0, 'expired' => 0, 'pending' => 0, 'total' => 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$counts = FindingException::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('workspace_id', (int) $tenant->workspace_id)
|
||||||
|
->selectRaw('count(*) as total')
|
||||||
|
->selectRaw("count(*) filter (where status = 'active') as active")
|
||||||
|
->selectRaw("count(*) filter (where status = 'expiring') as expiring")
|
||||||
|
->selectRaw("count(*) filter (where status = 'expired') as expired")
|
||||||
|
->selectRaw("count(*) filter (where status = 'pending') as pending")
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'active' => (int) ($counts?->active ?? 0),
|
||||||
|
'expiring' => (int) ($counts?->expiring ?? 0),
|
||||||
|
'expired' => (int) ($counts?->expired ?? 0),
|
||||||
|
'pending' => (int) ($counts?->pending ?? 0),
|
||||||
|
'total' => (int) ($counts?->total ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function resolveScopedRecordOrFail(int|string|null $record): Model
|
||||||
|
{
|
||||||
|
return static::resolveTenantOwnedRecordOrFail($record, parent::getEloquentQuery()->with(static::relationshipsForView()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function infolist(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema->schema([
|
||||||
|
Section::make('Exception')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('status')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus)),
|
||||||
|
TextEntry::make('current_validity_state')
|
||||||
|
->label('Validity')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity)),
|
||||||
|
TextEntry::make('governance_warning')
|
||||||
|
->label('Governance warning')
|
||||||
|
->state(fn (FindingException $record): ?string => static::governanceWarning($record))
|
||||||
|
->color(fn (FindingException $record): string => static::governanceWarningColor($record))
|
||||||
|
->columnSpanFull()
|
||||||
|
->visible(fn (FindingException $record): bool => static::governanceWarning($record) !== null),
|
||||||
|
TextEntry::make('tenant.name')->label('Tenant'),
|
||||||
|
TextEntry::make('finding_summary')
|
||||||
|
->label('Finding')
|
||||||
|
->state(fn (FindingException $record): string => static::findingSummary($record)),
|
||||||
|
TextEntry::make('requester.name')->label('Requested by')->placeholder('—'),
|
||||||
|
TextEntry::make('owner.name')->label('Owner')->placeholder('—'),
|
||||||
|
TextEntry::make('approver.name')->label('Approved by')->placeholder('—'),
|
||||||
|
TextEntry::make('requested_at')->label('Requested')->dateTime()->placeholder('—'),
|
||||||
|
TextEntry::make('approved_at')->label('Approved')->dateTime()->placeholder('—'),
|
||||||
|
TextEntry::make('review_due_at')->label('Review due')->dateTime()->placeholder('—'),
|
||||||
|
TextEntry::make('effective_from')->label('Effective from')->dateTime()->placeholder('—'),
|
||||||
|
TextEntry::make('expires_at')->label('Expires')->dateTime()->placeholder('—'),
|
||||||
|
TextEntry::make('request_reason')->label('Request reason')->columnSpanFull(),
|
||||||
|
TextEntry::make('approval_reason')->label('Approval reason')->placeholder('—')->columnSpanFull(),
|
||||||
|
TextEntry::make('rejection_reason')->label('Rejection reason')->placeholder('—')->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
Section::make('Decision history')
|
||||||
|
->schema([
|
||||||
|
RepeatableEntry::make('decisions')
|
||||||
|
->hiddenLabel()
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('decision_type')->label('Decision'),
|
||||||
|
TextEntry::make('actor.name')->label('Actor')->placeholder('—'),
|
||||||
|
TextEntry::make('decided_at')->label('Decided')->dateTime()->placeholder('—'),
|
||||||
|
TextEntry::make('reason')->label('Reason')->placeholder('—')->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columns(3),
|
||||||
|
]),
|
||||||
|
Section::make('Evidence references')
|
||||||
|
->schema([
|
||||||
|
RepeatableEntry::make('evidenceReferences')
|
||||||
|
->hiddenLabel()
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('label')->label('Label'),
|
||||||
|
TextEntry::make('source_type')->label('Source'),
|
||||||
|
TextEntry::make('source_id')->label('Source ID')->placeholder('—'),
|
||||||
|
TextEntry::make('source_fingerprint')->label('Fingerprint')->placeholder('—'),
|
||||||
|
TextEntry::make('measured_at')->label('Measured')->dateTime()->placeholder('—'),
|
||||||
|
TextEntry::make('summary_payload')
|
||||||
|
->label('Summary')
|
||||||
|
->state(function (FindingExceptionEvidenceReference $record): ?string {
|
||||||
|
if ($record->summary_payload === [] || $record->summary_payload === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_encode($record->summary_payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: null;
|
||||||
|
})
|
||||||
|
->placeholder('—')
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
])
|
||||||
|
->visible(fn (FindingException $record): bool => $record->evidenceReferences->isNotEmpty()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->defaultSort('requested_at', 'desc')
|
||||||
|
->paginated(TablePaginationProfiles::resource())
|
||||||
|
->persistFiltersInSession()
|
||||||
|
->persistSearchInSession()
|
||||||
|
->persistSortInSession()
|
||||||
|
->recordUrl(fn (FindingException $record): string => static::getUrl('view', ['record' => $record]))
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('status')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus))
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('current_validity_state')
|
||||||
|
->label('Validity')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('finding.severity')
|
||||||
|
->label('Severity')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity))
|
||||||
|
->placeholder('—')
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('finding_summary')
|
||||||
|
->label('Finding')
|
||||||
|
->state(fn (FindingException $record): string => static::findingSummary($record))
|
||||||
|
->searchable()
|
||||||
|
->wrap()
|
||||||
|
->limit(60),
|
||||||
|
Tables\Columns\TextColumn::make('governance_warning')
|
||||||
|
->label('Governance warning')
|
||||||
|
->state(fn (FindingException $record): ?string => static::governanceWarning($record))
|
||||||
|
->color(fn (FindingException $record): string => static::governanceWarningColor($record))
|
||||||
|
->wrap(),
|
||||||
|
Tables\Columns\TextColumn::make('requester.name')
|
||||||
|
->label('Requested by')
|
||||||
|
->placeholder('—'),
|
||||||
|
Tables\Columns\TextColumn::make('owner.name')
|
||||||
|
->label('Owner')
|
||||||
|
->placeholder('—'),
|
||||||
|
Tables\Columns\TextColumn::make('review_due_at')
|
||||||
|
->label('Review due')
|
||||||
|
->dateTime()
|
||||||
|
->placeholder('—')
|
||||||
|
->sortable()
|
||||||
|
->description(fn (FindingException $record): ?string => static::relativeTimeDescription($record->review_due_at)),
|
||||||
|
Tables\Columns\TextColumn::make('expires_at')
|
||||||
|
->label('Expires')
|
||||||
|
->dateTime()
|
||||||
|
->placeholder('—')
|
||||||
|
->sortable()
|
||||||
|
->description(fn (FindingException $record): ?string => static::relativeTimeDescription($record->expires_at)),
|
||||||
|
Tables\Columns\TextColumn::make('requested_at')
|
||||||
|
->label('Requested')
|
||||||
|
->dateTime()
|
||||||
|
->sortable(),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
SelectFilter::make('status')
|
||||||
|
->options(FilterOptionCatalog::findingExceptionStatuses()),
|
||||||
|
SelectFilter::make('current_validity_state')
|
||||||
|
->label('Validity')
|
||||||
|
->options(FilterOptionCatalog::findingExceptionValidityStates()),
|
||||||
|
SelectFilter::make('finding_severity')
|
||||||
|
->label('Finding severity')
|
||||||
|
->options(FilterOptionCatalog::findingSeverities())
|
||||||
|
->query(fn (Builder $query, array $data): Builder => filled($data['value'])
|
||||||
|
? $query->whereHas('finding', fn (Builder $q) => $q->where('severity', $data['value']))
|
||||||
|
: $query),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Action::make('renew_exception')
|
||||||
|
->label('Renew exception')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('warning')
|
||||||
|
->visible(fn (FindingException $record): bool => static::canManageRecord($record) && $record->canBeRenewed())
|
||||||
|
->requiresConfirmation()
|
||||||
|
->form([
|
||||||
|
Select::make('owner_user_id')
|
||||||
|
->label('Owner')
|
||||||
|
->required()
|
||||||
|
->options(fn (): array => static::tenantMemberOptions())
|
||||||
|
->searchable(),
|
||||||
|
Textarea::make('request_reason')
|
||||||
|
->label('Renewal reason')
|
||||||
|
->rows(4)
|
||||||
|
->required()
|
||||||
|
->maxLength(2000),
|
||||||
|
DateTimePicker::make('review_due_at')
|
||||||
|
->label('Review due at')
|
||||||
|
->required()
|
||||||
|
->seconds(false),
|
||||||
|
DateTimePicker::make('expires_at')
|
||||||
|
->label('Requested expiry')
|
||||||
|
->seconds(false),
|
||||||
|
Repeater::make('evidence_references')
|
||||||
|
->label('Evidence references')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('label')
|
||||||
|
->label('Label')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('source_type')
|
||||||
|
->label('Source type')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('source_id')
|
||||||
|
->label('Source ID')
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('source_fingerprint')
|
||||||
|
->label('Fingerprint')
|
||||||
|
->maxLength(255),
|
||||||
|
DateTimePicker::make('measured_at')
|
||||||
|
->label('Measured at')
|
||||||
|
->seconds(false),
|
||||||
|
])
|
||||||
|
->defaultItems(0)
|
||||||
|
->collapsed(),
|
||||||
|
])
|
||||||
|
->action(function (FindingException $record, array $data, FindingExceptionService $service): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $record->tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$service->renew($record, $user, $data);
|
||||||
|
} catch (InvalidArgumentException $exception) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Renewal request failed')
|
||||||
|
->body($exception->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Renewal request submitted')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
Action::make('revoke_exception')
|
||||||
|
->label('Revoke exception')
|
||||||
|
->icon('heroicon-o-no-symbol')
|
||||||
|
->color('danger')
|
||||||
|
->visible(fn (FindingException $record): bool => static::canManageRecord($record) && $record->canBeRevoked())
|
||||||
|
->requiresConfirmation()
|
||||||
|
->form([
|
||||||
|
Textarea::make('revocation_reason')
|
||||||
|
->label('Revocation reason')
|
||||||
|
->rows(4)
|
||||||
|
->required()
|
||||||
|
->maxLength(2000),
|
||||||
|
])
|
||||||
|
->action(function (FindingException $record, array $data, FindingExceptionService $service): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$service->revoke($record, $user, $data);
|
||||||
|
} catch (InvalidArgumentException $exception) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Exception revocation failed')
|
||||||
|
->body($exception->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Exception revoked')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->bulkActions([])
|
||||||
|
->emptyStateHeading('No exceptions match this view')
|
||||||
|
->emptyStateDescription('Exception requests are created from finding detail when a governed risk acceptance review is needed.')
|
||||||
|
->emptyStateIcon('heroicon-o-shield-exclamation')
|
||||||
|
->emptyStateActions([
|
||||||
|
Action::make('open_findings')
|
||||||
|
->label('Open findings')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->color('gray')
|
||||||
|
->url(fn (): string => FindingResource::getUrl('index')),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListFindingExceptions::route('/'),
|
||||||
|
'view' => Pages\ViewFindingException::route('/{record}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string|array<int|string, mixed>>
|
||||||
|
*/
|
||||||
|
private static function relationshipsForView(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'tenant',
|
||||||
|
'requester',
|
||||||
|
'owner',
|
||||||
|
'approver',
|
||||||
|
'currentDecision',
|
||||||
|
'decisions.actor',
|
||||||
|
'evidenceReferences',
|
||||||
|
'finding' => fn ($query) => $query->withSubjectDisplayName(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private static function tenantMemberOptions(): array
|
||||||
|
{
|
||||||
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return \App\Models\TenantMembership::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->join('users', 'users.id', '=', 'tenant_memberships.user_id')
|
||||||
|
->orderBy('users.name')
|
||||||
|
->pluck('users.name', 'users.id')
|
||||||
|
->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function findingSummary(FindingException $record): string
|
||||||
|
{
|
||||||
|
$finding = $record->finding;
|
||||||
|
|
||||||
|
if (! $finding instanceof \App\Models\Finding) {
|
||||||
|
return 'Finding #'.$record->finding_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$displayName = $finding->resolvedSubjectDisplayName();
|
||||||
|
$findingType = $finding->finding_type;
|
||||||
|
|
||||||
|
$parts = [];
|
||||||
|
|
||||||
|
if (is_string($displayName) && trim($displayName) !== '') {
|
||||||
|
$parts[] = trim($displayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($findingType) && trim($findingType) !== '') {
|
||||||
|
$label = str_replace('_', ' ', trim($findingType));
|
||||||
|
$parts[] = '('.ucfirst($label).')';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($parts !== []) {
|
||||||
|
return implode(' ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Finding #'.$record->finding_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function relativeTimeDescription(mixed $date): ?string
|
||||||
|
{
|
||||||
|
if (! $date instanceof \DateTimeInterface) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$carbon = \Illuminate\Support\Carbon::instance($date);
|
||||||
|
|
||||||
|
if ($carbon->isToday()) {
|
||||||
|
return 'Today';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($carbon->isPast()) {
|
||||||
|
return $carbon->diffForHumans();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($carbon->isTomorrow()) {
|
||||||
|
return 'Tomorrow';
|
||||||
|
}
|
||||||
|
|
||||||
|
$daysUntil = (int) now()->startOfDay()->diffInDays($carbon->startOfDay());
|
||||||
|
|
||||||
|
if ($daysUntil <= 14) {
|
||||||
|
return 'In '.$daysUntil.' days';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $carbon->diffForHumans();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function canManageRecord(FindingException $record): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
return $user instanceof User
|
||||||
|
&& $record->tenant instanceof Tenant
|
||||||
|
&& $user->canAccessTenant($record->tenant)
|
||||||
|
&& $user->can(Capabilities::FINDING_EXCEPTION_MANAGE, $record->tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function governanceWarning(FindingException $record): ?string
|
||||||
|
{
|
||||||
|
$finding = $record->relationLoaded('finding')
|
||||||
|
? $record->finding
|
||||||
|
: $record->finding()->withSubjectDisplayName()->first();
|
||||||
|
|
||||||
|
if (! $finding instanceof \App\Models\Finding) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(FindingRiskGovernanceResolver::class)->resolveWarningMessage($finding, $record);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function governanceWarningColor(FindingException $record): string
|
||||||
|
{
|
||||||
|
$finding = $record->relationLoaded('finding')
|
||||||
|
? $record->finding
|
||||||
|
: $record->finding()->withSubjectDisplayName()->first();
|
||||||
|
|
||||||
|
if ((string) $record->current_validity_state === FindingException::VALIDITY_EXPIRING) {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($finding instanceof \App\Models\Finding && $record->requiresFreshDecisionForFinding($finding)) {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canAccessApprovalQueueForTenant(?Tenant $tenant = null): bool
|
||||||
|
{
|
||||||
|
$tenant ??= static::resolveTenantContextForCurrentPanel();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::query()->whereKey($tenant->workspace_id)->first();
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var WorkspaceCapabilityResolver $resolver */
|
||||||
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->isMember($user, $workspace)
|
||||||
|
&& $resolver->can($user, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function approvalQueueUrl(?Tenant $tenant = null): ?string
|
||||||
|
{
|
||||||
|
$tenant ??= static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return route('admin.finding-exceptions.open-queue', [
|
||||||
|
'tenant' => (string) $tenant->external_id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\FindingExceptionResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingExceptionResource;
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Filament\Widgets\Tenant\FindingExceptionStatsOverview;
|
||||||
|
use App\Models\FindingException;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Filament\Schemas\Components\Tabs\Tab;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
class ListFindingExceptions extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = FindingExceptionResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderWidgets(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
FindingExceptionStatsOverview::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, Tab>
|
||||||
|
*/
|
||||||
|
public function getTabs(): array
|
||||||
|
{
|
||||||
|
$stats = FindingExceptionResource::exceptionStatsForCurrentTenant();
|
||||||
|
$needsAction = $stats['pending'] + $stats['expiring'] + $stats['expired'];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'all' => Tab::make('All')
|
||||||
|
->icon('heroicon-m-list-bullet'),
|
||||||
|
'needs_action' => Tab::make('Needs action')
|
||||||
|
->icon('heroicon-m-exclamation-triangle')
|
||||||
|
->modifyQueryUsing(fn (Builder $query) => $query->whereIn('status', [
|
||||||
|
FindingException::STATUS_PENDING,
|
||||||
|
FindingException::STATUS_EXPIRING,
|
||||||
|
FindingException::STATUS_EXPIRED,
|
||||||
|
]))
|
||||||
|
->badge($needsAction > 0 ? $needsAction : null)
|
||||||
|
->badgeColor('warning'),
|
||||||
|
'active' => Tab::make('Active')
|
||||||
|
->icon('heroicon-m-check-badge')
|
||||||
|
->modifyQueryUsing(fn (Builder $query) => $query->where('status', FindingException::STATUS_ACTIVE)),
|
||||||
|
'historical' => Tab::make('Historical')
|
||||||
|
->icon('heroicon-m-archive-box')
|
||||||
|
->modifyQueryUsing(fn (Builder $query) => $query->whereIn('status', [
|
||||||
|
FindingException::STATUS_REJECTED,
|
||||||
|
FindingException::STATUS_REVOKED,
|
||||||
|
FindingException::STATUS_SUPERSEDED,
|
||||||
|
])),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Action::make('open_findings')
|
||||||
|
->label('Open findings')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->color('gray')
|
||||||
|
->url(FindingResource::getUrl('index')),
|
||||||
|
Action::make('open_approval_queue')
|
||||||
|
->label('Open approval queue')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (): bool => FindingExceptionResource::canAccessApprovalQueueForTenant())
|
||||||
|
->url(fn (): ?string => FindingExceptionResource::approvalQueueUrl()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,225 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\FindingExceptionResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingExceptionResource;
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Models\FindingException;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Findings\FindingExceptionService;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
|
use Filament\Forms\Components\Repeater;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
class ViewFindingException extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = FindingExceptionResource::class;
|
||||||
|
|
||||||
|
protected function resolveRecord(int|string $key): Model
|
||||||
|
{
|
||||||
|
return FindingExceptionResource::resolveScopedRecordOrFail($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Action::make('open_finding')
|
||||||
|
->label('Open finding')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->color('gray')
|
||||||
|
->url(function (): ?string {
|
||||||
|
$record = $this->getRecord();
|
||||||
|
|
||||||
|
if (! $record instanceof FindingException || ! $record->finding || ! $record->tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant);
|
||||||
|
}),
|
||||||
|
Action::make('open_approval_queue')
|
||||||
|
->label('Open approval queue')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->color('gray')
|
||||||
|
->visible(function (): bool {
|
||||||
|
$record = $this->getRecord();
|
||||||
|
|
||||||
|
return $record instanceof FindingException
|
||||||
|
&& FindingExceptionResource::canAccessApprovalQueueForTenant($record->tenant);
|
||||||
|
})
|
||||||
|
->url(function (): ?string {
|
||||||
|
$record = $this->getRecord();
|
||||||
|
|
||||||
|
return $record instanceof FindingException
|
||||||
|
? FindingExceptionResource::approvalQueueUrl($record->tenant)
|
||||||
|
: null;
|
||||||
|
}),
|
||||||
|
Action::make('renew_exception')
|
||||||
|
->label('Renew exception')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('warning')
|
||||||
|
->visible(fn (): bool => $this->canManageRecord() && $this->getRecord() instanceof FindingException && $this->getRecord()->canBeRenewed())
|
||||||
|
->fillForm(fn (): array => [
|
||||||
|
'owner_user_id' => $this->getRecord() instanceof FindingException ? $this->getRecord()->owner_user_id : null,
|
||||||
|
])
|
||||||
|
->requiresConfirmation()
|
||||||
|
->form([
|
||||||
|
Select::make('owner_user_id')
|
||||||
|
->label('Owner')
|
||||||
|
->required()
|
||||||
|
->options(fn (): array => FindingExceptionResource::canViewAny() ? $this->tenantMemberOptions() : [])
|
||||||
|
->searchable(),
|
||||||
|
Textarea::make('request_reason')
|
||||||
|
->label('Renewal reason')
|
||||||
|
->rows(4)
|
||||||
|
->required()
|
||||||
|
->maxLength(2000),
|
||||||
|
DateTimePicker::make('review_due_at')
|
||||||
|
->label('Review due at')
|
||||||
|
->required()
|
||||||
|
->seconds(false),
|
||||||
|
DateTimePicker::make('expires_at')
|
||||||
|
->label('Requested expiry')
|
||||||
|
->seconds(false),
|
||||||
|
Repeater::make('evidence_references')
|
||||||
|
->label('Evidence references')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('label')
|
||||||
|
->label('Label')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('source_type')
|
||||||
|
->label('Source type')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('source_id')
|
||||||
|
->label('Source ID')
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('source_fingerprint')
|
||||||
|
->label('Fingerprint')
|
||||||
|
->maxLength(255),
|
||||||
|
DateTimePicker::make('measured_at')
|
||||||
|
->label('Measured at')
|
||||||
|
->seconds(false),
|
||||||
|
])
|
||||||
|
->defaultItems(0)
|
||||||
|
->collapsed(),
|
||||||
|
])
|
||||||
|
->action(function (array $data, FindingExceptionService $service): void {
|
||||||
|
$record = $this->getRecord();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $record instanceof FindingException || ! $user instanceof User) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$service->renew($record, $user, $data);
|
||||||
|
} catch (InvalidArgumentException $exception) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Renewal request failed')
|
||||||
|
->body($exception->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Renewal request submitted')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
$this->refreshFormData(['status', 'current_validity_state', 'review_due_at']);
|
||||||
|
}),
|
||||||
|
Action::make('revoke_exception')
|
||||||
|
->label('Revoke exception')
|
||||||
|
->icon('heroicon-o-no-symbol')
|
||||||
|
->color('danger')
|
||||||
|
->visible(fn (): bool => $this->canManageRecord() && $this->getRecord() instanceof FindingException && $this->getRecord()->canBeRevoked())
|
||||||
|
->requiresConfirmation()
|
||||||
|
->form([
|
||||||
|
Textarea::make('revocation_reason')
|
||||||
|
->label('Revocation reason')
|
||||||
|
->rows(4)
|
||||||
|
->required()
|
||||||
|
->maxLength(2000),
|
||||||
|
])
|
||||||
|
->action(function (array $data, FindingExceptionService $service): void {
|
||||||
|
$record = $this->getRecord();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $record instanceof FindingException || ! $user instanceof User) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$service->revoke($record, $user, $data);
|
||||||
|
} catch (InvalidArgumentException $exception) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Exception revocation failed')
|
||||||
|
->body($exception->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Exception revoked')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
$this->refreshFormData(['status', 'current_validity_state', 'revocation_reason', 'revoked_at']);
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function tenantMemberOptions(): array
|
||||||
|
{
|
||||||
|
$record = $this->getRecord();
|
||||||
|
|
||||||
|
if (! $record instanceof FindingException) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $record->tenant;
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return \App\Models\TenantMembership::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->join('users', 'users.id', '=', 'tenant_memberships.user_id')
|
||||||
|
->orderBy('users.name')
|
||||||
|
->pluck('users.name', 'users.id')
|
||||||
|
->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canManageRecord(): bool
|
||||||
|
{
|
||||||
|
$record = $this->getRecord();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
return $record instanceof FindingException
|
||||||
|
&& $record->tenant instanceof Tenant
|
||||||
|
&& $user instanceof User
|
||||||
|
&& $user->canAccessTenant($record->tenant)
|
||||||
|
&& $user->can(Capabilities::FINDING_EXCEPTION_MANAGE, $record->tenant);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\FindingResource\Pages;
|
namespace App\Filament\Resources\FindingResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Filament\Widgets\Tenant\BaselineCompareCoverageBanner;
|
use App\Filament\Widgets\Tenant\BaselineCompareCoverageBanner;
|
||||||
|
use App\Filament\Widgets\Tenant\FindingStatsOverview;
|
||||||
use App\Jobs\BackfillFindingLifecycleJob;
|
use App\Jobs\BackfillFindingLifecycleJob;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -11,6 +13,7 @@
|
|||||||
use App\Services\Findings\FindingWorkflowService;
|
use App\Services\Findings\FindingWorkflowService;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
@ -20,18 +23,82 @@
|
|||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Filament\Schemas\Components\Tabs\Tab;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class ListFindings extends ListRecords
|
class ListFindings extends ListRecords
|
||||||
{
|
{
|
||||||
|
use ResolvesPanelTenantContext;
|
||||||
|
|
||||||
protected static string $resource = FindingResource::class;
|
protected static string $resource = FindingResource::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $arguments
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
|
||||||
|
{
|
||||||
|
if (($context['table'] ?? false) === true && filled($context['recordKey'] ?? null) && in_array($name, ['triage', 'start_progress', 'assign', 'resolve', 'close', 'request_exception', 'reopen'], true)) {
|
||||||
|
try {
|
||||||
|
FindingResource::resolveScopedRecordOrFail($context['recordKey']);
|
||||||
|
} catch (ModelNotFoundException) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::mountAction($name, $arguments, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->syncCanonicalAdminTenantFilterState();
|
||||||
|
|
||||||
|
parent::mount();
|
||||||
|
}
|
||||||
|
|
||||||
protected function getHeaderWidgets(): array
|
protected function getHeaderWidgets(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
BaselineCompareCoverageBanner::class,
|
BaselineCompareCoverageBanner::class,
|
||||||
|
FindingStatsOverview::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, Tab>
|
||||||
|
*/
|
||||||
|
public function getTabs(): array
|
||||||
|
{
|
||||||
|
$stats = FindingResource::findingStatsForCurrentTenant();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'all' => Tab::make('All')
|
||||||
|
->icon('heroicon-m-list-bullet'),
|
||||||
|
'needs_action' => Tab::make('Needs action')
|
||||||
|
->icon('heroicon-m-exclamation-triangle')
|
||||||
|
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||||
|
->whereIn('status', Finding::openStatusesForQuery()))
|
||||||
|
->badge($stats['open'] > 0 ? $stats['open'] : null)
|
||||||
|
->badgeColor('warning'),
|
||||||
|
'overdue' => Tab::make('Overdue')
|
||||||
|
->icon('heroicon-m-clock')
|
||||||
|
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||||
|
->whereIn('status', Finding::openStatusesForQuery())
|
||||||
|
->whereNotNull('due_at')
|
||||||
|
->where('due_at', '<', now()))
|
||||||
|
->badge($stats['overdue'] > 0 ? $stats['overdue'] : null)
|
||||||
|
->badgeColor('danger'),
|
||||||
|
'risk_accepted' => Tab::make('Risk accepted')
|
||||||
|
->icon('heroicon-m-shield-check')
|
||||||
|
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||||
|
->where('status', Finding::STATUS_RISK_ACCEPTED)),
|
||||||
|
'resolved' => Tab::make('Resolved')
|
||||||
|
->icon('heroicon-m-archive-box')
|
||||||
|
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||||
|
->whereIn('status', [Finding::STATUS_RESOLVED, Finding::STATUS_CLOSED])),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,7 +122,7 @@ protected function getHeaderActions(): array
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = \Filament\Facades\Filament::getTenant();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
abort(404);
|
abort(404);
|
||||||
@ -161,7 +228,7 @@ protected function getHeaderActions(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$tenant = \Filament\Facades\Filament::getTenant();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User) {
|
||||||
abort(403);
|
abort(403);
|
||||||
@ -230,15 +297,7 @@ protected function getHeaderActions(): array
|
|||||||
|
|
||||||
protected function buildAllMatchingQuery(): Builder
|
protected function buildAllMatchingQuery(): Builder
|
||||||
{
|
{
|
||||||
$query = Finding::query();
|
$query = FindingResource::getEloquentQuery();
|
||||||
|
|
||||||
$tenantId = \Filament\Facades\Filament::getTenant()?->getKey();
|
|
||||||
|
|
||||||
if (! is_numeric($tenantId)) {
|
|
||||||
return $query->whereRaw('1 = 0');
|
|
||||||
}
|
|
||||||
|
|
||||||
$query->where('tenant_id', (int) $tenantId);
|
|
||||||
|
|
||||||
$query->where('status', Finding::STATUS_NEW);
|
$query->where('status', Finding::STATUS_NEW);
|
||||||
|
|
||||||
@ -288,6 +347,16 @@ protected function buildAllMatchingQuery(): Builder
|
|||||||
return $query;
|
return $query;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function syncCanonicalAdminTenantFilterState(): void
|
||||||
|
{
|
||||||
|
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||||
|
$this->getTableFiltersSessionKey(),
|
||||||
|
tenantSensitiveFilters: ['scope_key', 'run_ids'],
|
||||||
|
request: request(),
|
||||||
|
tenantFilterName: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private function filterIsActive(string $filterName): bool
|
private function filterIsActive(string $filterName): bool
|
||||||
{
|
{
|
||||||
$state = $this->getTableFilterState($filterName);
|
$state = $this->getTableFilterState($filterName);
|
||||||
|
|||||||
@ -2,18 +2,53 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\FindingResource\Pages;
|
namespace App\Filament\Resources\FindingResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingExceptionResource;
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||||
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
use Illuminate\Contracts\Support\Htmlable;
|
use Illuminate\Contracts\Support\Htmlable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class ViewFinding extends ViewRecord
|
class ViewFinding extends ViewRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = FindingResource::class;
|
protected static string $resource = FindingResource::class;
|
||||||
|
|
||||||
|
protected function resolveRecord(int|string $key): Model
|
||||||
|
{
|
||||||
|
return FindingResource::resolveScopedRecordOrFail($key);
|
||||||
|
}
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
Actions\Action::make('primary_related')
|
||||||
|
->label(fn (): string => app(RelatedNavigationResolver::class)
|
||||||
|
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->actionLabel ?? 'Open related record')
|
||||||
|
->url(fn (): ?string => app(RelatedNavigationResolver::class)
|
||||||
|
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->targetUrl)
|
||||||
|
->hidden(fn (): bool => ! (app(RelatedNavigationResolver::class)
|
||||||
|
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->isAvailable() ?? false))
|
||||||
|
->color('gray'),
|
||||||
|
Actions\Action::make('open_approval_queue')
|
||||||
|
->label('Open approval queue')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->color('gray')
|
||||||
|
->visible(function (): bool {
|
||||||
|
$record = $this->getRecord();
|
||||||
|
|
||||||
|
return $record instanceof Finding
|
||||||
|
&& FindingExceptionResource::canAccessApprovalQueueForTenant($record->tenant);
|
||||||
|
})
|
||||||
|
->url(function (): ?string {
|
||||||
|
$record = $this->getRecord();
|
||||||
|
|
||||||
|
return $record instanceof Finding
|
||||||
|
? FindingExceptionResource::approvalQueueUrl($record->tenant)
|
||||||
|
: null;
|
||||||
|
}),
|
||||||
Actions\ActionGroup::make(FindingResource::workflowActions())
|
Actions\ActionGroup::make(FindingResource::workflowActions())
|
||||||
->label('Actions')
|
->label('Actions')
|
||||||
->icon('heroicon-o-ellipsis-vertical')
|
->icon('heroicon-o-ellipsis-vertical')
|
||||||
@ -23,6 +58,6 @@ protected function getHeaderActions(): array
|
|||||||
|
|
||||||
public function getSubheading(): string|Htmlable|null
|
public function getSubheading(): string|Htmlable|null
|
||||||
{
|
{
|
||||||
return FindingResource::redactionIntegrityNoteForRecord($this->getRecord());
|
return FindingResource::findingSubheading($this->getRecord());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Filament\Clusters\Inventory\InventoryCluster;
|
use App\Filament\Clusters\Inventory\InventoryCluster;
|
||||||
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||||
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Resources\InventoryItemResource\Pages;
|
use App\Filament\Resources\InventoryItemResource\Pages;
|
||||||
use App\Models\InventoryItem;
|
use App\Models\InventoryItem;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -16,12 +18,14 @@
|
|||||||
use App\Support\Badges\TagBadgeDomain;
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
use App\Support\Badges\TagBadgeRenderer;
|
use App\Support\Badges\TagBadgeRenderer;
|
||||||
use App\Support\Enums\RelationshipType;
|
use App\Support\Enums\RelationshipType;
|
||||||
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
@ -35,6 +39,9 @@
|
|||||||
|
|
||||||
class InventoryItemResource extends Resource
|
class InventoryItemResource extends Resource
|
||||||
{
|
{
|
||||||
|
use InteractsWithTenantOwnedRecords;
|
||||||
|
use ResolvesPanelTenantContext;
|
||||||
|
|
||||||
protected static ?string $model = InventoryItem::class;
|
protected static ?string $model = InventoryItem::class;
|
||||||
|
|
||||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||||
@ -47,6 +54,15 @@ class InventoryItemResource extends Resource
|
|||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||||
|
|
||||||
|
public static function shouldRegisterNavigation(): bool
|
||||||
|
{
|
||||||
|
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::shouldRegisterNavigation();
|
||||||
|
}
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||||
@ -59,7 +75,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
|
|
||||||
public static function canViewAny(): bool
|
public static function canViewAny(): bool
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
@ -74,7 +90,7 @@ public static function canViewAny(): bool
|
|||||||
|
|
||||||
public static function canView(Model $record): bool
|
public static function canView(Model $record): bool
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
@ -174,7 +190,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
|
|
||||||
$service = app(DependencyQueryService::class);
|
$service = app(DependencyQueryService::class);
|
||||||
$resolver = app(DependencyTargetResolver::class);
|
$resolver = app(DependencyTargetResolver::class);
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
$edges = collect();
|
$edges = collect();
|
||||||
if ($direction === 'inbound' || $direction === 'all') {
|
if ($direction === 'inbound' || $direction === 'all') {
|
||||||
@ -204,10 +220,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
|
|
||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
$typeOptions = collect(static::allTypeMeta())
|
$typeOptions = FilterOptionCatalog::policyTypes();
|
||||||
->mapWithKeys(fn (array $row) => [($row['type'] ?? null) => ($row['label'] ?? $row['type'] ?? null)])
|
|
||||||
->filter(fn ($label, $type) => is_string($type) && $type !== '' && is_string($label) && $label !== '')
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$categoryOptions = collect(static::allTypeMeta())
|
$categoryOptions = collect(static::allTypeMeta())
|
||||||
->mapWithKeys(fn (array $row) => [($row['category'] ?? null) => ($row['category'] ?? null)])
|
->mapWithKeys(fn (array $row) => [($row['category'] ?? null) => ($row['category'] ?? null)])
|
||||||
@ -216,10 +229,15 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
return $table
|
return $table
|
||||||
->defaultSort('last_seen_at', 'desc')
|
->defaultSort('last_seen_at', 'desc')
|
||||||
|
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
|
||||||
|
->persistFiltersInSession()
|
||||||
|
->persistSearchInSession()
|
||||||
|
->persistSortInSession()
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('display_name')
|
Tables\Columns\TextColumn::make('display_name')
|
||||||
->label('Name')
|
->label('Name')
|
||||||
->searchable(),
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('policy_type')
|
Tables\Columns\TextColumn::make('policy_type')
|
||||||
->label('Type')
|
->label('Type')
|
||||||
->badge()
|
->badge()
|
||||||
@ -277,23 +295,56 @@ public static function table(Table $table): Table
|
|||||||
Tables\Filters\SelectFilter::make('category')
|
Tables\Filters\SelectFilter::make('category')
|
||||||
->options($categoryOptions)
|
->options($categoryOptions)
|
||||||
->searchable(),
|
->searchable(),
|
||||||
|
Tables\Filters\SelectFilter::make('platform')
|
||||||
|
->options(FilterOptionCatalog::platforms())
|
||||||
|
->searchable(),
|
||||||
|
Tables\Filters\SelectFilter::make('stale')
|
||||||
|
->label('Freshness')
|
||||||
|
->options([
|
||||||
|
'0' => 'Fresh',
|
||||||
|
'1' => 'Stale',
|
||||||
|
])
|
||||||
|
->query(function (Builder $query, array $data): Builder {
|
||||||
|
$value = $data['value'] ?? null;
|
||||||
|
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cutoff = now()->subHours(max(1, (int) config('tenantpilot.hardening.intune_write_gate.freshness_threshold_hours', 24)));
|
||||||
|
|
||||||
|
if ((string) $value === '1') {
|
||||||
|
return $query->where(function (Builder $staleQuery) use ($cutoff): void {
|
||||||
|
$staleQuery
|
||||||
|
->whereNull('last_seen_at')
|
||||||
|
->orWhere('last_seen_at', '<', $cutoff);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->where('last_seen_at', '>=', $cutoff);
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
->recordUrl(static fn (Model $record): ?string => static::canView($record)
|
->recordUrl(static fn (Model $record): ?string => static::canView($record)
|
||||||
? static::getUrl('view', ['record' => $record])
|
? static::getUrl('view', ['record' => $record])
|
||||||
: null)
|
: null)
|
||||||
->actions([])
|
->actions([])
|
||||||
->bulkActions([]);
|
->bulkActions([])
|
||||||
|
->emptyStateHeading('No inventory items')
|
||||||
|
->emptyStateDescription('Run an inventory sync to capture policy state for this tenant.')
|
||||||
|
->emptyStateIcon('heroicon-o-clipboard-document-list');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
$tenantId = Tenant::current()?->getKey();
|
return static::getTenantOwnedEloquentQuery()
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
|
||||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
|
||||||
->with('lastSeenRun');
|
->with('lastSeenRun');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function resolveScopedRecordOrFail(int|string $key): Model
|
||||||
|
{
|
||||||
|
return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->with('lastSeenRun'));
|
||||||
|
}
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\InventoryItemResource\Pages;
|
namespace App\Filament\Resources\InventoryItemResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Resources\InventoryItemResource;
|
use App\Filament\Resources\InventoryItemResource;
|
||||||
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
||||||
use App\Jobs\RunInventorySyncJob;
|
use App\Jobs\RunInventorySyncJob;
|
||||||
@ -11,6 +12,7 @@
|
|||||||
use App\Services\Inventory\InventorySyncService;
|
use App\Services\Inventory\InventorySyncService;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
@ -28,8 +30,21 @@
|
|||||||
|
|
||||||
class ListInventoryItems extends ListRecords
|
class ListInventoryItems extends ListRecords
|
||||||
{
|
{
|
||||||
|
use ResolvesPanelTenantContext;
|
||||||
|
|
||||||
protected static string $resource = InventoryItemResource::class;
|
protected static string $resource = InventoryItemResource::class;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||||
|
$this->getTableFiltersSessionKey(),
|
||||||
|
request: request(),
|
||||||
|
tenantFilterName: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
parent::mount();
|
||||||
|
}
|
||||||
|
|
||||||
protected function getHeaderWidgets(): array
|
protected function getHeaderWidgets(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@ -90,7 +105,7 @@ protected function getHeaderActions(): array
|
|||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
Toggle::make('include_foundations')
|
Toggle::make('include_foundations')
|
||||||
->label('Include foundation types')
|
->label('Include foundation types')
|
||||||
->helperText('Include scope tags, assignment filters, and notification templates.')
|
->helperText('Include scope tags, assignment filters, notification templates, and Intune RBAC role definitions and assignments.')
|
||||||
->default(true)
|
->default(true)
|
||||||
->dehydrated()
|
->dehydrated()
|
||||||
->rules(['boolean'])
|
->rules(['boolean'])
|
||||||
@ -103,7 +118,7 @@ protected function getHeaderActions(): array
|
|||||||
->rules(['boolean'])
|
->rules(['boolean'])
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
Hidden::make('tenant_id')
|
Hidden::make('tenant_id')
|
||||||
->default(fn (): ?string => Tenant::current()?->getKey())
|
->default(fn (): ?string => static::resolveTenantContextForCurrentPanel()?->getKey())
|
||||||
->dehydrated(),
|
->dehydrated(),
|
||||||
])
|
])
|
||||||
->visible(function (): bool {
|
->visible(function (): bool {
|
||||||
@ -112,7 +127,7 @@ protected function getHeaderActions(): array
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -120,7 +135,7 @@ protected function getHeaderActions(): array
|
|||||||
return $user->canAccessTenant($tenant);
|
return $user->canAccessTenant($tenant);
|
||||||
})
|
})
|
||||||
->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void {
|
->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
@ -159,6 +174,8 @@ protected function getHeaderActions(): array
|
|||||||
],
|
],
|
||||||
context: array_merge($computed['selection'], [
|
context: array_merge($computed['selection'], [
|
||||||
'selection_hash' => $computed['selection_hash'],
|
'selection_hash' => $computed['selection_hash'],
|
||||||
|
'execution_authority_mode' => 'actor_bound',
|
||||||
|
'required_capability' => Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||||
'target_scope' => [
|
'target_scope' => [
|
||||||
'entra_tenant_id' => $tenant->graphTenantId(),
|
'entra_tenant_id' => $tenant->graphTenantId(),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -4,8 +4,14 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\InventoryItemResource;
|
use App\Filament\Resources\InventoryItemResource;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class ViewInventoryItem extends ViewRecord
|
class ViewInventoryItem extends ViewRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = InventoryItemResource::class;
|
protected static string $resource = InventoryItemResource::class;
|
||||||
|
|
||||||
|
protected function resolveRecord(int|string $key): Model
|
||||||
|
{
|
||||||
|
return InventoryItemResource::resolveScopedRecordOrFail($key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||||
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
|
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
||||||
use App\Filament\Resources\PolicyResource\Pages;
|
use App\Filament\Resources\PolicyResource\Pages;
|
||||||
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
|
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
|
||||||
use App\Jobs\BulkPolicyDeleteJob;
|
use App\Jobs\BulkPolicyDeleteJob;
|
||||||
@ -20,6 +23,7 @@
|
|||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Badges\TagBadgeDomain;
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
use App\Support\Badges\TagBadgeRenderer;
|
use App\Support\Badges\TagBadgeRenderer;
|
||||||
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
@ -33,6 +37,7 @@
|
|||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Actions\BulkAction;
|
use Filament\Actions\BulkAction;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
@ -51,17 +56,32 @@
|
|||||||
|
|
||||||
class PolicyResource extends Resource
|
class PolicyResource extends Resource
|
||||||
{
|
{
|
||||||
|
use InteractsWithTenantOwnedRecords;
|
||||||
|
use ResolvesPanelTenantContext;
|
||||||
|
use ScopesGlobalSearchToTenant;
|
||||||
|
|
||||||
protected static ?string $model = Policy::class;
|
protected static ?string $model = Policy::class;
|
||||||
|
|
||||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||||
|
|
||||||
|
protected static bool $isGloballySearchable = false;
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||||
|
|
||||||
|
public static function shouldRegisterNavigation(): bool
|
||||||
|
{
|
||||||
|
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::shouldRegisterNavigation();
|
||||||
|
}
|
||||||
|
|
||||||
public static function canViewAny(): bool
|
public static function canViewAny(): bool
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
@ -97,7 +117,7 @@ public static function makeSyncAction(string $name = 'sync'): Actions\Action
|
|||||||
->modalHeading('Sync policies from Intune')
|
->modalHeading('Sync policies from Intune')
|
||||||
->modalDescription('This queues a background sync operation for supported policy types in the current tenant.')
|
->modalDescription('This queues a background sync operation for supported policy types in the current tenant.')
|
||||||
->action(function (Pages\ListPolicies $livewire): void {
|
->action(function (Pages\ListPolicies $livewire): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||||
@ -340,10 +360,16 @@ public static function table(Table $table): Table
|
|||||||
// Full solution in Feature 005: Policy Lifecycle Management (soft delete)
|
// Full solution in Feature 005: Policy Lifecycle Management (soft delete)
|
||||||
$query->where('last_synced_at', '>', now()->subDays(7));
|
$query->where('last_synced_at', '>', now()->subDays(7));
|
||||||
})
|
})
|
||||||
|
->defaultSort('display_name')
|
||||||
|
->paginated(TablePaginationProfiles::resource())
|
||||||
|
->persistFiltersInSession()
|
||||||
|
->persistSearchInSession()
|
||||||
|
->persistSortInSession()
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('display_name')
|
Tables\Columns\TextColumn::make('display_name')
|
||||||
->label('Policy')
|
->label('Policy')
|
||||||
->searchable(),
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('policy_type')
|
Tables\Columns\TextColumn::make('policy_type')
|
||||||
->label('Type')
|
->label('Type')
|
||||||
->badge()
|
->badge()
|
||||||
@ -390,7 +416,8 @@ public static function table(Table $table): Table
|
|||||||
Tables\Columns\TextColumn::make('external_id')
|
Tables\Columns\TextColumn::make('external_id')
|
||||||
->label('External ID')
|
->label('External ID')
|
||||||
->copyable()
|
->copyable()
|
||||||
->limit(32),
|
->limit(32)
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('last_synced_at')
|
Tables\Columns\TextColumn::make('last_synced_at')
|
||||||
->label('Last synced')
|
->label('Last synced')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
@ -480,7 +507,7 @@ public static function table(Table $table): Table
|
|||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
fn () => Tenant::current(),
|
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->tooltip('You do not have permission to ignore policies.')
|
->tooltip('You do not have permission to ignore policies.')
|
||||||
@ -501,7 +528,7 @@ public static function table(Table $table): Table
|
|||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
fn () => Tenant::current(),
|
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->tooltip('You do not have permission to restore policies.')
|
->tooltip('You do not have permission to restore policies.')
|
||||||
@ -515,7 +542,7 @@ public static function table(Table $table): Table
|
|||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
||||||
->action(function (Policy $record, HasTable $livewire): void {
|
->action(function (Policy $record, HasTable $livewire): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
@ -561,7 +588,7 @@ public static function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
fn () => Tenant::current(),
|
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_SYNC)
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
@ -578,7 +605,7 @@ public static function table(Table $table): Table
|
|||||||
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
||||||
])
|
])
|
||||||
->action(function (Policy $record, array $data): void {
|
->action(function (Policy $record, array $data): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
@ -631,7 +658,7 @@ public static function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
fn () => Tenant::current(),
|
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
@ -670,7 +697,7 @@ public static function table(Table $table): Table
|
|||||||
return [];
|
return [];
|
||||||
})
|
})
|
||||||
->action(function (Collection $records, HasTable $livewire): void {
|
->action(function (Collection $records, HasTable $livewire): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
$ids = $records->pluck('id')->toArray();
|
||||||
@ -753,7 +780,7 @@ public static function table(Table $table): Table
|
|||||||
return ! in_array($value, [null, 'ignored'], true);
|
return ! in_array($value, [null, 'ignored'], true);
|
||||||
})
|
})
|
||||||
->action(function (Collection $records, HasTable $livewire): void {
|
->action(function (Collection $records, HasTable $livewire): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
$ids = $records->pluck('id')->toArray();
|
||||||
@ -835,7 +862,7 @@ public static function table(Table $table): Table
|
|||||||
return $value === 'ignored';
|
return $value === 'ignored';
|
||||||
})
|
})
|
||||||
->action(function (Collection $records, HasTable $livewire): void {
|
->action(function (Collection $records, HasTable $livewire): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
|
|
||||||
@ -906,7 +933,7 @@ public static function table(Table $table): Table
|
|||||||
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
||||||
])
|
])
|
||||||
->action(function (Collection $records, array $data): void {
|
->action(function (Collection $records, array $data): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
$ids = $records->pluck('id')->toArray();
|
||||||
@ -987,16 +1014,25 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
$tenantId = Tenant::currentOrFail()->getKey();
|
return static::getTenantOwnedEloquentQuery()
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
|
||||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
|
||||||
->withCount('versions')
|
->withCount('versions')
|
||||||
->with([
|
->with([
|
||||||
'versions' => fn ($query) => $query->orderByDesc('captured_at')->limit(1),
|
'versions' => fn ($query) => $query->orderByDesc('captured_at')->limit(1),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function resolveScopedRecordOrFail(int|string $key): \Illuminate\Database\Eloquent\Model
|
||||||
|
{
|
||||||
|
return static::resolveTenantOwnedRecordOrFail(
|
||||||
|
$key,
|
||||||
|
parent::getEloquentQuery()
|
||||||
|
->withCount('versions')
|
||||||
|
->with([
|
||||||
|
'versions' => fn ($query) => $query->orderByDesc('captured_at')->limit(1),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public static function getRelations(): array
|
public static function getRelations(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@ -3,16 +3,41 @@
|
|||||||
namespace App\Filament\Resources\PolicyResource\Pages;
|
namespace App\Filament\Resources\PolicyResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\PolicyResource;
|
use App\Filament\Resources\PolicyResource;
|
||||||
|
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
class ListPolicies extends ListRecords
|
class ListPolicies extends ListRecords
|
||||||
{
|
{
|
||||||
protected static string $resource = PolicyResource::class;
|
protected static string $resource = PolicyResource::class;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->syncCanonicalAdminTenantFilterState();
|
||||||
|
|
||||||
|
parent::mount();
|
||||||
|
}
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
PolicyResource::makeSyncAction(),
|
PolicyResource::makeSyncAction(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function getTableEmptyStateActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
PolicyResource::makeSyncAction(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function syncCanonicalAdminTenantFilterState(): void
|
||||||
|
{
|
||||||
|
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||||
|
$this->getTableFiltersSessionKey(),
|
||||||
|
tenantSensitiveFilters: [],
|
||||||
|
request: request(),
|
||||||
|
tenantFilterName: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,7 @@
|
|||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
use Filament\Support\Enums\Width;
|
use Filament\Support\Enums\Width;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class ViewPolicy extends ViewRecord
|
class ViewPolicy extends ViewRecord
|
||||||
@ -24,6 +25,11 @@ class ViewPolicy extends ViewRecord
|
|||||||
|
|
||||||
protected Width|string|null $maxContentWidth = Width::Full;
|
protected Width|string|null $maxContentWidth = Width::Full;
|
||||||
|
|
||||||
|
protected function resolveRecord(int|string $key): Model
|
||||||
|
{
|
||||||
|
return PolicyResource::resolveScopedRecordOrFail($key);
|
||||||
|
}
|
||||||
|
|
||||||
protected function getActions(): array
|
protected function getActions(): array
|
||||||
{
|
{
|
||||||
return [$this->makeCaptureSnapshotAction()];
|
return [$this->makeCaptureSnapshotAction()];
|
||||||
|
|||||||
@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\PolicyResource\RelationManagers;
|
namespace App\Filament\Resources\PolicyResource\RelationManagers;
|
||||||
|
|
||||||
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
|
use App\Models\Policy;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@ -26,8 +28,23 @@
|
|||||||
|
|
||||||
class VersionsRelationManager extends RelationManager
|
class VersionsRelationManager extends RelationManager
|
||||||
{
|
{
|
||||||
|
use ResolvesPanelTenantContext;
|
||||||
|
|
||||||
protected static string $relationship = 'versions';
|
protected static string $relationship = 'versions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $arguments
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
|
||||||
|
{
|
||||||
|
if (($context['table'] ?? false) === true && $name === 'restore_to_intune' && filled($context['recordKey'] ?? null)) {
|
||||||
|
$this->resolveOwnerScopedVersionRecord($this->getOwnerRecord(), $context['recordKey']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::mountAction($name, $arguments, $context);
|
||||||
|
}
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||||
@ -52,8 +69,9 @@ public function table(Table $table): Table
|
|||||||
->label('Preview only (dry-run)')
|
->label('Preview only (dry-run)')
|
||||||
->default(true),
|
->default(true),
|
||||||
])
|
])
|
||||||
->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) {
|
->action(function (mixed $record, array $data, RestoreService $restoreService) {
|
||||||
$tenant = Tenant::current();
|
$record = $this->resolveOwnerScopedVersionRecord($this->getOwnerRecord(), $record);
|
||||||
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
@ -110,7 +128,7 @@ public function table(Table $table): Table
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
@ -130,7 +148,7 @@ public function table(Table $table): Table
|
|||||||
return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).';
|
return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).';
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
@ -162,6 +180,7 @@ public function table(Table $table): Table
|
|||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
])
|
])
|
||||||
->defaultSort('version_number', 'desc')
|
->defaultSort('version_number', 'desc')
|
||||||
|
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
|
||||||
->filters([])
|
->filters([])
|
||||||
->headerActions([])
|
->headerActions([])
|
||||||
->actions([
|
->actions([
|
||||||
@ -170,6 +189,30 @@ public function table(Table $table): Table
|
|||||||
->url(fn ($record) => \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $record]))
|
->url(fn ($record) => \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $record]))
|
||||||
->openUrlInNewTab(false),
|
->openUrlInNewTab(false),
|
||||||
])
|
])
|
||||||
->bulkActions([]);
|
->bulkActions([])
|
||||||
|
->emptyStateHeading('No versions captured')
|
||||||
|
->emptyStateDescription('Capture or sync this policy again to create version history entries.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveOwnerScopedVersionRecord(Policy $policy, mixed $record): PolicyVersion
|
||||||
|
{
|
||||||
|
$recordId = $record instanceof PolicyVersion
|
||||||
|
? (int) $record->getKey()
|
||||||
|
: (is_numeric($record) ? (int) $record : 0);
|
||||||
|
|
||||||
|
if ($recordId <= 0) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolvedRecord = $policy->versions()
|
||||||
|
->where('tenant_id', (int) $policy->tenant_id)
|
||||||
|
->whereKey($recordId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $resolvedRecord instanceof PolicyVersion) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolvedRecord;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||||
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
|
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
||||||
use App\Filament\Resources\PolicyVersionResource\Pages;
|
use App\Filament\Resources\PolicyVersionResource\Pages;
|
||||||
use App\Jobs\BulkPolicyVersionForceDeleteJob;
|
use App\Jobs\BulkPolicyVersionForceDeleteJob;
|
||||||
use App\Jobs\BulkPolicyVersionPruneJob;
|
use App\Jobs\BulkPolicyVersionPruneJob;
|
||||||
@ -21,6 +24,11 @@
|
|||||||
use App\Support\Badges\TagBadgeDomain;
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
use App\Support\Badges\TagBadgeRenderer;
|
use App\Support\Badges\TagBadgeRenderer;
|
||||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||||
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
|
use App\Support\Filament\FilterPresets;
|
||||||
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||||
|
use App\Support\Navigation\RelatedContextEntry;
|
||||||
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
@ -34,10 +42,12 @@
|
|||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\BulkAction;
|
use Filament\Actions\BulkAction;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Infolists;
|
use Filament\Infolists;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Components\Tabs;
|
use Filament\Schemas\Components\Tabs;
|
||||||
use Filament\Schemas\Components\Tabs\Tab;
|
use Filament\Schemas\Components\Tabs\Tab;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
@ -51,17 +61,32 @@
|
|||||||
|
|
||||||
class PolicyVersionResource extends Resource
|
class PolicyVersionResource extends Resource
|
||||||
{
|
{
|
||||||
|
use InteractsWithTenantOwnedRecords;
|
||||||
|
use ResolvesPanelTenantContext;
|
||||||
|
use ScopesGlobalSearchToTenant;
|
||||||
|
|
||||||
protected static ?string $model = PolicyVersion::class;
|
protected static ?string $model = PolicyVersion::class;
|
||||||
|
|
||||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||||
|
|
||||||
|
protected static bool $isGloballySearchable = false;
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-list-bullet';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-list-bullet';
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||||
|
|
||||||
|
public static function shouldRegisterNavigation(): bool
|
||||||
|
{
|
||||||
|
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::shouldRegisterNavigation();
|
||||||
|
}
|
||||||
|
|
||||||
public static function canViewAny(): bool
|
public static function canViewAny(): bool
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
@ -90,7 +115,9 @@ public static function infolist(Schema $schema): Schema
|
|||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
->schema([
|
->schema([
|
||||||
Infolists\Components\TextEntry::make('policy.display_name')->label('Policy'),
|
Infolists\Components\TextEntry::make('policy.display_name')
|
||||||
|
->label('Policy')
|
||||||
|
->state(fn (PolicyVersion $record): string => static::resolvedDisplayName($record)),
|
||||||
Infolists\Components\TextEntry::make('version_number')->label('Version'),
|
Infolists\Components\TextEntry::make('version_number')->label('Version'),
|
||||||
Infolists\Components\TextEntry::make('policy_type')
|
Infolists\Components\TextEntry::make('policy_type')
|
||||||
->badge()
|
->badge()
|
||||||
@ -102,6 +129,15 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
|
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
|
||||||
Infolists\Components\TextEntry::make('created_by')->label('Actor'),
|
Infolists\Components\TextEntry::make('created_by')->label('Actor'),
|
||||||
Infolists\Components\TextEntry::make('captured_at')->dateTime(),
|
Infolists\Components\TextEntry::make('captured_at')->dateTime(),
|
||||||
|
Section::make('Related context')
|
||||||
|
->schema([
|
||||||
|
Infolists\Components\ViewEntry::make('related_context')
|
||||||
|
->label('')
|
||||||
|
->view('filament.infolists.entries.related-context')
|
||||||
|
->state(fn (PolicyVersion $record): array => static::relatedContextEntries($record))
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
Tabs::make()
|
Tabs::make()
|
||||||
->activeTab(1)
|
->activeTab(1)
|
||||||
->persistTabInQueryString('tab')
|
->persistTabInQueryString('tab')
|
||||||
@ -257,7 +293,7 @@ public static function table(Table $table): Table
|
|||||||
return $fields;
|
return $fields;
|
||||||
})
|
})
|
||||||
->action(function (Collection $records, array $data) {
|
->action(function (Collection $records, array $data) {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
$ids = $records->pluck('id')->toArray();
|
||||||
@ -336,7 +372,7 @@ public static function table(Table $table): Table
|
|||||||
->modalHeading(fn (Collection $records) => "Restore {$records->count()} policy versions?")
|
->modalHeading(fn (Collection $records) => "Restore {$records->count()} policy versions?")
|
||||||
->modalDescription('Archived versions will be restored back to the active list. Active versions will be skipped.')
|
->modalDescription('Archived versions will be restored back to the active list. Active versions will be skipped.')
|
||||||
->action(function (Collection $records) {
|
->action(function (Collection $records) {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
$ids = $records->pluck('id')->toArray();
|
||||||
@ -420,7 +456,7 @@ public static function table(Table $table): Table
|
|||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
->action(function (Collection $records, array $data) {
|
->action(function (Collection $records, array $data) {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
$ids = $records->pluck('id')->toArray();
|
||||||
@ -480,8 +516,17 @@ public static function table(Table $table): Table
|
|||||||
->apply();
|
->apply();
|
||||||
|
|
||||||
return $table
|
return $table
|
||||||
|
->defaultSort('captured_at', 'desc')
|
||||||
|
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
|
||||||
|
->persistFiltersInSession()
|
||||||
|
->persistSearchInSession()
|
||||||
|
->persistSortInSession()
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('policy.display_name')->label('Policy')->sortable()->searchable(),
|
Tables\Columns\TextColumn::make('policy.display_name')
|
||||||
|
->label('Policy')
|
||||||
|
->sortable()
|
||||||
|
->searchable()
|
||||||
|
->getStateUsing(fn (PolicyVersion $record): string => static::resolvedDisplayName($record)),
|
||||||
Tables\Columns\TextColumn::make('version_number')->sortable(),
|
Tables\Columns\TextColumn::make('version_number')->sortable(),
|
||||||
Tables\Columns\TextColumn::make('policy_type')
|
Tables\Columns\TextColumn::make('policy_type')
|
||||||
->badge()
|
->badge()
|
||||||
@ -495,16 +540,21 @@ public static function table(Table $table): Table
|
|||||||
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
|
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
TrashedFilter::make()
|
Tables\Filters\SelectFilter::make('policy_type')
|
||||||
->label('Archived')
|
->label('Type')
|
||||||
->placeholder('Active')
|
->options(FilterOptionCatalog::policyTypes())
|
||||||
->trueLabel('All')
|
->searchable(),
|
||||||
->falseLabel('Archived'),
|
Tables\Filters\SelectFilter::make('platform')
|
||||||
|
->options(FilterOptionCatalog::platforms())
|
||||||
|
->searchable(),
|
||||||
|
FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'),
|
||||||
|
FilterPresets::archived(),
|
||||||
])
|
])
|
||||||
->recordUrl(static fn (PolicyVersion $record): ?string => static::canView($record)
|
->recordUrl(static fn (PolicyVersion $record): ?string => static::canView($record)
|
||||||
? static::getUrl('view', ['record' => $record])
|
? static::getUrl('view', ['record' => $record])
|
||||||
: null)
|
: null)
|
||||||
->actions([
|
->actions([
|
||||||
|
static::primaryRelatedAction(),
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
(function (): Actions\Action {
|
(function (): Actions\Action {
|
||||||
$action = Actions\Action::make('restore_via_wizard')
|
$action = Actions\Action::make('restore_via_wizard')
|
||||||
@ -515,7 +565,7 @@ public static function table(Table $table): Table
|
|||||||
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?")
|
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?")
|
||||||
->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.')
|
->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.')
|
||||||
->visible(function (): bool {
|
->visible(function (): bool {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
@ -532,7 +582,7 @@ public static function table(Table $table): Table
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
@ -549,7 +599,7 @@ public static function table(Table $table): Table
|
|||||||
return ! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE);
|
return ! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE);
|
||||||
})
|
})
|
||||||
->tooltip(function (PolicyVersion $record): ?string {
|
->tooltip(function (PolicyVersion $record): ?string {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
@ -574,7 +624,7 @@ public static function table(Table $table): Table
|
|||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
->action(function (PolicyVersion $record) {
|
->action(function (PolicyVersion $record) {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
@ -838,13 +888,15 @@ public static function table(Table $table): Table
|
|||||||
$bulkRestoreVersions,
|
$bulkRestoreVersions,
|
||||||
$bulkForceDeleteVersions,
|
$bulkForceDeleteVersions,
|
||||||
])->label('More'),
|
])->label('More'),
|
||||||
]);
|
])
|
||||||
|
->emptyStateHeading('No policy versions')
|
||||||
|
->emptyStateDescription('Capture or sync policy snapshots to build a version history.')
|
||||||
|
->emptyStateIcon('heroicon-o-clock');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
$tenant = Tenant::currentOrFail();
|
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
|
||||||
$tenantId = $tenant->getKey();
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
$resolver = app(CapabilityResolver::class);
|
||||||
@ -854,8 +906,7 @@ public static function getEloquentQuery(): Builder
|
|||||||
|| $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW)
|
|| $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW)
|
||||||
);
|
);
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
return static::getTenantOwnedEloquentQuery()
|
||||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
|
||||||
->when(! $canSeeBaselinePurposeEvidence, function (Builder $query): Builder {
|
->when(! $canSeeBaselinePurposeEvidence, function (Builder $query): Builder {
|
||||||
return $query->where(function (Builder $query): void {
|
return $query->where(function (Builder $query): void {
|
||||||
$query
|
$query
|
||||||
@ -869,6 +920,72 @@ public static function getEloquentQuery(): Builder
|
|||||||
->with('policy');
|
->with('policy');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function resolveScopedRecordOrFail(int|string $key): \Illuminate\Database\Eloquent\Model
|
||||||
|
{
|
||||||
|
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
$canSeeBaselinePurposeEvidence = $user instanceof User
|
||||||
|
&& (
|
||||||
|
$resolver->can($user, $tenant, Capabilities::TENANT_SYNC)
|
||||||
|
|| $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW)
|
||||||
|
);
|
||||||
|
|
||||||
|
return static::resolveTenantOwnedRecordOrFail(
|
||||||
|
$key,
|
||||||
|
parent::getEloquentQuery()
|
||||||
|
->withTrashed()
|
||||||
|
->when(! $canSeeBaselinePurposeEvidence, function (Builder $query): Builder {
|
||||||
|
return $query->where(function (Builder $query): void {
|
||||||
|
$query
|
||||||
|
->whereNull('capture_purpose')
|
||||||
|
->orWhereNotIn('capture_purpose', [
|
||||||
|
PolicyVersionCapturePurpose::BaselineCapture->value,
|
||||||
|
PolicyVersionCapturePurpose::BaselineCompare->value,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->with('policy'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{
|
||||||
|
* key: string,
|
||||||
|
* label: string,
|
||||||
|
* value: string,
|
||||||
|
* secondaryValue: ?string,
|
||||||
|
* targetUrl: ?string,
|
||||||
|
* targetKind: string,
|
||||||
|
* availability: string,
|
||||||
|
* unavailableReason: ?string,
|
||||||
|
* contextBadge: ?string,
|
||||||
|
* priority: int,
|
||||||
|
* actionLabel: string
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public static function relatedContextEntries(PolicyVersion $record): array
|
||||||
|
{
|
||||||
|
return app(RelatedNavigationResolver::class)
|
||||||
|
->detailEntries(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $record);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function primaryRelatedAction(): Actions\Action
|
||||||
|
{
|
||||||
|
return Actions\Action::make('primary_drill_down')
|
||||||
|
->label(fn (PolicyVersion $record): string => static::primaryRelatedEntry($record)?->actionLabel ?? 'Open related record')
|
||||||
|
->url(fn (PolicyVersion $record): ?string => static::primaryRelatedEntry($record)?->targetUrl)
|
||||||
|
->hidden(fn (PolicyVersion $record): bool => ! (static::primaryRelatedEntry($record)?->isAvailable() ?? false))
|
||||||
|
->color('gray');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function primaryRelatedEntry(PolicyVersion $record): ?RelatedContextEntry
|
||||||
|
{
|
||||||
|
return app(RelatedNavigationResolver::class)
|
||||||
|
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $record);
|
||||||
|
}
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@ -876,4 +993,19 @@ public static function getPages(): array
|
|||||||
'view' => Pages\ViewPolicyVersion::route('/{record}'),
|
'view' => Pages\ViewPolicyVersion::route('/{record}'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function resolvedDisplayName(PolicyVersion $record): string
|
||||||
|
{
|
||||||
|
$snapshot = is_array($record->snapshot) ? $record->snapshot : [];
|
||||||
|
$displayName = $snapshot['displayName']
|
||||||
|
?? $snapshot['name']
|
||||||
|
?? $record->policy?->display_name
|
||||||
|
?? null;
|
||||||
|
|
||||||
|
if (is_string($displayName) && $displayName !== '') {
|
||||||
|
return $displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('Version %d', (int) $record->version_number);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,12 +3,24 @@
|
|||||||
namespace App\Filament\Resources\PolicyVersionResource\Pages;
|
namespace App\Filament\Resources\PolicyVersionResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\PolicyVersionResource;
|
use App\Filament\Resources\PolicyVersionResource;
|
||||||
|
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
class ListPolicyVersions extends ListRecords
|
class ListPolicyVersions extends ListRecords
|
||||||
{
|
{
|
||||||
protected static string $resource = PolicyVersionResource::class;
|
protected static string $resource = PolicyVersionResource::class;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||||
|
$this->getTableFiltersSessionKey(),
|
||||||
|
request: request(),
|
||||||
|
tenantFilterName: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
parent::mount();
|
||||||
|
}
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@ -3,9 +3,14 @@
|
|||||||
namespace App\Filament\Resources\PolicyVersionResource\Pages;
|
namespace App\Filament\Resources\PolicyVersionResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\PolicyVersionResource;
|
use App\Filament\Resources\PolicyVersionResource;
|
||||||
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||||
|
use App\Support\Navigation\RelatedContextEntry;
|
||||||
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
|
use Filament\Actions\Action;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
use Filament\Support\Enums\Width;
|
use Filament\Support\Enums\Width;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class ViewPolicyVersion extends ViewRecord
|
class ViewPolicyVersion extends ViewRecord
|
||||||
{
|
{
|
||||||
@ -13,10 +18,35 @@ class ViewPolicyVersion extends ViewRecord
|
|||||||
|
|
||||||
protected Width|string|null $maxContentWidth = Width::Full;
|
protected Width|string|null $maxContentWidth = Width::Full;
|
||||||
|
|
||||||
|
protected function resolveRecord(int|string $key): Model
|
||||||
|
{
|
||||||
|
return PolicyVersionResource::resolveScopedRecordOrFail($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Action::make('primary_related')
|
||||||
|
->label(fn (): string => $this->primaryRelatedEntry()?->actionLabel ?? 'Open related record')
|
||||||
|
->url(fn (): ?string => $this->primaryRelatedEntry()?->targetUrl)
|
||||||
|
->hidden(fn (): bool => ! ($this->primaryRelatedEntry()?->isAvailable() ?? false))
|
||||||
|
->color('gray'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function getFooter(): ?View
|
public function getFooter(): ?View
|
||||||
{
|
{
|
||||||
return view('filament.resources.policy-version-resource.pages.view-policy-version-footer', [
|
return view('filament.resources.policy-version-resource.pages.view-policy-version-footer', [
|
||||||
'record' => $this->getRecord(),
|
'record' => $this->getRecord(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function primaryRelatedEntry(bool $fresh = false): ?RelatedContextEntry
|
||||||
|
{
|
||||||
|
$resolver = app(RelatedNavigationResolver::class);
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? $resolver->primaryListActionFresh(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $this->getRecord())
|
||||||
|
: $resolver->primaryListAction(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $this->getRecord());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Resources\ProviderConnectionResource\Pages;
|
use App\Filament\Resources\ProviderConnectionResource\Pages;
|
||||||
use App\Jobs\ProviderComplianceSnapshotJob;
|
use App\Jobs\ProviderComplianceSnapshotJob;
|
||||||
use App\Jobs\ProviderInventorySyncJob;
|
use App\Jobs\ProviderInventorySyncJob;
|
||||||
@ -11,15 +12,17 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Providers\CredentialManager;
|
use App\Services\Providers\ProviderConnectionMutationService;
|
||||||
use App\Services\Providers\ProviderOperationStartGate;
|
use App\Services\Providers\ProviderOperationStartGate;
|
||||||
use App\Services\Verification\StartVerification;
|
use App\Services\Verification\StartVerification;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
|
use App\Support\Providers\ProviderConnectionType;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
@ -29,9 +32,10 @@
|
|||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Forms\Components\Placeholder;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Infolists;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
@ -48,6 +52,8 @@
|
|||||||
|
|
||||||
class ProviderConnectionResource extends Resource
|
class ProviderConnectionResource extends Resource
|
||||||
{
|
{
|
||||||
|
use ResolvesPanelTenantContext;
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
protected static bool $isScopedToTenant = false;
|
protected static bool $isScopedToTenant = false;
|
||||||
@ -146,9 +152,7 @@ protected static function resolveScopedTenant(): ?Tenant
|
|||||||
return static::resolveTenantByExternalId($contextTenantExternalId);
|
return static::resolveTenantByExternalId($contextTenantExternalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
$filamentTenant = Filament::getTenant();
|
return static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
return $filamentTenant instanceof Tenant ? $filamentTenant : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function resolveTenantForRecord(?ProviderConnection $record = null): ?Tenant
|
public static function resolveTenantForRecord(?ProviderConnection $record = null): ?Tenant
|
||||||
@ -195,10 +199,10 @@ public static function resolveContextTenantExternalId(): ?string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$filamentTenant = Filament::getTenant();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if ($filamentTenant instanceof Tenant) {
|
if ($tenant instanceof Tenant) {
|
||||||
return (string) $filamentTenant->external_id;
|
return (string) $tenant->external_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@ -328,10 +332,10 @@ private static function applyMembershipScope(Builder $query): Builder
|
|||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! is_int($workspaceId)) {
|
if (! is_int($workspaceId)) {
|
||||||
$filamentTenant = Filament::getTenant();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if ($filamentTenant instanceof Tenant) {
|
if ($tenant instanceof Tenant) {
|
||||||
$workspaceId = (int) $filamentTenant->workspace_id;
|
$workspaceId = (int) $tenant->workspace_id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -400,6 +404,96 @@ private static function sanitizeErrorMessage(?string $value): ?string
|
|||||||
return Str::limit($normalized, 120);
|
return Str::limit($normalized, 120);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function providerConnectionTypeLabel(?ProviderConnection $record): string
|
||||||
|
{
|
||||||
|
$connectionType = $record?->connection_type;
|
||||||
|
|
||||||
|
if ($connectionType instanceof ProviderConnectionType && $connectionType === ProviderConnectionType::Dedicated) {
|
||||||
|
return 'Dedicated connection';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Platform connection';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function effectiveAppId(?ProviderConnection $record): string
|
||||||
|
{
|
||||||
|
$effectiveAppId = $record?->effectiveAppId();
|
||||||
|
|
||||||
|
return filled($effectiveAppId) ? (string) $effectiveAppId : 'Effective app pending review';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function credentialSourceLabel(?ProviderConnection $record): string
|
||||||
|
{
|
||||||
|
if (! $record instanceof ProviderConnection) {
|
||||||
|
return 'Managed centrally by platform';
|
||||||
|
}
|
||||||
|
|
||||||
|
$effectiveApp = $record->effectiveAppMetadata();
|
||||||
|
|
||||||
|
return match ($effectiveApp['source'] ?? null) {
|
||||||
|
'dedicated_credential' => 'Dedicated credential',
|
||||||
|
'review_required' => 'Legacy identity review required',
|
||||||
|
default => 'Managed centrally by platform',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function migrationReviewLabel(?ProviderConnection $record): string
|
||||||
|
{
|
||||||
|
return $record?->requiresMigrationReview() ? 'Review required' : 'Clear';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function migrationReviewDescription(?ProviderConnection $record): ?string
|
||||||
|
{
|
||||||
|
if (! $record instanceof ProviderConnection) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$metadata = $record->legacyIdentityMetadata();
|
||||||
|
$source = $metadata['legacy_identity_classification_source'] ?? null;
|
||||||
|
$result = $metadata['legacy_identity_result'] ?? null;
|
||||||
|
|
||||||
|
if (! $record->requiresMigrationReview()) {
|
||||||
|
return filled($source) ? sprintf('Classified via %s as %s.', $source, $result ?? 'platform') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$signals = $metadata['legacy_identity_signals'] ?? [];
|
||||||
|
$tenantClientId = $signals['tenant_client_id'] ?? null;
|
||||||
|
$credentialClientId = $signals['credential_client_id'] ?? null;
|
||||||
|
|
||||||
|
if (filled($tenantClientId) && filled($credentialClientId)) {
|
||||||
|
return sprintf('Legacy tenant app %s conflicts with dedicated app %s.', $tenantClientId, $credentialClientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Legacy app evidence conflicts with the current connection and needs explicit review.';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function consentStatusLabelFromState(mixed $state): string
|
||||||
|
{
|
||||||
|
$value = $state instanceof BackedEnum ? $state->value : (is_string($state) ? $state : 'unknown');
|
||||||
|
|
||||||
|
return match ($value) {
|
||||||
|
'required' => 'Required',
|
||||||
|
'granted' => 'Granted',
|
||||||
|
'failed' => 'Failed',
|
||||||
|
'revoked' => 'Revoked',
|
||||||
|
default => 'Unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function verificationStatusLabelFromState(mixed $state): string
|
||||||
|
{
|
||||||
|
$value = $state instanceof BackedEnum ? $state->value : (is_string($state) ? $state : 'unknown');
|
||||||
|
|
||||||
|
return match ($value) {
|
||||||
|
'pending' => 'Pending',
|
||||||
|
'healthy' => 'Healthy',
|
||||||
|
'degraded' => 'Degraded',
|
||||||
|
'blocked' => 'Blocked',
|
||||||
|
'error' => 'Error',
|
||||||
|
default => 'Unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
@ -417,6 +511,15 @@ public static function form(Schema $schema): Schema
|
|||||||
->maxLength(255)
|
->maxLength(255)
|
||||||
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
||||||
->rules(['uuid']),
|
->rules(['uuid']),
|
||||||
|
Placeholder::make('connection_type_display')
|
||||||
|
->label('Connection type')
|
||||||
|
->content(fn (?ProviderConnection $record): string => static::providerConnectionTypeLabel($record)),
|
||||||
|
Placeholder::make('platform_app_id_display')
|
||||||
|
->label('Effective app ID')
|
||||||
|
->content(fn (?ProviderConnection $record): string => static::effectiveAppId($record)),
|
||||||
|
Placeholder::make('effective_app_source_display')
|
||||||
|
->label('Effective app source')
|
||||||
|
->content(fn (?ProviderConnection $record): string => static::credentialSourceLabel($record)),
|
||||||
Toggle::make('is_default')
|
Toggle::make('is_default')
|
||||||
->label('Default connection')
|
->label('Default connection')
|
||||||
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
||||||
@ -426,6 +529,12 @@ public static function form(Schema $schema): Schema
|
|||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
Section::make('Status')
|
Section::make('Status')
|
||||||
->schema([
|
->schema([
|
||||||
|
Placeholder::make('consent_status_display')
|
||||||
|
->label('Consent')
|
||||||
|
->content(fn (?ProviderConnection $record): string => static::consentStatusLabelFromState($record?->consent_status)),
|
||||||
|
Placeholder::make('verification_status_display')
|
||||||
|
->label('Verification')
|
||||||
|
->content(fn (?ProviderConnection $record): string => static::verificationStatusLabelFromState($record?->verification_status)),
|
||||||
TextInput::make('status')
|
TextInput::make('status')
|
||||||
->label('Status')
|
->label('Status')
|
||||||
->disabled()
|
->disabled()
|
||||||
@ -434,17 +543,69 @@ public static function form(Schema $schema): Schema
|
|||||||
->label('Health')
|
->label('Health')
|
||||||
->disabled()
|
->disabled()
|
||||||
->dehydrated(false),
|
->dehydrated(false),
|
||||||
|
Placeholder::make('migration_review_status_display')
|
||||||
|
->label('Migration review')
|
||||||
|
->content(fn (?ProviderConnection $record): string => static::migrationReviewLabel($record))
|
||||||
|
->hint(fn (?ProviderConnection $record): ?string => static::migrationReviewDescription($record)),
|
||||||
])
|
])
|
||||||
->columns(2)
|
->columns(2)
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function infolist(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->schema([
|
||||||
|
Section::make('Connection')
|
||||||
|
->schema([
|
||||||
|
Infolists\Components\TextEntry::make('display_name')
|
||||||
|
->label('Display name'),
|
||||||
|
Infolists\Components\TextEntry::make('provider')
|
||||||
|
->label('Provider'),
|
||||||
|
Infolists\Components\TextEntry::make('entra_tenant_id')
|
||||||
|
->label('Entra tenant ID')
|
||||||
|
->copyable(),
|
||||||
|
Infolists\Components\TextEntry::make('connection_type')
|
||||||
|
->label('Connection type')
|
||||||
|
->formatStateUsing(fn (?ProviderConnectionType $state): string => $state === ProviderConnectionType::Dedicated
|
||||||
|
? 'Dedicated connection'
|
||||||
|
: 'Platform connection'),
|
||||||
|
Infolists\Components\TextEntry::make('effective_app_id')
|
||||||
|
->label('Effective app ID')
|
||||||
|
->state(fn (ProviderConnection $record): string => static::effectiveAppId($record))
|
||||||
|
->copyable(),
|
||||||
|
Infolists\Components\TextEntry::make('effective_app_source')
|
||||||
|
->label('Effective app source')
|
||||||
|
->state(fn (ProviderConnection $record): string => static::credentialSourceLabel($record)),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
Section::make('Status')
|
||||||
|
->schema([
|
||||||
|
Infolists\Components\TextEntry::make('consent_status')
|
||||||
|
->label('Consent')
|
||||||
|
->formatStateUsing(fn ($state): string => static::consentStatusLabelFromState($state)),
|
||||||
|
Infolists\Components\TextEntry::make('verification_status')
|
||||||
|
->label('Verification')
|
||||||
|
->formatStateUsing(fn ($state): string => static::verificationStatusLabelFromState($state)),
|
||||||
|
Infolists\Components\TextEntry::make('status')
|
||||||
|
->label('Status'),
|
||||||
|
Infolists\Components\TextEntry::make('health_status')
|
||||||
|
->label('Health'),
|
||||||
|
Infolists\Components\TextEntry::make('migration_review_required')
|
||||||
|
->label('Migration review')
|
||||||
|
->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record))
|
||||||
|
->tooltip(fn (ProviderConnection $record): ?string => static::migrationReviewDescription($record)),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
->modifyQueryUsing(function (Builder $query): Builder {
|
->modifyQueryUsing(function (Builder $query): Builder {
|
||||||
$query->with('tenant');
|
$query->with(['tenant', 'credential']);
|
||||||
|
|
||||||
$tenantExternalId = static::resolveRequestedTenantExternalId();
|
$tenantExternalId = static::resolveRequestedTenantExternalId();
|
||||||
|
|
||||||
@ -457,6 +618,10 @@ public static function table(Table $table): Table
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
->defaultSort('display_name')
|
->defaultSort('display_name')
|
||||||
|
->paginated(TablePaginationProfiles::resource())
|
||||||
|
->persistFiltersInSession()
|
||||||
|
->persistSearchInSession()
|
||||||
|
->persistSortInSession()
|
||||||
->recordUrl(fn (ProviderConnection $record): string => static::getUrl('view', ['record' => $record]))
|
->recordUrl(fn (ProviderConnection $record): string => static::getUrl('view', ['record' => $record]))
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('tenant.name')
|
Tables\Columns\TextColumn::make('tenant.name')
|
||||||
@ -479,10 +644,17 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin');
|
return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin');
|
||||||
}),
|
}),
|
||||||
Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable(),
|
Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable()->sortable(),
|
||||||
Tables\Columns\TextColumn::make('provider')->label('Provider')->toggleable(),
|
Tables\Columns\TextColumn::make('provider')->label('Provider')->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('entra_tenant_id')->label('Entra tenant ID')->copyable()->toggleable(),
|
Tables\Columns\TextColumn::make('entra_tenant_id')->label('Entra tenant ID')->copyable()->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\IconColumn::make('is_default')->label('Default')->boolean(),
|
Tables\Columns\IconColumn::make('is_default')->label('Default')->boolean(),
|
||||||
|
Tables\Columns\TextColumn::make('connection_type')
|
||||||
|
->label('Connection type')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(fn (?ProviderConnectionType $state): string => $state === ProviderConnectionType::Dedicated
|
||||||
|
? 'Dedicated'
|
||||||
|
: 'Platform')
|
||||||
|
->color(fn (?ProviderConnectionType $state): string => $state === ProviderConnectionType::Dedicated ? 'info' : 'gray'),
|
||||||
Tables\Columns\TextColumn::make('status')
|
Tables\Columns\TextColumn::make('status')
|
||||||
->label('Status')
|
->label('Status')
|
||||||
->badge()
|
->badge()
|
||||||
@ -497,14 +669,20 @@ public static function table(Table $table): Table
|
|||||||
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionHealth))
|
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionHealth))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth))
|
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)),
|
||||||
Tables\Columns\TextColumn::make('last_health_check_at')->label('Last check')->since()->toggleable(),
|
Tables\Columns\TextColumn::make('migration_review_required')
|
||||||
|
->label('Migration review')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(fn (bool $state): string => $state ? 'Review required' : 'Clear')
|
||||||
|
->color(fn (bool $state): string => $state ? 'warning' : 'success')
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
Tables\Columns\TextColumn::make('last_health_check_at')->label('Last check')->since()->sortable(),
|
||||||
Tables\Columns\TextColumn::make('last_error_reason_code')
|
Tables\Columns\TextColumn::make('last_error_reason_code')
|
||||||
->label('Last error reason')
|
->label('Last error reason')
|
||||||
->toggleable(),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('last_error_message')
|
Tables\Columns\TextColumn::make('last_error_message')
|
||||||
->label('Last error message')
|
->label('Last error message')
|
||||||
->formatStateUsing(fn (?string $state): ?string => static::sanitizeErrorMessage($state))
|
->formatStateUsing(fn (?string $state): ?string => static::sanitizeErrorMessage($state))
|
||||||
->toggleable(),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
SelectFilter::make('tenant')
|
SelectFilter::make('tenant')
|
||||||
@ -646,9 +824,12 @@ public static function table(Table $table): Table
|
|||||||
? (string) $result->run->context['reason_code']
|
? (string) $result->run->context['reason_code']
|
||||||
: 'unknown_error';
|
: 'unknown_error';
|
||||||
|
|
||||||
|
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||||
|
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Connection check blocked')
|
->title('Connection check blocked')
|
||||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
->body(implode("\n", $bodyLines))
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
@ -743,9 +924,12 @@ public static function table(Table $table): Table
|
|||||||
? (string) $result->run->context['reason_code']
|
? (string) $result->run->context['reason_code']
|
||||||
: 'unknown_error';
|
: 'unknown_error';
|
||||||
|
|
||||||
|
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||||
|
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Inventory sync blocked')
|
->title('Inventory sync blocked')
|
||||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
->body(implode("\n", $bodyLines))
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
@ -837,9 +1021,12 @@ public static function table(Table $table): Table
|
|||||||
? (string) $result->run->context['reason_code']
|
? (string) $result->run->context['reason_code']
|
||||||
: 'unknown_error';
|
: 'unknown_error';
|
||||||
|
|
||||||
|
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||||
|
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Compliance snapshot blocked')
|
->title('Compliance snapshot blocked')
|
||||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
->body(implode("\n", $bodyLines))
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
@ -915,31 +1102,32 @@ public static function table(Table $table): Table
|
|||||||
->apply(),
|
->apply(),
|
||||||
|
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('update_credentials')
|
Actions\Action::make('enable_dedicated_override')
|
||||||
->label('Update credentials')
|
->label('Enable dedicated override')
|
||||||
->icon('heroicon-o-key')
|
->icon('heroicon-o-key')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalDescription('Client secret is stored encrypted and will never be shown again.')
|
->modalDescription('Dedicated credentials are stored encrypted and reset consent to the dedicated app registration.')
|
||||||
|
->visible(fn (ProviderConnection $record): bool => $record->connection_type !== ProviderConnectionType::Dedicated)
|
||||||
->form([
|
->form([
|
||||||
TextInput::make('client_id')
|
TextInput::make('client_id')
|
||||||
->label('Client ID')
|
->label('Dedicated app (client) ID')
|
||||||
->required()
|
->required()
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
TextInput::make('client_secret')
|
TextInput::make('client_secret')
|
||||||
->label('Client secret')
|
->label('Dedicated client secret')
|
||||||
->password()
|
->password()
|
||||||
->required()
|
->required()
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
])
|
])
|
||||||
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
|
->action(function (array $data, ProviderConnection $record, ProviderConnectionMutationService $mutations, AuditLogger $auditLogger): void {
|
||||||
$tenant = static::resolveTenantForRecord($record);
|
$tenant = static::resolveTenantForRecord($record);
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$credentials->upsertClientSecretCredential(
|
$mutations->enableDedicatedOverride(
|
||||||
connection: $record,
|
connection: $record,
|
||||||
clientId: (string) $data['client_id'],
|
clientId: (string) $data['client_id'],
|
||||||
clientSecret: (string) $data['client_secret'],
|
clientSecret: (string) $data['client_secret'],
|
||||||
@ -952,12 +1140,16 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'provider_connection.credentials_updated',
|
action: 'provider_connection.connection_type_changed',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
|
'provider_connection_id' => (int) $record->getKey(),
|
||||||
'provider' => $record->provider,
|
'provider' => $record->provider,
|
||||||
'entra_tenant_id' => $record->entra_tenant_id,
|
'entra_tenant_id' => $record->entra_tenant_id,
|
||||||
|
'from_connection_type' => ProviderConnectionType::Platform->value,
|
||||||
|
'to_connection_type' => ProviderConnectionType::Dedicated->value,
|
||||||
'client_id' => (string) $data['client_id'],
|
'client_id' => (string) $data['client_id'],
|
||||||
|
'source' => 'provider_connection.resource_table',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
actorId: $actorId,
|
actorId: $actorId,
|
||||||
@ -969,12 +1161,138 @@ public static function table(Table $table): Table
|
|||||||
);
|
);
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Credentials updated')
|
->title('Dedicated override enabled')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('rotate_dedicated_credential')
|
||||||
|
->label('Rotate dedicated credential')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('primary')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (ProviderConnection $record): bool => $record->connection_type === ProviderConnectionType::Dedicated)
|
||||||
|
->form([
|
||||||
|
TextInput::make('client_id')
|
||||||
|
->label('Dedicated app (client) ID')
|
||||||
|
->default(function (ProviderConnection $record): string {
|
||||||
|
$payload = $record->credential?->payload;
|
||||||
|
|
||||||
|
return is_array($payload) ? (string) ($payload['client_id'] ?? '') : '';
|
||||||
|
})
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('client_secret')
|
||||||
|
->label('Dedicated client secret')
|
||||||
|
->password()
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
])
|
||||||
|
->action(function (array $data, ProviderConnection $record, ProviderConnectionMutationService $mutations): void {
|
||||||
|
$tenant = static::resolveTenantForRecord($record);
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mutations->enableDedicatedOverride(
|
||||||
|
connection: $record,
|
||||||
|
clientId: (string) $data['client_id'],
|
||||||
|
clientSecret: (string) $data['client_secret'],
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Dedicated credential rotated')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('delete_dedicated_credential')
|
||||||
|
->label('Delete dedicated credential')
|
||||||
|
->icon('heroicon-o-trash')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (ProviderConnection $record): bool => $record->connection_type === ProviderConnectionType::Dedicated
|
||||||
|
&& $record->credential()->exists())
|
||||||
|
->action(function (ProviderConnection $record, ProviderConnectionMutationService $mutations): void {
|
||||||
|
$tenant = static::resolveTenantForRecord($record);
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mutations->deleteDedicatedCredential($record);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Dedicated credential deleted')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('revert_to_platform')
|
||||||
|
->label('Revert to platform')
|
||||||
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
|
->color('gray')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (ProviderConnection $record): bool => $record->connection_type === ProviderConnectionType::Dedicated)
|
||||||
|
->action(function (ProviderConnection $record, ProviderConnectionMutationService $mutations, AuditLogger $auditLogger): void {
|
||||||
|
$tenant = static::resolveTenantForRecord($record);
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mutations->revertToPlatform($record);
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
||||||
|
$actorEmail = $user instanceof User ? $user->email : null;
|
||||||
|
$actorName = $user instanceof User ? $user->name : null;
|
||||||
|
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'provider_connection.connection_type_changed',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'provider_connection_id' => (int) $record->getKey(),
|
||||||
|
'provider' => $record->provider,
|
||||||
|
'entra_tenant_id' => $record->entra_tenant_id,
|
||||||
|
'from_connection_type' => ProviderConnectionType::Dedicated->value,
|
||||||
|
'to_connection_type' => ProviderConnectionType::Platform->value,
|
||||||
|
'source' => 'provider_connection.resource_table',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorId: $actorId,
|
||||||
|
actorEmail: $actorEmail,
|
||||||
|
actorName: $actorName,
|
||||||
|
resourceType: 'provider_connection',
|
||||||
|
resourceId: (string) $record->getKey(),
|
||||||
|
status: 'success',
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Connection reverted to platform')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||||
|
->preserveVisibility()
|
||||||
->apply(),
|
->apply(),
|
||||||
|
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
|
|||||||
@ -6,6 +6,11 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
|
use App\Services\Providers\ProviderConnectionStateProjector;
|
||||||
|
use App\Support\Providers\ProviderConnectionType;
|
||||||
|
use App\Support\Providers\ProviderConsentStatus;
|
||||||
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
use App\Support\Providers\ProviderVerificationStatus;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
@ -25,12 +30,31 @@ protected function mutateFormDataBeforeCreate(array $data): array
|
|||||||
|
|
||||||
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
|
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
|
||||||
|
|
||||||
|
$projectedState = app(ProviderConnectionStateProjector::class)->project(
|
||||||
|
connectionType: ProviderConnectionType::Platform,
|
||||||
|
consentStatus: ProviderConsentStatus::Required,
|
||||||
|
verificationStatus: ProviderVerificationStatus::Unknown,
|
||||||
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
'tenant_id' => $tenant->getKey(),
|
'tenant_id' => $tenant->getKey(),
|
||||||
'provider' => 'microsoft',
|
'provider' => 'microsoft',
|
||||||
'entra_tenant_id' => $data['entra_tenant_id'],
|
'entra_tenant_id' => $data['entra_tenant_id'],
|
||||||
'display_name' => $data['display_name'],
|
'display_name' => $data['display_name'],
|
||||||
|
'connection_type' => ProviderConnectionType::Platform->value,
|
||||||
|
'status' => $projectedState['status'],
|
||||||
|
'consent_status' => ProviderConsentStatus::Required->value,
|
||||||
|
'consent_granted_at' => null,
|
||||||
|
'consent_last_checked_at' => null,
|
||||||
|
'consent_error_code' => null,
|
||||||
|
'consent_error_message' => null,
|
||||||
|
'verification_status' => ProviderVerificationStatus::Unknown->value,
|
||||||
|
'health_status' => $projectedState['health_status'],
|
||||||
|
'migration_review_required' => false,
|
||||||
|
'migration_reviewed_at' => null,
|
||||||
|
'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing,
|
||||||
|
'last_error_message' => null,
|
||||||
'is_default' => false,
|
'is_default' => false,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -57,6 +81,7 @@ protected function afterCreate(): void
|
|||||||
'metadata' => [
|
'metadata' => [
|
||||||
'provider' => $record->provider,
|
'provider' => $record->provider,
|
||||||
'entra_tenant_id' => $record->entra_tenant_id,
|
'entra_tenant_id' => $record->entra_tenant_id,
|
||||||
|
'connection_type' => $record->connection_type->value,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
actorId: $actorId,
|
actorId: $actorId,
|
||||||
|
|||||||
@ -11,13 +11,14 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Providers\CredentialManager;
|
use App\Services\Providers\ProviderConnectionMutationService;
|
||||||
use App\Services\Providers\ProviderOperationStartGate;
|
use App\Services\Providers\ProviderOperationStartGate;
|
||||||
use App\Services\Verification\StartVerification;
|
use App\Services\Verification\StartVerification;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
|
use App\Support\Providers\ProviderConnectionType;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -277,9 +278,12 @@ protected function getHeaderActions(): array
|
|||||||
? (string) $result->run->context['reason_code']
|
? (string) $result->run->context['reason_code']
|
||||||
: 'unknown_error';
|
: 'unknown_error';
|
||||||
|
|
||||||
|
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||||
|
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Connection check blocked')
|
->title('Connection check blocked')
|
||||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
->body(implode("\n", $bodyLines))
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
@ -310,32 +314,33 @@ protected function getHeaderActions(): array
|
|||||||
->apply(),
|
->apply(),
|
||||||
|
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Action::make('update_credentials')
|
Action::make('enable_dedicated_override')
|
||||||
->label('Update credentials')
|
->label('Enable dedicated override')
|
||||||
->icon('heroicon-o-key')
|
->icon('heroicon-o-key')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalDescription('Client secret is stored encrypted and will never be shown again.')
|
->modalDescription('Dedicated credentials are stored encrypted and reset consent to the dedicated app registration.')
|
||||||
->visible(fn (): bool => $tenant instanceof Tenant)
|
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
||||||
|
&& $record->connection_type !== ProviderConnectionType::Dedicated)
|
||||||
->form([
|
->form([
|
||||||
TextInput::make('client_id')
|
TextInput::make('client_id')
|
||||||
->label('Client ID')
|
->label('Dedicated app (client) ID')
|
||||||
->required()
|
->required()
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
TextInput::make('client_secret')
|
TextInput::make('client_secret')
|
||||||
->label('Client secret')
|
->label('Dedicated client secret')
|
||||||
->password()
|
->password()
|
||||||
->required()
|
->required()
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
])
|
])
|
||||||
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
|
->action(function (array $data, ProviderConnection $record, ProviderConnectionMutationService $mutations, AuditLogger $auditLogger): void {
|
||||||
$tenant = $this->currentTenant();
|
$tenant = $this->currentTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$credentials->upsertClientSecretCredential(
|
$mutations->enableDedicatedOverride(
|
||||||
connection: $record,
|
connection: $record,
|
||||||
clientId: (string) $data['client_id'],
|
clientId: (string) $data['client_id'],
|
||||||
clientSecret: (string) $data['client_secret'],
|
clientSecret: (string) $data['client_secret'],
|
||||||
@ -348,12 +353,16 @@ protected function getHeaderActions(): array
|
|||||||
|
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'provider_connection.credentials_updated',
|
action: 'provider_connection.connection_type_changed',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
|
'provider_connection_id' => (int) $record->getKey(),
|
||||||
'provider' => $record->provider,
|
'provider' => $record->provider,
|
||||||
'entra_tenant_id' => $record->entra_tenant_id,
|
'entra_tenant_id' => $record->entra_tenant_id,
|
||||||
|
'from_connection_type' => ProviderConnectionType::Platform->value,
|
||||||
|
'to_connection_type' => ProviderConnectionType::Dedicated->value,
|
||||||
'client_id' => (string) $data['client_id'],
|
'client_id' => (string) $data['client_id'],
|
||||||
|
'source' => 'provider_connection.edit_page',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
actorId: $actorId,
|
actorId: $actorId,
|
||||||
@ -365,13 +374,143 @@ protected function getHeaderActions(): array
|
|||||||
);
|
);
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Credentials updated')
|
->title('Dedicated override enabled')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||||
->tooltip('You do not have permission to manage provider connections.')
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Action::make('rotate_dedicated_credential')
|
||||||
|
->label('Rotate dedicated credential')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('primary')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalDescription('Stores a replacement dedicated client secret and refreshes dedicated identity state.')
|
||||||
|
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
||||||
|
&& $record->connection_type === ProviderConnectionType::Dedicated)
|
||||||
|
->form([
|
||||||
|
TextInput::make('client_id')
|
||||||
|
->label('Dedicated app (client) ID')
|
||||||
|
->default(function (ProviderConnection $record): string {
|
||||||
|
$payload = $record->credential?->payload;
|
||||||
|
|
||||||
|
return is_array($payload) ? (string) ($payload['client_id'] ?? '') : '';
|
||||||
|
})
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('client_secret')
|
||||||
|
->label('Dedicated client secret')
|
||||||
|
->password()
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
])
|
||||||
|
->action(function (array $data, ProviderConnection $record, ProviderConnectionMutationService $mutations): void {
|
||||||
|
$tenant = $this->currentTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$mutations->enableDedicatedOverride(
|
||||||
|
connection: $record,
|
||||||
|
clientId: (string) $data['client_id'],
|
||||||
|
clientSecret: (string) $data['client_secret'],
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Dedicated credential rotated')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Action::make('delete_dedicated_credential')
|
||||||
|
->label('Delete dedicated credential')
|
||||||
|
->icon('heroicon-o-trash')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalDescription('Deletes the dedicated credential and leaves the connection blocked until a replacement is added or the type is reverted.')
|
||||||
|
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
||||||
|
&& $record->connection_type === ProviderConnectionType::Dedicated
|
||||||
|
&& $record->credential()->exists())
|
||||||
|
->action(function (ProviderConnection $record, ProviderConnectionMutationService $mutations): void {
|
||||||
|
$tenant = $this->currentTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$mutations->deleteDedicatedCredential($record);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Dedicated credential deleted')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Action::make('revert_to_platform')
|
||||||
|
->label('Revert to platform')
|
||||||
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
|
->color('gray')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalDescription('Reverts the connection to the platform-managed identity and removes any dedicated credential.')
|
||||||
|
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
||||||
|
&& $record->connection_type === ProviderConnectionType::Dedicated)
|
||||||
|
->action(function (ProviderConnection $record, ProviderConnectionMutationService $mutations, AuditLogger $auditLogger): void {
|
||||||
|
$tenant = $this->currentTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$mutations->revertToPlatform($record);
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
||||||
|
$actorEmail = $user instanceof User ? $user->email : null;
|
||||||
|
$actorName = $user instanceof User ? $user->name : null;
|
||||||
|
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'provider_connection.connection_type_changed',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'provider_connection_id' => (int) $record->getKey(),
|
||||||
|
'provider' => $record->provider,
|
||||||
|
'entra_tenant_id' => $record->entra_tenant_id,
|
||||||
|
'from_connection_type' => ProviderConnectionType::Dedicated->value,
|
||||||
|
'to_connection_type' => ProviderConnectionType::Platform->value,
|
||||||
|
'source' => 'provider_connection.edit_page',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorId: $actorId,
|
||||||
|
actorEmail: $actorEmail,
|
||||||
|
actorName: $actorName,
|
||||||
|
resourceType: 'provider_connection',
|
||||||
|
resourceId: (string) $record->getKey(),
|
||||||
|
status: 'success',
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Connection reverted to platform')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->apply(),
|
->apply(),
|
||||||
|
|
||||||
@ -511,9 +650,12 @@ protected function getHeaderActions(): array
|
|||||||
? (string) $result->run->context['reason_code']
|
? (string) $result->run->context['reason_code']
|
||||||
: 'unknown_error';
|
: 'unknown_error';
|
||||||
|
|
||||||
|
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||||
|
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Inventory sync blocked')
|
->title('Inventory sync blocked')
|
||||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
->body(implode("\n", $bodyLines))
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
@ -622,9 +764,12 @@ protected function getHeaderActions(): array
|
|||||||
? (string) $result->run->context['reason_code']
|
? (string) $result->run->context['reason_code']
|
||||||
: 'unknown_error';
|
: 'unknown_error';
|
||||||
|
|
||||||
|
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||||
|
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Compliance snapshot blocked')
|
->title('Compliance snapshot blocked')
|
||||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
->body(implode("\n", $bodyLines))
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
|
|||||||
@ -7,14 +7,26 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
class ListProviderConnections extends ListRecords
|
class ListProviderConnections extends ListRecords
|
||||||
{
|
{
|
||||||
protected static string $resource = ProviderConnectionResource::class;
|
protected static string $resource = ProviderConnectionResource::class;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||||
|
$this->getTableFiltersSessionKey(),
|
||||||
|
request: request(),
|
||||||
|
tenantFilterName: 'tenant',
|
||||||
|
tenantAttribute: 'external_id',
|
||||||
|
);
|
||||||
|
|
||||||
|
parent::mount();
|
||||||
|
}
|
||||||
|
|
||||||
private function tableHasRecords(): bool
|
private function tableHasRecords(): bool
|
||||||
{
|
{
|
||||||
return $this->getTableRecords()->count() > 0;
|
return $this->getTableRecords()->count() > 0;
|
||||||
@ -207,9 +219,7 @@ private function resolveTenantExternalIdForCreateAction(): ?string
|
|||||||
return $requested;
|
return $requested;
|
||||||
}
|
}
|
||||||
|
|
||||||
$filamentTenant = Filament::getTenant();
|
return ProviderConnectionResource::resolveContextTenantExternalId();
|
||||||
|
|
||||||
return $filamentTenant instanceof Tenant ? (string) $filamentTenant->external_id : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveTenantForCreateAction(): ?Tenant
|
private function resolveTenantForCreateAction(): ?Tenant
|
||||||
@ -227,12 +237,12 @@ private function resolveTenantForCreateAction(): ?Tenant
|
|||||||
|
|
||||||
public function getTableEmptyStateHeading(): ?string
|
public function getTableEmptyStateHeading(): ?string
|
||||||
{
|
{
|
||||||
return 'No provider connections found';
|
return 'No Microsoft connections found';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getTableEmptyStateDescription(): ?string
|
public function getTableEmptyStateDescription(): ?string
|
||||||
{
|
{
|
||||||
return 'Create a Microsoft provider connection or adjust the tenant filter to inspect another managed tenant.';
|
return 'Start with a platform-managed Microsoft connection. Dedicated overrides are handled separately with stronger authorization.';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getTableEmptyStateActions(): array
|
public function getTableEmptyStateActions(): array
|
||||||
|
|||||||
@ -3,9 +3,17 @@
|
|||||||
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
|
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\ProviderConnectionResource;
|
use App\Filament\Resources\ProviderConnectionResource;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Intune\AuditLogger;
|
||||||
|
use App\Services\Providers\ProviderConnectionMutationService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Links\RequiredPermissionsLinks;
|
||||||
|
use App\Support\Providers\ProviderConnectionType;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
class ViewProviderConnection extends ViewRecord
|
class ViewProviderConnection extends ViewRecord
|
||||||
@ -15,6 +23,24 @@ class ViewProviderConnection extends ViewRecord
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('grant_admin_consent')
|
||||||
|
->label('Grant admin consent')
|
||||||
|
->icon('heroicon-o-clipboard-document')
|
||||||
|
->url(function (): ?string {
|
||||||
|
$tenant = ProviderConnectionResource::resolveTenantForRecord($this->record);
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant
|
||||||
|
? RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant)
|
||||||
|
: null;
|
||||||
|
})
|
||||||
|
->visible(function (): bool {
|
||||||
|
return ProviderConnectionResource::resolveTenantForRecord($this->record) instanceof Tenant;
|
||||||
|
})
|
||||||
|
->openUrlInNewTab()
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||||
|
->apply(),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('edit')
|
Actions\Action::make('edit')
|
||||||
->label('Edit')
|
->label('Edit')
|
||||||
@ -23,6 +49,201 @@ protected function getHeaderActions(): array
|
|||||||
)
|
)
|
||||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||||
->apply(),
|
->apply(),
|
||||||
|
Actions\ActionGroup::make([
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('enable_dedicated_override')
|
||||||
|
->label('Enable dedicated override')
|
||||||
|
->icon('heroicon-o-key')
|
||||||
|
->color('primary')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalDescription('Dedicated credentials are stored encrypted and reset consent to the dedicated app registration.')
|
||||||
|
->visible(fn (): bool => $this->record->connection_type !== ProviderConnectionType::Dedicated)
|
||||||
|
->form([
|
||||||
|
TextInput::make('client_id')
|
||||||
|
->label('Dedicated app (client) ID')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('client_secret')
|
||||||
|
->label('Dedicated client secret')
|
||||||
|
->password()
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
])
|
||||||
|
->action(function (array $data, ProviderConnectionMutationService $mutations, AuditLogger $auditLogger): void {
|
||||||
|
$tenant = ProviderConnectionResource::resolveTenantForRecord($this->record);
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$mutations->enableDedicatedOverride(
|
||||||
|
connection: $this->record,
|
||||||
|
clientId: (string) $data['client_id'],
|
||||||
|
clientSecret: (string) $data['client_secret'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
||||||
|
$actorEmail = $user instanceof User ? $user->email : null;
|
||||||
|
$actorName = $user instanceof User ? $user->name : null;
|
||||||
|
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'provider_connection.connection_type_changed',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'provider_connection_id' => (int) $this->record->getKey(),
|
||||||
|
'provider' => $this->record->provider,
|
||||||
|
'entra_tenant_id' => $this->record->entra_tenant_id,
|
||||||
|
'from_connection_type' => ProviderConnectionType::Platform->value,
|
||||||
|
'to_connection_type' => ProviderConnectionType::Dedicated->value,
|
||||||
|
'client_id' => (string) $data['client_id'],
|
||||||
|
'source' => 'provider_connection.view_page',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorId: $actorId,
|
||||||
|
actorEmail: $actorEmail,
|
||||||
|
actorName: $actorName,
|
||||||
|
resourceType: 'provider_connection',
|
||||||
|
resourceId: (string) $this->record->getKey(),
|
||||||
|
status: 'success',
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Dedicated override enabled')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('rotate_dedicated_credential')
|
||||||
|
->label('Rotate dedicated credential')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('primary')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (): bool => $this->record->connection_type === ProviderConnectionType::Dedicated)
|
||||||
|
->form([
|
||||||
|
TextInput::make('client_id')
|
||||||
|
->label('Dedicated app (client) ID')
|
||||||
|
->default(function (): string {
|
||||||
|
$payload = $this->record->credential?->payload;
|
||||||
|
|
||||||
|
return is_array($payload) ? (string) ($payload['client_id'] ?? '') : '';
|
||||||
|
})
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('client_secret')
|
||||||
|
->label('Dedicated client secret')
|
||||||
|
->password()
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
])
|
||||||
|
->action(function (array $data, ProviderConnectionMutationService $mutations): void {
|
||||||
|
$tenant = ProviderConnectionResource::resolveTenantForRecord($this->record);
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$mutations->enableDedicatedOverride(
|
||||||
|
connection: $this->record,
|
||||||
|
clientId: (string) $data['client_id'],
|
||||||
|
clientSecret: (string) $data['client_secret'],
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Dedicated credential rotated')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('delete_dedicated_credential')
|
||||||
|
->label('Delete dedicated credential')
|
||||||
|
->icon('heroicon-o-trash')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (): bool => $this->record->connection_type === ProviderConnectionType::Dedicated
|
||||||
|
&& $this->record->credential()->exists())
|
||||||
|
->action(function (ProviderConnectionMutationService $mutations): void {
|
||||||
|
$tenant = ProviderConnectionResource::resolveTenantForRecord($this->record);
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$mutations->deleteDedicatedCredential($this->record);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Dedicated credential deleted')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('revert_to_platform')
|
||||||
|
->label('Revert to platform')
|
||||||
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
|
->color('gray')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (): bool => $this->record->connection_type === ProviderConnectionType::Dedicated)
|
||||||
|
->action(function (ProviderConnectionMutationService $mutations, AuditLogger $auditLogger): void {
|
||||||
|
$tenant = ProviderConnectionResource::resolveTenantForRecord($this->record);
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$mutations->revertToPlatform($this->record);
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
||||||
|
$actorEmail = $user instanceof User ? $user->email : null;
|
||||||
|
$actorName = $user instanceof User ? $user->name : null;
|
||||||
|
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'provider_connection.connection_type_changed',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'provider_connection_id' => (int) $this->record->getKey(),
|
||||||
|
'provider' => $this->record->provider,
|
||||||
|
'entra_tenant_id' => $this->record->entra_tenant_id,
|
||||||
|
'from_connection_type' => ProviderConnectionType::Dedicated->value,
|
||||||
|
'to_connection_type' => ProviderConnectionType::Platform->value,
|
||||||
|
'source' => 'provider_connection.view_page',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorId: $actorId,
|
||||||
|
actorEmail: $actorEmail,
|
||||||
|
actorName: $actorName,
|
||||||
|
resourceType: 'provider_connection',
|
||||||
|
resourceId: (string) $this->record->getKey(),
|
||||||
|
status: 'success',
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Connection reverted to platform')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
|
])
|
||||||
|
->label('Manage dedicated override')
|
||||||
|
->icon('heroicon-o-cog-6-tooth')
|
||||||
|
->color('gray'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
use App\Contracts\Hardening\WriteGateInterface;
|
use App\Contracts\Hardening\WriteGateInterface;
|
||||||
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
|
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
|
||||||
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||||
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Resources\RestoreRunResource\Pages;
|
use App\Filament\Resources\RestoreRunResource\Pages;
|
||||||
use App\Jobs\BulkRestoreRunDeleteJob;
|
use App\Jobs\BulkRestoreRunDeleteJob;
|
||||||
use App\Jobs\BulkRestoreRunForceDeleteJob;
|
use App\Jobs\BulkRestoreRunForceDeleteJob;
|
||||||
@ -27,6 +29,8 @@
|
|||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
|
use App\Support\Filament\FilterPresets;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
@ -52,6 +56,7 @@
|
|||||||
use Filament\Tables\Contracts\HasTable;
|
use Filament\Tables\Contracts\HasTable;
|
||||||
use Filament\Tables\Filters\TrashedFilter;
|
use Filament\Tables\Filters\TrashedFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\QueryException;
|
use Illuminate\Database\QueryException;
|
||||||
@ -62,6 +67,9 @@
|
|||||||
|
|
||||||
class RestoreRunResource extends Resource
|
class RestoreRunResource extends Resource
|
||||||
{
|
{
|
||||||
|
use InteractsWithTenantOwnedRecords;
|
||||||
|
use ResolvesPanelTenantContext;
|
||||||
|
|
||||||
protected static ?string $model = RestoreRun::class;
|
protected static ?string $model = RestoreRun::class;
|
||||||
|
|
||||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||||
@ -81,7 +89,7 @@ public static function shouldRegisterNavigation(): bool
|
|||||||
|
|
||||||
public static function canCreate(): bool
|
public static function canCreate(): bool
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
@ -102,7 +110,7 @@ public static function form(Schema $schema): Schema
|
|||||||
Forms\Components\Select::make('backup_set_id')
|
Forms\Components\Select::make('backup_set_id')
|
||||||
->label('Backup set')
|
->label('Backup set')
|
||||||
->options(function () {
|
->options(function () {
|
||||||
$tenantId = Tenant::currentOrFail()->getKey();
|
$tenantId = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
|
||||||
|
|
||||||
return BackupSet::query()
|
return BackupSet::query()
|
||||||
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
|
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
|
||||||
@ -142,7 +150,7 @@ public static function form(Schema $schema): Schema
|
|||||||
->schema(function (Get $get): array {
|
->schema(function (Get $get): array {
|
||||||
$backupSetId = $get('backup_set_id');
|
$backupSetId = $get('backup_set_id');
|
||||||
$selectedItemIds = $get('backup_item_ids');
|
$selectedItemIds = $get('backup_item_ids');
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant || ! $backupSetId) {
|
if (! $tenant || ! $backupSetId) {
|
||||||
return [];
|
return [];
|
||||||
@ -196,7 +204,7 @@ public static function form(Schema $schema): Schema
|
|||||||
->label('Sync Groups')
|
->label('Sync Groups')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('warning')
|
->color('warning')
|
||||||
->url(fn (): string => EntraGroupResource::getUrl('index', tenant: Tenant::current()))
|
->url(fn (): string => EntraGroupResource::getUrl('index', tenant: static::resolveTenantContextForCurrentPanel()))
|
||||||
->visible(fn (): bool => $cacheNotice !== null)
|
->visible(fn (): bool => $cacheNotice !== null)
|
||||||
);
|
);
|
||||||
}, $unresolved);
|
}, $unresolved);
|
||||||
@ -204,7 +212,7 @@ public static function form(Schema $schema): Schema
|
|||||||
->visible(function (Get $get): bool {
|
->visible(function (Get $get): bool {
|
||||||
$backupSetId = $get('backup_set_id');
|
$backupSetId = $get('backup_set_id');
|
||||||
$selectedItemIds = $get('backup_item_ids');
|
$selectedItemIds = $get('backup_item_ids');
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant || ! $backupSetId) {
|
if (! $tenant || ! $backupSetId) {
|
||||||
return false;
|
return false;
|
||||||
@ -234,6 +242,48 @@ public static function makeCreateAction(): Actions\CreateAction
|
|||||||
return $action;
|
return $action;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function getEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
return static::scopeTenantOwnedQuery(parent::getEloquentQuery())
|
||||||
|
->with('backupSet');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function resolveScopedRecordOrFail(int|string $key): Model
|
||||||
|
{
|
||||||
|
return static::resolveTenantOwnedRecordOrFail(
|
||||||
|
$key,
|
||||||
|
parent::getEloquentQuery()->withTrashed()->with('backupSet'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function resolveProtectedRestoreRunRecordOrFail(RestoreRun|int|string $record): RestoreRun
|
||||||
|
{
|
||||||
|
$resolvedRecord = static::resolveScopedRecordOrFail($record instanceof Model ? $record->getKey() : $record);
|
||||||
|
|
||||||
|
if (! $resolvedRecord instanceof RestoreRun) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolvedRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, int>
|
||||||
|
*/
|
||||||
|
protected static function resolveProtectedRestoreRunIds(Collection $records): array
|
||||||
|
{
|
||||||
|
return $records
|
||||||
|
->map(function (mixed $record): int {
|
||||||
|
$resolvedRecord = static::resolveProtectedRestoreRunRecordOrFail($record instanceof RestoreRun ? $record : (is_numeric($record) ? (int) $record : 0));
|
||||||
|
|
||||||
|
return (int) $resolvedRecord->getKey();
|
||||||
|
})
|
||||||
|
->filter(fn (int $value): bool => $value > 0)
|
||||||
|
->unique()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, Step>
|
* @return array<int, Step>
|
||||||
*/
|
*/
|
||||||
@ -246,7 +296,7 @@ public static function getWizardSteps(): array
|
|||||||
Forms\Components\Select::make('backup_set_id')
|
Forms\Components\Select::make('backup_set_id')
|
||||||
->label('Backup set')
|
->label('Backup set')
|
||||||
->options(function () {
|
->options(function () {
|
||||||
$tenantId = Tenant::currentOrFail()->getKey();
|
$tenantId = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
|
||||||
|
|
||||||
return BackupSet::query()
|
return BackupSet::query()
|
||||||
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
|
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
|
||||||
@ -271,7 +321,7 @@ public static function getWizardSteps(): array
|
|||||||
backupSetId: $get('backup_set_id'),
|
backupSetId: $get('backup_set_id'),
|
||||||
scopeMode: 'all',
|
scopeMode: 'all',
|
||||||
selectedItemIds: null,
|
selectedItemIds: null,
|
||||||
tenant: Tenant::current(),
|
tenant: static::resolveTenantContextForCurrentPanel(),
|
||||||
));
|
));
|
||||||
$set('is_dry_run', true);
|
$set('is_dry_run', true);
|
||||||
$set('acknowledged_impact', false);
|
$set('acknowledged_impact', false);
|
||||||
@ -298,7 +348,7 @@ public static function getWizardSteps(): array
|
|||||||
->reactive()
|
->reactive()
|
||||||
->afterStateUpdated(function (Set $set, Get $get, $state): void {
|
->afterStateUpdated(function (Set $set, Get $get, $state): void {
|
||||||
$backupSetId = $get('backup_set_id');
|
$backupSetId = $get('backup_set_id');
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$set('is_dry_run', true);
|
$set('is_dry_run', true);
|
||||||
$set('acknowledged_impact', false);
|
$set('acknowledged_impact', false);
|
||||||
$set('tenant_confirm', null);
|
$set('tenant_confirm', null);
|
||||||
@ -338,7 +388,7 @@ public static function getWizardSteps(): array
|
|||||||
$backupSetId = $get('backup_set_id');
|
$backupSetId = $get('backup_set_id');
|
||||||
$selectedItemIds = $get('backup_item_ids');
|
$selectedItemIds = $get('backup_item_ids');
|
||||||
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
|
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
$set('group_mapping', static::groupMappingPlaceholders(
|
$set('group_mapping', static::groupMappingPlaceholders(
|
||||||
backupSetId: $backupSetId,
|
backupSetId: $backupSetId,
|
||||||
@ -389,7 +439,7 @@ public static function getWizardSteps(): array
|
|||||||
$backupSetId = $get('backup_set_id');
|
$backupSetId = $get('backup_set_id');
|
||||||
$scopeMode = $get('scope_mode') ?? 'all';
|
$scopeMode = $get('scope_mode') ?? 'all';
|
||||||
$selectedItemIds = $get('backup_item_ids');
|
$selectedItemIds = $get('backup_item_ids');
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant || ! $backupSetId) {
|
if (! $tenant || ! $backupSetId) {
|
||||||
return [];
|
return [];
|
||||||
@ -458,7 +508,7 @@ public static function getWizardSteps(): array
|
|||||||
->label('Sync Groups')
|
->label('Sync Groups')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('warning')
|
->color('warning')
|
||||||
->url(fn (): string => EntraGroupResource::getUrl('index', tenant: Tenant::current()))
|
->url(fn (): string => EntraGroupResource::getUrl('index', tenant: static::resolveTenantContextForCurrentPanel()))
|
||||||
->visible(fn (): bool => $cacheNotice !== null)
|
->visible(fn (): bool => $cacheNotice !== null)
|
||||||
);
|
);
|
||||||
}, $unresolved);
|
}, $unresolved);
|
||||||
@ -467,7 +517,7 @@ public static function getWizardSteps(): array
|
|||||||
$backupSetId = $get('backup_set_id');
|
$backupSetId = $get('backup_set_id');
|
||||||
$scopeMode = $get('scope_mode') ?? 'all';
|
$scopeMode = $get('scope_mode') ?? 'all';
|
||||||
$selectedItemIds = $get('backup_item_ids');
|
$selectedItemIds = $get('backup_item_ids');
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant || ! $backupSetId) {
|
if (! $tenant || ! $backupSetId) {
|
||||||
return false;
|
return false;
|
||||||
@ -508,7 +558,7 @@ public static function getWizardSteps(): array
|
|||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (Get $get): bool => filled($get('backup_set_id')))
|
->visible(fn (Get $get): bool => filled($get('backup_set_id')))
|
||||||
->action(function (Get $get, Set $set): void {
|
->action(function (Get $get, Set $set): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant) {
|
if (! $tenant) {
|
||||||
return;
|
return;
|
||||||
@ -606,7 +656,7 @@ public static function getWizardSteps(): array
|
|||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (Get $get): bool => filled($get('backup_set_id')))
|
->visible(fn (Get $get): bool => filled($get('backup_set_id')))
|
||||||
->action(function (Get $get, Set $set): void {
|
->action(function (Get $get, Set $set): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant) {
|
if (! $tenant) {
|
||||||
return;
|
return;
|
||||||
@ -685,7 +735,7 @@ public static function getWizardSteps(): array
|
|||||||
Forms\Components\Placeholder::make('confirm_tenant_label')
|
Forms\Components\Placeholder::make('confirm_tenant_label')
|
||||||
->label('Tenant hard-confirm label')
|
->label('Tenant hard-confirm label')
|
||||||
->content(function (): string {
|
->content(function (): string {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant) {
|
if (! $tenant) {
|
||||||
return '';
|
return '';
|
||||||
@ -722,7 +772,7 @@ public static function getWizardSteps(): array
|
|||||||
->required(fn (Get $get): bool => $get('is_dry_run') === false)
|
->required(fn (Get $get): bool => $get('is_dry_run') === false)
|
||||||
->visible(fn (Get $get): bool => $get('is_dry_run') === false)
|
->visible(fn (Get $get): bool => $get('is_dry_run') === false)
|
||||||
->in(function (): array {
|
->in(function (): array {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant) {
|
if (! $tenant) {
|
||||||
return [];
|
return [];
|
||||||
@ -736,7 +786,7 @@ public static function getWizardSteps(): array
|
|||||||
'in' => 'Tenant hard-confirm does not match.',
|
'in' => 'Tenant hard-confirm does not match.',
|
||||||
])
|
])
|
||||||
->helperText(function (): string {
|
->helperText(function (): string {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant) {
|
if (! $tenant) {
|
||||||
return '';
|
return '';
|
||||||
@ -754,34 +804,63 @@ public static function getWizardSteps(): array
|
|||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
|
->persistFiltersInSession()
|
||||||
|
->persistSearchInSession()
|
||||||
|
->persistSortInSession()
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('backupSet.name')->label('Backup set'),
|
Tables\Columns\TextColumn::make('backupSet.name')
|
||||||
|
->label('Backup set')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
Tables\Columns\IconColumn::make('is_dry_run')->label('Dry-run')->boolean(),
|
Tables\Columns\IconColumn::make('is_dry_run')->label('Dry-run')->boolean(),
|
||||||
Tables\Columns\TextColumn::make('status')
|
Tables\Columns\TextColumn::make('status')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::RestoreRunStatus))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::RestoreRunStatus))
|
||||||
->color(BadgeRenderer::color(BadgeDomain::RestoreRunStatus))
|
->color(BadgeRenderer::color(BadgeDomain::RestoreRunStatus))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::RestoreRunStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::RestoreRunStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::RestoreRunStatus)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::RestoreRunStatus))
|
||||||
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('summary_total')
|
Tables\Columns\TextColumn::make('summary_total')
|
||||||
->label('Total')
|
->label('Total')
|
||||||
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['total'] ?? 0)),
|
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['total'] ?? 0)),
|
||||||
Tables\Columns\TextColumn::make('summary_succeeded')
|
Tables\Columns\TextColumn::make('summary_succeeded')
|
||||||
->label('Succeeded')
|
->label('Applied')
|
||||||
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['succeeded'] ?? 0)),
|
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['succeeded'] ?? 0)),
|
||||||
Tables\Columns\TextColumn::make('summary_failed')
|
Tables\Columns\TextColumn::make('summary_failed')
|
||||||
->label('Failed')
|
->label('Failed items')
|
||||||
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['failed'] ?? 0)),
|
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['failed'] ?? 0)),
|
||||||
Tables\Columns\TextColumn::make('started_at')->dateTime()->since(),
|
Tables\Columns\TextColumn::make('started_at')->dateTime()->since()->sortable(),
|
||||||
Tables\Columns\TextColumn::make('completed_at')->dateTime()->since(),
|
Tables\Columns\TextColumn::make('completed_at')->dateTime()->since()->sortable(),
|
||||||
Tables\Columns\TextColumn::make('requested_by')->label('Requested by'),
|
Tables\Columns\TextColumn::make('requested_by')->label('Requested by')->searchable()->sortable(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
TrashedFilter::make()
|
Tables\Filters\SelectFilter::make('status')
|
||||||
->label('Archived')
|
->options(FilterOptionCatalog::restoreRunStatuses())
|
||||||
->placeholder('Active')
|
->searchable(),
|
||||||
->trueLabel('All')
|
Tables\Filters\SelectFilter::make('outcome')
|
||||||
->falseLabel('Archived'),
|
->options(FilterOptionCatalog::restoreRunOutcomes())
|
||||||
|
->query(function (\Illuminate\Database\Eloquent\Builder $query, array $data): \Illuminate\Database\Eloquent\Builder {
|
||||||
|
$value = $data['value'] ?? null;
|
||||||
|
|
||||||
|
return match ((string) $value) {
|
||||||
|
'succeeded' => $query->whereIn('status', [
|
||||||
|
\App\Support\RestoreRunStatus::Previewed->value,
|
||||||
|
\App\Support\RestoreRunStatus::Completed->value,
|
||||||
|
]),
|
||||||
|
'partial' => $query->whereIn('status', [
|
||||||
|
\App\Support\RestoreRunStatus::Partial->value,
|
||||||
|
\App\Support\RestoreRunStatus::CompletedWithErrors->value,
|
||||||
|
]),
|
||||||
|
'failed' => $query->whereIn('status', [
|
||||||
|
\App\Support\RestoreRunStatus::Failed->value,
|
||||||
|
\App\Support\RestoreRunStatus::Cancelled->value,
|
||||||
|
\App\Support\RestoreRunStatus::Aborted->value,
|
||||||
|
]),
|
||||||
|
default => $query,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
FilterPresets::dateRange('started_at', 'Started', 'started_at'),
|
||||||
|
FilterPresets::archived(),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\ViewAction::make(),
|
Actions\ViewAction::make(),
|
||||||
@ -795,6 +874,8 @@ public static function table(Table $table): Table
|
|||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (RestoreRun $record): bool => $record->trashed())
|
->visible(fn (RestoreRun $record): bool => $record->trashed())
|
||||||
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
||||||
|
$record = static::resolveProtectedRestoreRunRecordOrFail($record);
|
||||||
|
|
||||||
$record->restore();
|
$record->restore();
|
||||||
|
|
||||||
if ($record->tenant) {
|
if ($record->tenant) {
|
||||||
@ -813,7 +894,7 @@ public static function table(Table $table): Table
|
|||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
fn () => Tenant::current(),
|
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
@ -826,6 +907,8 @@ public static function table(Table $table): Table
|
|||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (RestoreRun $record): bool => ! $record->trashed())
|
->visible(fn (RestoreRun $record): bool => ! $record->trashed())
|
||||||
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
||||||
|
$record = static::resolveProtectedRestoreRunRecordOrFail($record);
|
||||||
|
|
||||||
if (! $record->isDeletable()) {
|
if (! $record->isDeletable()) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Restore run cannot be archived')
|
->title('Restore run cannot be archived')
|
||||||
@ -854,7 +937,7 @@ public static function table(Table $table): Table
|
|||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
fn () => Tenant::current(),
|
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
@ -867,6 +950,8 @@ public static function table(Table $table): Table
|
|||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (RestoreRun $record): bool => $record->trashed())
|
->visible(fn (RestoreRun $record): bool => $record->trashed())
|
||||||
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
||||||
|
$record = static::resolveProtectedRestoreRunRecordOrFail($record);
|
||||||
|
|
||||||
if ($record->tenant) {
|
if ($record->tenant) {
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $record->tenant,
|
tenant: $record->tenant,
|
||||||
@ -885,7 +970,7 @@ public static function table(Table $table): Table
|
|||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
fn () => Tenant::current(),
|
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
@ -924,10 +1009,10 @@ public static function table(Table $table): Table
|
|||||||
return [];
|
return [];
|
||||||
})
|
})
|
||||||
->action(function (Collection $records) {
|
->action(function (Collection $records) {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
$ids = static::resolveProtectedRestoreRunIds($records);
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return;
|
return;
|
||||||
@ -994,10 +1079,10 @@ public static function table(Table $table): Table
|
|||||||
->modalHeading(fn (Collection $records) => "Restore {$records->count()} restore runs?")
|
->modalHeading(fn (Collection $records) => "Restore {$records->count()} restore runs?")
|
||||||
->modalDescription('Archived runs will be restored back to the active list. Active runs will be skipped.')
|
->modalDescription('Archived runs will be restored back to the active list. Active runs will be skipped.')
|
||||||
->action(function (Collection $records) {
|
->action(function (Collection $records) {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
$ids = static::resolveProtectedRestoreRunIds($records);
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return;
|
return;
|
||||||
@ -1084,10 +1169,10 @@ public static function table(Table $table): Table
|
|||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
->action(function (Collection $records) {
|
->action(function (Collection $records) {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
$ids = static::resolveProtectedRestoreRunIds($records);
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return;
|
return;
|
||||||
@ -1176,7 +1261,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
$succeeded = (int) ($meta['succeeded'] ?? 0);
|
$succeeded = (int) ($meta['succeeded'] ?? 0);
|
||||||
$failed = (int) ($meta['failed'] ?? 0);
|
$failed = (int) ($meta['failed'] ?? 0);
|
||||||
|
|
||||||
return sprintf('Total: %d • Succeeded: %d • Failed: %d', $total, $succeeded, $failed);
|
return sprintf('Total: %d • Applied: %d • Failed items: %d', $total, $succeeded, $failed);
|
||||||
}),
|
}),
|
||||||
Infolists\Components\TextEntry::make('is_dry_run')
|
Infolists\Components\TextEntry::make('is_dry_run')
|
||||||
->label('Dry-run')
|
->label('Dry-run')
|
||||||
@ -1228,7 +1313,7 @@ private static function typeMeta(?string $type): array
|
|||||||
*/
|
*/
|
||||||
private static function restoreItemOptionData(?int $backupSetId): array
|
private static function restoreItemOptionData(?int $backupSetId): array
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant || ! $backupSetId) {
|
if (! $tenant || ! $backupSetId) {
|
||||||
return [
|
return [
|
||||||
@ -1299,7 +1384,7 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
|||||||
*/
|
*/
|
||||||
private static function restoreItemGroupedOptions(?int $backupSetId): array
|
private static function restoreItemGroupedOptions(?int $backupSetId): array
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant || ! $backupSetId) {
|
if (! $tenant || ! $backupSetId) {
|
||||||
return [];
|
return [];
|
||||||
@ -1351,7 +1436,7 @@ private static function restoreItemGroupedOptions(?int $backupSetId): array
|
|||||||
|
|
||||||
public static function createRestoreRun(array $data): RestoreRun
|
public static function createRestoreRun(array $data): RestoreRun
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
@ -1643,6 +1728,8 @@ public static function createRestoreRun(array $data): RestoreRun
|
|||||||
'restore_run_id' => (int) $restoreRun->getKey(),
|
'restore_run_id' => (int) $restoreRun->getKey(),
|
||||||
'backup_set_id' => (int) $backupSet->getKey(),
|
'backup_set_id' => (int) $backupSet->getKey(),
|
||||||
'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false),
|
'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false),
|
||||||
|
'execution_authority_mode' => 'actor_bound',
|
||||||
|
'required_capability' => Capabilities::TENANT_MANAGE,
|
||||||
],
|
],
|
||||||
initiator: $initiator,
|
initiator: $initiator,
|
||||||
);
|
);
|
||||||
@ -1874,6 +1961,7 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
|
|||||||
\App\Services\Intune\AuditLogger $auditLogger,
|
\App\Services\Intune\AuditLogger $auditLogger,
|
||||||
HasTable $livewire
|
HasTable $livewire
|
||||||
) {
|
) {
|
||||||
|
$record = static::resolveProtectedRestoreRunRecordOrFail($record);
|
||||||
$tenant = $record->tenant;
|
$tenant = $record->tenant;
|
||||||
$backupSet = $record->backupSet;
|
$backupSet = $record->backupSet;
|
||||||
|
|
||||||
@ -2041,6 +2129,8 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
|
|||||||
'restore_run_id' => (int) $newRun->getKey(),
|
'restore_run_id' => (int) $newRun->getKey(),
|
||||||
'backup_set_id' => (int) $backupSet->getKey(),
|
'backup_set_id' => (int) $backupSet->getKey(),
|
||||||
'is_dry_run' => (bool) ($newRun->is_dry_run ?? false),
|
'is_dry_run' => (bool) ($newRun->is_dry_run ?? false),
|
||||||
|
'execution_authority_mode' => 'actor_bound',
|
||||||
|
'required_capability' => Capabilities::TENANT_MANAGE,
|
||||||
],
|
],
|
||||||
initiator: $initiator,
|
initiator: $initiator,
|
||||||
);
|
);
|
||||||
@ -2117,7 +2207,7 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
|
|||||||
OperationUxPresenter::queuedToast('restore.execute')
|
OperationUxPresenter::queuedToast('restore.execute')
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
fn () => Tenant::current(),
|
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
@ -2133,7 +2223,7 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = $record instanceof RestoreRun ? $record->tenant : Tenant::current();
|
$tenant = $record instanceof RestoreRun ? $record->tenant : static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return true;
|
return true;
|
||||||
@ -2157,7 +2247,7 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
|
|||||||
return \App\Support\Auth\UiTooltips::insufficientPermission();
|
return \App\Support\Auth\UiTooltips::insufficientPermission();
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = $record instanceof RestoreRun ? $record->tenant : Tenant::current();
|
$tenant = $record instanceof RestoreRun ? $record->tenant : static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return 'Tenant unavailable';
|
return 'Tenant unavailable';
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\RestoreRunResource\Pages;
|
namespace App\Filament\Resources\RestoreRunResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -17,12 +18,13 @@
|
|||||||
class CreateRestoreRun extends CreateRecord
|
class CreateRestoreRun extends CreateRecord
|
||||||
{
|
{
|
||||||
use HasWizard;
|
use HasWizard;
|
||||||
|
use ResolvesPanelTenantContext;
|
||||||
|
|
||||||
protected static string $resource = RestoreRunResource::class;
|
protected static string $resource = RestoreRunResource::class;
|
||||||
|
|
||||||
protected function authorizeAccess(): void
|
protected function authorizeAccess(): void
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -60,7 +62,7 @@ protected function afterFill(): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant) {
|
if (! $tenant) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -3,12 +3,42 @@
|
|||||||
namespace App\Filament\Resources\RestoreRunResource\Pages;
|
namespace App\Filament\Resources\RestoreRunResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
|
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
|
|
||||||
class ListRestoreRuns extends ListRecords
|
class ListRestoreRuns extends ListRecords
|
||||||
{
|
{
|
||||||
protected static string $resource = RestoreRunResource::class;
|
protected static string $resource = RestoreRunResource::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $arguments
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
|
||||||
|
{
|
||||||
|
if (($context['table'] ?? false) === true && filled($context['recordKey'] ?? null) && in_array($name, ['archive', 'forceDelete', 'rerun'], true)) {
|
||||||
|
try {
|
||||||
|
RestoreRunResource::resolveScopedRecordOrFail($context['recordKey']);
|
||||||
|
} catch (ModelNotFoundException) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::mountAction($name, $arguments, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||||
|
$this->getTableFiltersSessionKey(),
|
||||||
|
request: request(),
|
||||||
|
tenantFilterName: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
parent::mount();
|
||||||
|
}
|
||||||
|
|
||||||
private function tableHasRecords(): bool
|
private function tableHasRecords(): bool
|
||||||
{
|
{
|
||||||
return $this->getTableRecords()->count() > 0;
|
return $this->getTableRecords()->count() > 0;
|
||||||
|
|||||||
@ -4,8 +4,14 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class ViewRestoreRun extends ViewRecord
|
class ViewRestoreRun extends ViewRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = RestoreRunResource::class;
|
protected static string $resource = RestoreRunResource::class;
|
||||||
|
|
||||||
|
protected function resolveRecord(int|string $key): Model
|
||||||
|
{
|
||||||
|
return RestoreRunResource::resolveScopedRecordOrFail($key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Exceptions\ReviewPackEvidenceResolutionException;
|
||||||
|
use App\Filament\Resources\EvidenceSnapshotResource as TenantEvidenceSnapshotResource;
|
||||||
use App\Filament\Resources\ReviewPackResource\Pages;
|
use App\Filament\Resources\ReviewPackResource\Pages;
|
||||||
use App\Models\ReviewPack;
|
use App\Models\ReviewPack;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\ReviewPackService;
|
use App\Services\ReviewPackService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
@ -17,11 +20,14 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
@ -109,6 +115,15 @@ public static function infolist(Schema $schema): Schema
|
|||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
->schema([
|
->schema([
|
||||||
|
Section::make('Artifact truth')
|
||||||
|
->schema([
|
||||||
|
ViewEntry::make('artifact_truth')
|
||||||
|
->hiddenLabel()
|
||||||
|
->view('filament.infolists.entries.governance-artifact-truth')
|
||||||
|
->state(fn (ReviewPack $record): array => static::truthEnvelope($record)->toArray())
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
Section::make('Status')
|
Section::make('Status')
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('status')
|
TextEntry::make('status')
|
||||||
@ -164,6 +179,21 @@ public static function infolist(Schema $schema): Schema
|
|||||||
Section::make('Metadata')
|
Section::make('Metadata')
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('initiator.name')->label('Initiated by')->placeholder('—'),
|
TextEntry::make('initiator.name')->label('Initiated by')->placeholder('—'),
|
||||||
|
TextEntry::make('tenantReview.id')
|
||||||
|
->label('Tenant review')
|
||||||
|
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||||
|
->url(fn (ReviewPack $record): ?string => $record->tenantReview && $record->tenant
|
||||||
|
? TenantReviewResource::tenantScopedUrl('view', ['record' => $record->tenantReview], $record->tenant)
|
||||||
|
: null)
|
||||||
|
->placeholder('—'),
|
||||||
|
TextEntry::make('summary.review_status')
|
||||||
|
->label('Review status')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
|
||||||
|
->placeholder('—'),
|
||||||
TextEntry::make('operationRun.id')
|
TextEntry::make('operationRun.id')
|
||||||
->label('Operation run')
|
->label('Operation run')
|
||||||
->url(fn (ReviewPack $record): ?string => $record->operation_run_id
|
->url(fn (ReviewPack $record): ?string => $record->operation_run_id
|
||||||
@ -177,6 +207,33 @@ public static function infolist(Schema $schema): Schema
|
|||||||
])
|
])
|
||||||
->columns(2)
|
->columns(2)
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
Section::make('Evidence snapshot')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('summary.evidence_resolution.outcome')
|
||||||
|
->label('Resolution')
|
||||||
|
->placeholder('—'),
|
||||||
|
TextEntry::make('evidenceSnapshot.id')
|
||||||
|
->label('Snapshot')
|
||||||
|
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||||
|
->url(fn (ReviewPack $record): ?string => $record->evidenceSnapshot
|
||||||
|
? TenantEvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
|
||||||
|
: null),
|
||||||
|
TextEntry::make('evidenceSnapshot.completeness_state')
|
||||||
|
->label('Snapshot completeness')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness))
|
||||||
|
->placeholder('—'),
|
||||||
|
TextEntry::make('summary.evidence_resolution.snapshot_fingerprint')
|
||||||
|
->label('Snapshot fingerprint')
|
||||||
|
->copyable()
|
||||||
|
->placeholder('—'),
|
||||||
|
])
|
||||||
|
->columns(2)
|
||||||
|
->columnSpanFull(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -184,6 +241,7 @@ public static function table(Table $table): Table
|
|||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
->defaultSort('created_at', 'desc')
|
->defaultSort('created_at', 'desc')
|
||||||
|
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
|
||||||
->recordUrl(fn (ReviewPack $record): string => static::getUrl('view', ['record' => $record]))
|
->recordUrl(fn (ReviewPack $record): string => static::getUrl('view', ['record' => $record]))
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('status')
|
Tables\Columns\TextColumn::make('status')
|
||||||
@ -193,6 +251,15 @@ public static function table(Table $table): Table
|
|||||||
->icon(BadgeRenderer::icon(BadgeDomain::ReviewPackStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::ReviewPackStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ReviewPackStatus))
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ReviewPackStatus))
|
||||||
->sortable(),
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('artifact_truth')
|
||||||
|
->label('Artifact truth')
|
||||||
|
->badge()
|
||||||
|
->getStateUsing(fn (ReviewPack $record): string => static::truthEnvelope($record)->primaryLabel)
|
||||||
|
->color(fn (ReviewPack $record): string => static::truthEnvelope($record)->primaryBadgeSpec()->color)
|
||||||
|
->icon(fn (ReviewPack $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
||||||
|
->iconColor(fn (ReviewPack $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
||||||
|
->description(fn (ReviewPack $record): ?string => static::truthEnvelope($record)->primaryExplanation)
|
||||||
|
->wrap(),
|
||||||
Tables\Columns\TextColumn::make('tenant.name')
|
Tables\Columns\TextColumn::make('tenant.name')
|
||||||
->label('Tenant')
|
->label('Tenant')
|
||||||
->searchable(),
|
->searchable(),
|
||||||
@ -200,6 +267,10 @@ public static function table(Table $table): Table
|
|||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable()
|
->sortable()
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
|
Tables\Columns\TextColumn::make('tenantReview.id')
|
||||||
|
->label('Review')
|
||||||
|
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('expires_at')
|
Tables\Columns\TextColumn::make('expires_at')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable()
|
->sortable()
|
||||||
@ -208,6 +279,29 @@ public static function table(Table $table): Table
|
|||||||
->label('Size')
|
->label('Size')
|
||||||
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—')
|
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—')
|
||||||
->sortable(),
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('publication_truth')
|
||||||
|
->label('Publication')
|
||||||
|
->badge()
|
||||||
|
->getStateUsing(fn (ReviewPack $record): string => BadgeCatalog::spec(
|
||||||
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
|
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
||||||
|
)->label)
|
||||||
|
->color(fn (ReviewPack $record): string => BadgeCatalog::spec(
|
||||||
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
|
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
||||||
|
)->color)
|
||||||
|
->icon(fn (ReviewPack $record): ?string => BadgeCatalog::spec(
|
||||||
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
|
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
||||||
|
)->icon)
|
||||||
|
->iconColor(fn (ReviewPack $record): ?string => BadgeCatalog::spec(
|
||||||
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
|
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
||||||
|
)->iconColor),
|
||||||
|
Tables\Columns\TextColumn::make('artifact_next_step')
|
||||||
|
->label('Next step')
|
||||||
|
->getStateUsing(fn (ReviewPack $record): string => static::truthEnvelope($record)->nextStepText())
|
||||||
|
->wrap(),
|
||||||
Tables\Columns\TextColumn::make('created_at')
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
->label('Created')
|
->label('Created')
|
||||||
->since()
|
->since()
|
||||||
@ -243,6 +337,7 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
$record->update(['status' => ReviewPackStatus::Expired->value]);
|
$record->update(['status' => ReviewPackStatus::Expired->value]);
|
||||||
|
static::truthEnvelope($record->refresh(), fresh: true);
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->success()
|
->success()
|
||||||
@ -303,6 +398,15 @@ public static function getPages(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function truthEnvelope(ReviewPack $record, bool $fresh = false): ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? $presenter->forReviewPackFresh($record)
|
||||||
|
: $presenter->forReviewPack($record);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $data
|
* @param array<string, mixed> $data
|
||||||
*/
|
*/
|
||||||
@ -330,7 +434,25 @@ public static function executeGeneration(array $data): void
|
|||||||
'include_operations' => (bool) ($data['include_operations'] ?? true),
|
'include_operations' => (bool) ($data['include_operations'] ?? true),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
$reviewPack = $service->generate($tenant, $user, $options);
|
$reviewPack = $service->generate($tenant, $user, $options);
|
||||||
|
} catch (ReviewPackEvidenceResolutionException $exception) {
|
||||||
|
$reasons = $exception->result->reasons;
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->danger()
|
||||||
|
->title(match ($exception->result->outcome) {
|
||||||
|
'missing_snapshot' => 'Create snapshot required',
|
||||||
|
'snapshot_ineligible' => 'Snapshot is not eligible',
|
||||||
|
default => 'Unable to generate review pack',
|
||||||
|
})
|
||||||
|
->body($reasons === [] ? $exception->getMessage() : implode(' ', $reasons))
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
static::truthEnvelope($reviewPack->refresh(), fresh: true);
|
||||||
|
|
||||||
if (! $reviewPack->wasRecentlyCreated) {
|
if (! $reviewPack->wasRecentlyCreated) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
|
|||||||
@ -11,7 +11,9 @@
|
|||||||
use App\Models\EntraRoleDefinition;
|
use App\Models\EntraRoleDefinition;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantOnboardingSession;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Auth\RoleCapabilityMap;
|
use App\Services\Auth\RoleCapabilityMap;
|
||||||
use App\Services\Directory\EntraGroupLabelResolver;
|
use App\Services\Directory\EntraGroupLabelResolver;
|
||||||
@ -21,17 +23,28 @@
|
|||||||
use App\Services\Intune\RbacOnboardingService;
|
use App\Services\Intune\RbacOnboardingService;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Operations\BulkSelectionIdentity;
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
|
use App\Services\Providers\AdminConsentUrlFactory;
|
||||||
|
use App\Services\Tenants\TenantActionPolicySurface;
|
||||||
|
use App\Services\Tenants\TenantOperabilityService;
|
||||||
use App\Services\Verification\StartVerification;
|
use App\Services\Verification\StartVerification;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\UiTooltips;
|
use App\Support\Auth\UiTooltips;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Badges\TagBadgeDomain;
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
use App\Support\Badges\TagBadgeRenderer;
|
use App\Support\Badges\TagBadgeRenderer;
|
||||||
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\Tenants\TenantActionDescriptor;
|
||||||
|
use App\Support\Tenants\TenantActionSurface;
|
||||||
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
|
use App\Support\Tenants\TenantLifecyclePresentation;
|
||||||
|
use App\Support\Tenants\TenantOperabilityOutcome;
|
||||||
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
@ -75,6 +88,11 @@ class TenantResource extends Resource
|
|||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Settings';
|
protected static string|UnitEnum|null $navigationGroup = 'Settings';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, Collection<int, TenantActionDescriptor>>
|
||||||
|
*/
|
||||||
|
protected static array $tenantActionCatalogCache = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tenant creation is handled exclusively by the onboarding wizard.
|
* Tenant creation is handled exclusively by the onboarding wizard.
|
||||||
* The CRUD create page has been removed.
|
* The CRUD create page has been removed.
|
||||||
@ -128,9 +146,10 @@ public static function canDeleteAny(): bool
|
|||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
|
->withListRowPrimaryActionLimit(2)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'List page provides a capability-gated create action.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'List page provides a capability-gated create action.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Row-level secondary actions are grouped under "More".')
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'At most two row actions stay primary; lifecycle-adjacent and contextual secondary actions move under "More".')
|
||||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Create action is reused in the list empty state.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Create action is reused in the list empty state.')
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Tenant view page exposes header actions via an action group.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Tenant view page exposes header actions via an action group.');
|
||||||
@ -206,16 +225,35 @@ public static function getEloquentQuery(): Builder
|
|||||||
->withMax('policies as last_policy_sync_at', 'last_synced_at');
|
->withMax('policies as last_policy_sync_at', 'last_synced_at');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function getGlobalSearchEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
if (app(WorkspaceContext::class)->currentWorkspaceId(request()) === null) {
|
||||||
|
return static::getEloquentQuery()->whereRaw('1 = 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::tenantOperability()->applyAdministrativeDiscoverabilityScope(
|
||||||
|
static::getEloquentQuery(),
|
||||||
|
(new Tenant)->getTable(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
|
->defaultSort('name')
|
||||||
|
->paginated(TablePaginationProfiles::resource())
|
||||||
|
->persistFiltersInSession()
|
||||||
|
->persistSearchInSession()
|
||||||
|
->persistSortInSession()
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('name')
|
Tables\Columns\TextColumn::make('name')
|
||||||
->searchable(),
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('tenant_id')
|
Tables\Columns\TextColumn::make('tenant_id')
|
||||||
->label('Tenant ID')
|
->label('Tenant ID')
|
||||||
->copyable()
|
->copyable()
|
||||||
->searchable(),
|
->searchable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('environment')
|
Tables\Columns\TextColumn::make('environment')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::TenantEnvironment))
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::TenantEnvironment))
|
||||||
@ -231,26 +269,30 @@ public static function table(Table $table): Table
|
|||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('domain')
|
Tables\Columns\TextColumn::make('domain')
|
||||||
->copyable()
|
->copyable()
|
||||||
->toggleable(),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\IconColumn::make('is_current')
|
Tables\Columns\IconColumn::make('is_current')
|
||||||
->label('Current')
|
->label('Current')
|
||||||
->boolean(),
|
->boolean()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('status')
|
Tables\Columns\TextColumn::make('status')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantStatus))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantStatus))
|
||||||
->color(BadgeRenderer::color(BadgeDomain::TenantStatus))
|
->color(BadgeRenderer::color(BadgeDomain::TenantStatus))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus))
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus))
|
||||||
|
->description(fn (Tenant $record): string => static::tenantLifecyclePresentation($record)->shortDescription)
|
||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('app_status')
|
Tables\Columns\TextColumn::make('app_status')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus))
|
||||||
->color(BadgeRenderer::color(BadgeDomain::TenantAppStatus))
|
->color(BadgeRenderer::color(BadgeDomain::TenantAppStatus))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantAppStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantAppStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus))
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('created_at')
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->since(),
|
->since()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
Tables\Filters\TrashedFilter::make()
|
Tables\Filters\TrashedFilter::make()
|
||||||
@ -275,11 +317,56 @@ public static function table(Table $table): Table
|
|||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
ActionGroup::make([
|
|
||||||
Actions\Action::make('view')
|
Actions\Action::make('view')
|
||||||
->label('View')
|
->label('View')
|
||||||
->icon('heroicon-o-eye')
|
->icon('heroicon-o-eye')
|
||||||
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record])),
|
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record])),
|
||||||
|
Actions\Action::make('related_onboarding')
|
||||||
|
->label(fn (Tenant $record): string => static::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantIndexRow) ?? 'Resume onboarding')
|
||||||
|
->icon(fn (Tenant $record): string => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-path')
|
||||||
|
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
||||||
|
->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'related_onboarding'),
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('restore')
|
||||||
|
->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Restore')
|
||||||
|
->color('success')
|
||||||
|
->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-uturn-left')
|
||||||
|
->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant restored')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Restore tenant')
|
||||||
|
->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.')
|
||||||
|
->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'restore')
|
||||||
|
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||||
|
static::restoreTenant($record, $auditLogger);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
|
->apply(),
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('archive')
|
||||||
|
->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Archive')
|
||||||
|
->color('danger')
|
||||||
|
->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-archive-box-x-mark')
|
||||||
|
->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant archived')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Archive tenant')
|
||||||
|
->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.')
|
||||||
|
->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'archive')
|
||||||
|
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||||
|
static::archiveTenant($record, $auditLogger);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
|
->apply(),
|
||||||
|
ActionGroup::make([
|
||||||
|
Actions\Action::make('related_onboarding_overflow')
|
||||||
|
->label(fn (Tenant $record): string => static::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantIndexRow) ?? 'View related onboarding')
|
||||||
|
->icon(fn (Tenant $record): string => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-eye')
|
||||||
|
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
||||||
|
->visible(fn (Tenant $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor
|
||||||
|
&& static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('syncTenant')
|
Actions\Action::make('syncTenant')
|
||||||
->label('Sync')
|
->label('Sync')
|
||||||
@ -406,46 +493,9 @@ public static function table(Table $table): Table
|
|||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('restore')
|
|
||||||
->label('Restore')
|
|
||||||
->color('success')
|
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
|
||||||
->successNotificationTitle('Tenant reactivated')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (Tenant $record): bool => $record->trashed())
|
|
||||||
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$record->restore();
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $record,
|
|
||||||
action: 'tenant.restored',
|
|
||||||
resourceType: 'tenant',
|
|
||||||
resourceId: (string) $record->id,
|
|
||||||
status: 'success',
|
|
||||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->preserveVisibility()
|
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
|
||||||
->apply(),
|
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('admin_consent')
|
Actions\Action::make('admin_consent')
|
||||||
->label('Admin consent')
|
->label('Grant admin consent')
|
||||||
->icon('heroicon-o-clipboard-document')
|
->icon('heroicon-o-clipboard-document')
|
||||||
->url(fn (Tenant $record) => static::adminConsentUrl($record))
|
->url(fn (Tenant $record) => static::adminConsentUrl($record))
|
||||||
->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null)
|
->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null)
|
||||||
@ -466,7 +516,7 @@ public static function table(Table $table): Table
|
|||||||
->icon('heroicon-o-check-badge')
|
->icon('heroicon-o-check-badge')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (Tenant $record): bool => $record->isActive())
|
->visible(fn (Tenant $record): bool => static::verificationActionVisible($record))
|
||||||
->action(function (
|
->action(function (
|
||||||
Tenant $record,
|
Tenant $record,
|
||||||
StartVerification $verification,
|
StartVerification $verification,
|
||||||
@ -558,9 +608,12 @@ public static function table(Table $table): Table
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||||
|
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Verification blocked')
|
->title('Verification blocked')
|
||||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
->body(implode("\n", $bodyLines))
|
||||||
->warning()
|
->warning()
|
||||||
->actions($actions)
|
->actions($actions)
|
||||||
->send();
|
->send();
|
||||||
@ -583,48 +636,6 @@ public static function table(Table $table): Table
|
|||||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||||
->apply(),
|
->apply(),
|
||||||
static::rbacAction(),
|
static::rbacAction(),
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('archive')
|
|
||||||
->label('Deactivate')
|
|
||||||
->color('danger')
|
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
|
||||||
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$record->delete();
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $record,
|
|
||||||
action: 'tenant.archived',
|
|
||||||
resourceType: 'tenant',
|
|
||||||
resourceId: (string) $record->id,
|
|
||||||
status: 'success',
|
|
||||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
|
||||||
);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Tenant deactivated')
|
|
||||||
->body('The tenant has been archived and hidden from lists.')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
->preserveVisibility()
|
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
|
||||||
->apply(),
|
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('forceDelete')
|
Actions\Action::make('forceDelete')
|
||||||
->label('Force delete')
|
->label('Force delete')
|
||||||
@ -807,7 +818,10 @@ public static function table(Table $table): Table
|
|||||||
->deselectRecordsAfterCompletion(),
|
->deselectRecordsAfterCompletion(),
|
||||||
])->label('More'),
|
])->label('More'),
|
||||||
])
|
])
|
||||||
->headerActions([]);
|
->headerActions([])
|
||||||
|
->emptyStateHeading('No tenants connected')
|
||||||
|
->emptyStateDescription('Add a tenant to start syncing inventory, policies, and provider health into this workspace.')
|
||||||
|
->emptyStateIcon('heroicon-o-building-office-2');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function infolist(Schema $schema): Schema
|
public static function infolist(Schema $schema): Schema
|
||||||
@ -826,6 +840,10 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->color(BadgeRenderer::color(BadgeDomain::TenantStatus))
|
->color(BadgeRenderer::color(BadgeDomain::TenantStatus))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus)),
|
||||||
|
Infolists\Components\TextEntry::make('lifecycle_summary')
|
||||||
|
->label('Lifecycle summary')
|
||||||
|
->state(fn (Tenant $record): string => static::tenantLifecyclePresentation($record)->longDescription)
|
||||||
|
->columnSpanFull(),
|
||||||
Infolists\Components\TextEntry::make('app_status')
|
Infolists\Components\TextEntry::make('app_status')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus))
|
||||||
@ -893,8 +911,20 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
|
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
|
||||||
Section::make('RBAC Details')
|
Section::make('RBAC Details')
|
||||||
->schema([
|
->schema([
|
||||||
|
Infolists\Components\TextEntry::make('rbac_status_reason_label')
|
||||||
|
->label('Reason')
|
||||||
|
->state(fn (Tenant $record): ?string => app(\App\Support\ReasonTranslation\ReasonPresenter::class)
|
||||||
|
->primaryLabel(app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forRbacReason($record->rbac_status_reason, 'detail')))
|
||||||
|
->visible(fn (?string $state): bool => filled($state)),
|
||||||
|
Infolists\Components\TextEntry::make('rbac_status_reason_explanation')
|
||||||
|
->label('Explanation')
|
||||||
|
->state(fn (Tenant $record): ?string => app(\App\Support\ReasonTranslation\ReasonPresenter::class)
|
||||||
|
->shortExplanation(app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forRbacReason($record->rbac_status_reason, 'detail')))
|
||||||
|
->visible(fn (?string $state): bool => filled($state))
|
||||||
|
->columnSpanFull(),
|
||||||
Infolists\Components\TextEntry::make('rbac_status_reason')
|
Infolists\Components\TextEntry::make('rbac_status_reason')
|
||||||
->label('Reason'),
|
->label('Diagnostic code')
|
||||||
|
->copyable(),
|
||||||
Infolists\Components\TextEntry::make('rbac_role_definition_id')
|
Infolists\Components\TextEntry::make('rbac_role_definition_id')
|
||||||
->label('Role definition ID')
|
->label('Role definition ID')
|
||||||
->copyable(),
|
->copyable(),
|
||||||
@ -924,7 +954,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
Section::make('Integration')
|
Section::make('Integration')
|
||||||
->schema([
|
->schema([
|
||||||
Infolists\Components\TextEntry::make('admin_consent_url')
|
Infolists\Components\TextEntry::make('admin_consent_url')
|
||||||
->label('Admin consent URL')
|
->label('Grant admin consent URL')
|
||||||
->state(fn (Tenant $record) => static::adminConsentUrl($record))
|
->state(fn (Tenant $record) => static::adminConsentUrl($record))
|
||||||
->visible(fn (?string $state) => filled($state))
|
->visible(fn (?string $state) => filled($state))
|
||||||
->copyable()
|
->copyable()
|
||||||
@ -1001,6 +1031,216 @@ protected static function storedPermissionSnapshot(Tenant $tenant): array
|
|||||||
return $snapshot;
|
return $snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected static function tenantLifecyclePresentation(Tenant $tenant): TenantLifecyclePresentation
|
||||||
|
{
|
||||||
|
return TenantLifecyclePresentation::fromTenant($tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function tenantOperability(): TenantOperabilityService
|
||||||
|
{
|
||||||
|
return app(TenantOperabilityService::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function tenantActionPolicy(): TenantActionPolicySurface
|
||||||
|
{
|
||||||
|
return app(TenantActionPolicySurface::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, TenantActionDescriptor>
|
||||||
|
*/
|
||||||
|
public static function tenantActionCatalog(Tenant $tenant, TenantActionSurface $surface = TenantActionSurface::TenantIndexRow): Collection
|
||||||
|
{
|
||||||
|
$cacheKey = static::tenantActionCatalogCacheKey($tenant, $surface);
|
||||||
|
|
||||||
|
if (! array_key_exists($cacheKey, static::$tenantActionCatalogCache)) {
|
||||||
|
static::$tenantActionCatalogCache[$cacheKey] = collect(static::tenantActionPolicy()->catalogForTenant($tenant, $surface))->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::$tenantActionCatalogCache[$cacheKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function lifecycleActionDescriptor(Tenant $tenant, TenantActionSurface $surface = TenantActionSurface::TenantIndexRow): ?TenantActionDescriptor
|
||||||
|
{
|
||||||
|
return static::tenantActionDescriptorForSurface($tenant, $surface, 'restore')
|
||||||
|
?? static::tenantActionDescriptorForSurface($tenant, $surface, 'archive');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function tenantIndexPrimaryAction(Tenant $tenant): ?TenantActionDescriptor
|
||||||
|
{
|
||||||
|
$catalog = static::tenantActionCatalog($tenant, TenantActionSurface::TenantIndexRow);
|
||||||
|
|
||||||
|
return $catalog[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function relatedOnboardingDraft(Tenant $tenant): ?TenantOnboardingSession
|
||||||
|
{
|
||||||
|
return static::tenantActionPolicy()->relatedOnboardingDraft($tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function relatedOnboardingDraftAction(Tenant $tenant, TenantActionSurface $surface = TenantActionSurface::TenantViewHeader): ?TenantActionDescriptor
|
||||||
|
{
|
||||||
|
return static::tenantActionDescriptorForSurface($tenant, $surface, 'related_onboarding');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function relatedOnboardingDraftActionLabel(Tenant $tenant, TenantActionSurface $surface = TenantActionSurface::TenantViewHeader): ?string
|
||||||
|
{
|
||||||
|
return static::relatedOnboardingDraftAction($tenant, $surface)?->label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function relatedOnboardingDraftUrl(Tenant $tenant): ?string
|
||||||
|
{
|
||||||
|
$draft = static::relatedOnboardingDraft($tenant);
|
||||||
|
|
||||||
|
if (! $draft instanceof TenantOnboardingSession) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function verificationActionVisible(Tenant $tenant): bool
|
||||||
|
{
|
||||||
|
$outcome = static::verificationReadinessOutcome($tenant);
|
||||||
|
|
||||||
|
return $outcome->allowed || $outcome->isDeniedForCapability();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function verificationReadinessOutcome(Tenant $tenant): TenantOperabilityOutcome
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
return static::tenantOperability()->outcomeFor(
|
||||||
|
tenant: $tenant,
|
||||||
|
question: TenantOperabilityQuestion::VerificationReadinessEligibility,
|
||||||
|
actor: $user instanceof User ? $user : null,
|
||||||
|
workspaceId: app(WorkspaceContext::class)->currentWorkspaceId(request()),
|
||||||
|
lane: TenantInteractionLane::AdministrativeManagement,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function tenantActionDescriptorForSurface(Tenant $tenant, TenantActionSurface $surface, string $key): ?TenantActionDescriptor
|
||||||
|
{
|
||||||
|
$descriptor = static::tenantActionCatalog($tenant, $surface)
|
||||||
|
->first(fn (TenantActionDescriptor $descriptor): bool => $descriptor->key === $key);
|
||||||
|
|
||||||
|
return $descriptor instanceof TenantActionDescriptor ? $descriptor : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function tenantActionCatalogCacheKey(Tenant $tenant, TenantActionSurface $surface): string
|
||||||
|
{
|
||||||
|
$relatedDraft = static::relatedOnboardingDraft($tenant);
|
||||||
|
|
||||||
|
return implode(':', [
|
||||||
|
(string) (auth()->id() ?? 'guest'),
|
||||||
|
$surface->value,
|
||||||
|
(string) ($tenant->getKey() ?? 'missing'),
|
||||||
|
(string) $tenant->status,
|
||||||
|
(string) ($tenant->updated_at?->getTimestamp() ?? 'no-updated-at'),
|
||||||
|
(string) ($tenant->deleted_at?->getTimestamp() ?? 'not-deleted'),
|
||||||
|
(string) ($relatedDraft?->getKey() ?? 'no-draft'),
|
||||||
|
(string) ($relatedDraft?->workflowStatus()->value ?? 'no-draft-status'),
|
||||||
|
(string) ($relatedDraft?->lifecycleState()->value ?? 'no-draft-lifecycle-state'),
|
||||||
|
(string) ($relatedDraft?->updated_at?->getTimestamp() ?? 'no-draft-updated-at'),
|
||||||
|
(string) ($relatedDraft?->completed_at?->getTimestamp() ?? 'no-draft-completed-at'),
|
||||||
|
(string) ($relatedDraft?->cancelled_at?->getTimestamp() ?? 'no-draft-cancelled-at'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function archiveTenant(Tenant $record, WorkspaceAuditLogger $auditLogger): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($record)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$descriptor = static::lifecycleActionDescriptor($record);
|
||||||
|
|
||||||
|
if (! $descriptor instanceof TenantActionDescriptor || $descriptor->key !== 'archive') {
|
||||||
|
Notification::make()
|
||||||
|
->title('Archive unavailable')
|
||||||
|
->body('Only active tenants can be archived from tenant management surfaces.')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->delete();
|
||||||
|
|
||||||
|
$auditLogger->logTenantLifecycleAction(
|
||||||
|
tenant: $record,
|
||||||
|
action: AuditActionId::TenantArchived,
|
||||||
|
actor: $user,
|
||||||
|
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title($descriptor->successNotificationTitle ?? 'Tenant archived')
|
||||||
|
->body($descriptor->successNotificationBody ?? 'The tenant remains available for inspection and audit history, but it is no longer selectable as active context.')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function restoreTenant(Tenant $record, WorkspaceAuditLogger $auditLogger): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($record)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$descriptor = static::lifecycleActionDescriptor($record);
|
||||||
|
|
||||||
|
if (! $descriptor instanceof TenantActionDescriptor || $descriptor->key !== 'restore') {
|
||||||
|
Notification::make()
|
||||||
|
->title('Restore unavailable')
|
||||||
|
->body('Only archived tenants can be restored from tenant management surfaces.')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->restore();
|
||||||
|
|
||||||
|
$auditLogger->logTenantLifecycleAction(
|
||||||
|
tenant: $record,
|
||||||
|
action: AuditActionId::TenantRestored,
|
||||||
|
actor: $user,
|
||||||
|
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title($descriptor->successNotificationTitle ?? 'Tenant restored')
|
||||||
|
->body($descriptor->successNotificationBody ?? 'The tenant is available again in normal tenant management flows and can be selected as active context.')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@ -1219,36 +1459,6 @@ public static function rbacAction(): Actions\Action
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static function adminConsentUrl(Tenant $tenant): ?string
|
public static function adminConsentUrl(Tenant $tenant): ?string
|
||||||
{
|
|
||||||
$tenantId = $tenant->graphTenantId();
|
|
||||||
$clientId = $tenant->app_client_id;
|
|
||||||
|
|
||||||
if (! is_string($clientId) || trim($clientId) === '') {
|
|
||||||
$clientId = static::resolveProviderClientIdForConsent($tenant);
|
|
||||||
}
|
|
||||||
$redirectUri = route('admin.consent.callback');
|
|
||||||
$state = sprintf('tenantpilot|%s', $tenant->id);
|
|
||||||
|
|
||||||
if (! $tenantId || ! $clientId || ! $redirectUri) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin consent should use `.default` so the tenant consents to the app's configured
|
|
||||||
// application permissions. Keeping the URL short also avoids edge cases where a long
|
|
||||||
// scope string gets truncated and causes AADSTS900144 (missing `scope`).
|
|
||||||
$scopes = 'https://graph.microsoft.com/.default';
|
|
||||||
|
|
||||||
$query = http_build_query([
|
|
||||||
'client_id' => $clientId,
|
|
||||||
'state' => $state,
|
|
||||||
'redirect_uri' => $redirectUri,
|
|
||||||
'scope' => $scopes,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return sprintf('https://login.microsoftonline.com/%s/v2.0/adminconsent?%s', $tenantId, $query);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function resolveProviderClientIdForConsent(Tenant $tenant): ?string
|
|
||||||
{
|
{
|
||||||
$connection = ProviderConnection::query()
|
$connection = ProviderConnection::query()
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
@ -1261,21 +1471,11 @@ private static function resolveProviderClientIdForConsent(Tenant $tenant): ?stri
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$payload = $connection->credential?->payload;
|
try {
|
||||||
|
return app(AdminConsentUrlFactory::class)->make($connection, sprintf('tenantpilot|%s', $tenant->id));
|
||||||
if (! is_array($payload)) {
|
} catch (\Throwable) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$clientId = $payload['client_id'] ?? null;
|
|
||||||
|
|
||||||
if (! is_string($clientId)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$clientId = trim($clientId);
|
|
||||||
|
|
||||||
return $clientId !== '' ? $clientId : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1328,10 +1528,21 @@ private static function providerConnectionState(Tenant $tenant): array
|
|||||||
|
|
||||||
public static function entraUrl(Tenant $tenant): ?string
|
public static function entraUrl(Tenant $tenant): ?string
|
||||||
{
|
{
|
||||||
if ($tenant->app_client_id) {
|
$connection = ProviderConnection::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('provider', 'microsoft')
|
||||||
|
->orderByDesc('is_default')
|
||||||
|
->orderBy('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$effectiveAppId = $connection instanceof ProviderConnection
|
||||||
|
? $connection->effectiveAppId()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (filled($effectiveAppId)) {
|
||||||
return sprintf(
|
return sprintf(
|
||||||
'https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Overview/appId/%s',
|
'https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Overview/appId/%s',
|
||||||
$tenant->app_client_id
|
$effectiveAppId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,8 +4,10 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\TenantResource;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\Tenants\TenantActionSurface;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
@ -18,14 +20,40 @@ protected function getHeaderActions(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\ViewAction::make(),
|
Actions\ViewAction::make(),
|
||||||
|
Actions\Action::make('related_onboarding')
|
||||||
|
->label(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantEditHeader) ?? 'View related onboarding')
|
||||||
|
->icon(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-eye')
|
||||||
|
->url(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
||||||
|
->visible(fn (Tenant $record): bool => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantEditHeader) instanceof \App\Support\Tenants\TenantActionDescriptor),
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Action::make('restore')
|
||||||
|
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->label ?? 'Restore')
|
||||||
|
->color('success')
|
||||||
|
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-arrow-uturn-left')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalHeading ?? 'Restore tenant')
|
||||||
|
->modalDescription(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.')
|
||||||
|
->visible(fn (Tenant $record): bool => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->key === 'restore')
|
||||||
|
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||||
|
TenantResource::restoreTenant($record, $auditLogger);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
|
->tooltip('You do not have permission to restore tenants.')
|
||||||
|
->preserveVisibility()
|
||||||
|
->destructive()
|
||||||
|
->apply(),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Action::make('archive')
|
Action::make('archive')
|
||||||
->label('Archive')
|
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->label ?? 'Archive')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
|
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-archive-box-x-mark')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalHeading ?? 'Archive tenant')
|
||||||
->action(function (Tenant $record): void {
|
->modalDescription(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.')
|
||||||
$record->delete();
|
->visible(fn (Tenant $record): bool => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->key === 'archive')
|
||||||
|
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||||
|
TenantResource::archiveTenant($record, $auditLogger);
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
|
|||||||
@ -1,8 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Filament\Resources\TenantResource\Pages;
|
namespace App\Filament\Resources\TenantResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\TenantResource;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Onboarding\OnboardingDraftResolver;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
@ -13,10 +19,7 @@ class ListTenants extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\Action::make('add_tenant')
|
$this->makeOnboardingEntryAction()
|
||||||
->label('Add tenant')
|
|
||||||
->icon('heroicon-m-plus')
|
|
||||||
->url(route('admin.onboarding'))
|
|
||||||
->visible(fn (): bool => $this->getTableRecords()->count() > 0),
|
->visible(fn (): bool => $this->getTableRecords()->count() > 0),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -24,10 +27,40 @@ protected function getHeaderActions(): array
|
|||||||
protected function getTableEmptyStateActions(): array
|
protected function getTableEmptyStateActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\Action::make('add_tenant')
|
$this->makeOnboardingEntryAction(),
|
||||||
->label('Add tenant')
|
|
||||||
->icon('heroicon-m-plus')
|
|
||||||
->url(route('admin.onboarding')),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function makeOnboardingEntryAction(): Actions\Action
|
||||||
|
{
|
||||||
|
$descriptor = TenantResource::tenantActionPolicy()->onboardingEntryDescriptor($this->accessibleResumableDraftCount());
|
||||||
|
|
||||||
|
return Actions\Action::make('add_tenant')
|
||||||
|
->label($descriptor->label)
|
||||||
|
->icon($descriptor->icon)
|
||||||
|
->url(route('admin.onboarding'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function accessibleResumableDraftCount(): int
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
if (! is_int($workspaceId)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(OnboardingDraftResolver::class)->resumableDraftsFor($user, $workspace)->count();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
use App\Jobs\RefreshTenantRbacHealthJob;
|
use App\Jobs\RefreshTenantRbacHealthJob;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Verification\StartVerification;
|
use App\Services\Verification\StartVerification;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
@ -20,6 +20,7 @@
|
|||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\Tenants\TenantActionSurface;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
@ -63,8 +64,13 @@ protected function getHeaderActions(): array
|
|||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->apply(),
|
->apply(),
|
||||||
|
Actions\Action::make('related_onboarding')
|
||||||
|
->label(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantViewHeader) ?? 'View related onboarding')
|
||||||
|
->icon(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantViewHeader)?->icon ?? 'heroicon-o-eye')
|
||||||
|
->url(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
||||||
|
->visible(fn (Tenant $record): bool => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantViewHeader) instanceof \App\Support\Tenants\TenantActionDescriptor),
|
||||||
Actions\Action::make('admin_consent')
|
Actions\Action::make('admin_consent')
|
||||||
->label('Admin consent')
|
->label('Grant admin consent')
|
||||||
->icon('heroicon-o-clipboard-document')
|
->icon('heroicon-o-clipboard-document')
|
||||||
->url(fn (Tenant $record) => TenantResource::adminConsentUrl($record))
|
->url(fn (Tenant $record) => TenantResource::adminConsentUrl($record))
|
||||||
->visible(fn (Tenant $record) => TenantResource::adminConsentUrl($record) !== null)
|
->visible(fn (Tenant $record) => TenantResource::adminConsentUrl($record) !== null)
|
||||||
@ -81,7 +87,7 @@ protected function getHeaderActions(): array
|
|||||||
->icon('heroicon-o-check-badge')
|
->icon('heroicon-o-check-badge')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (Tenant $record): bool => $record->isActive())
|
->visible(fn (Tenant $record): bool => TenantResource::verificationActionVisible($record))
|
||||||
->action(function (
|
->action(function (
|
||||||
Tenant $record,
|
Tenant $record,
|
||||||
StartVerification $verification,
|
StartVerification $verification,
|
||||||
@ -172,9 +178,12 @@ protected function getHeaderActions(): array
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||||
|
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Verification blocked')
|
->title('Verification blocked')
|
||||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
->body(implode("\n", $bodyLines))
|
||||||
->warning()
|
->warning()
|
||||||
->actions($actions)
|
->actions($actions)
|
||||||
->send();
|
->send();
|
||||||
@ -264,34 +273,34 @@ protected function getHeaderActions(): array
|
|||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||||
->apply(),
|
->apply(),
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('restore')
|
||||||
|
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->label ?? 'Restore')
|
||||||
|
->color('success')
|
||||||
|
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->icon ?? 'heroicon-o-arrow-uturn-left')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->modalHeading ?? 'Restore tenant')
|
||||||
|
->modalDescription(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.')
|
||||||
|
->visible(fn (Tenant $record): bool => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->key === 'restore')
|
||||||
|
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||||
|
TenantResource::restoreTenant($record, $auditLogger);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
|
->destructive()
|
||||||
|
->apply(),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('archive')
|
Actions\Action::make('archive')
|
||||||
->label('Deactivate')
|
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->label ?? 'Archive')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->icon ?? 'heroicon-o-archive-box-x-mark')
|
||||||
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
->requiresConfirmation()
|
||||||
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->modalHeading ?? 'Archive tenant')
|
||||||
$record->delete();
|
->modalDescription(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.')
|
||||||
|
->visible(fn (Tenant $record): bool => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->key === 'archive')
|
||||||
$auditLogger->log(
|
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||||
tenant: $record,
|
TenantResource::archiveTenant($record, $auditLogger);
|
||||||
action: 'tenant.archived',
|
|
||||||
resourceType: 'tenant',
|
|
||||||
resourceId: (string) $record->getKey(),
|
|
||||||
status: 'success',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'internal_tenant_id' => (int) $record->getKey(),
|
|
||||||
'tenant_guid' => (string) $record->tenant_id,
|
|
||||||
],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Tenant deactivated')
|
|
||||||
->body('The tenant has been archived and hidden from lists.')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
|
|||||||
@ -24,6 +24,8 @@ public function table(Table $table): Table
|
|||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
|
->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
|
||||||
|
->defaultSort('created_at', 'desc')
|
||||||
|
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('user.email')
|
Tables\Columns\TextColumn::make('user.email')
|
||||||
->label(__('User'))
|
->label(__('User'))
|
||||||
@ -48,7 +50,7 @@ public function table(Table $table): Table
|
|||||||
Tables\Columns\TextColumn::make('source')
|
Tables\Columns\TextColumn::make('source')
|
||||||
->badge()
|
->badge()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('created_at')->since(),
|
Tables\Columns\TextColumn::make('created_at')->since()->sortable(),
|
||||||
])
|
])
|
||||||
->headerActions([
|
->headerActions([
|
||||||
UiEnforcement::forTableAction(
|
UiEnforcement::forTableAction(
|
||||||
@ -218,6 +220,8 @@ public function table(Table $table): Table
|
|||||||
->destructive()
|
->destructive()
|
||||||
->apply(),
|
->apply(),
|
||||||
])
|
])
|
||||||
->bulkActions([]);
|
->bulkActions([])
|
||||||
|
->emptyStateHeading(__('No tenant members'))
|
||||||
|
->emptyStateDescription(__('Add a member to delegate access inside this tenant.'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
622
app/Filament/Resources/TenantReviewResource.php
Normal file
622
app/Filament/Resources/TenantReviewResource.php
Normal file
@ -0,0 +1,622 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||||
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
|
use App\Filament\Resources\TenantReviewResource\Pages;
|
||||||
|
use App\Models\EvidenceSnapshot;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantReview;
|
||||||
|
use App\Models\TenantReviewSection;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\ReviewPackService;
|
||||||
|
use App\Services\TenantReviews\TenantReviewService;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\TenantReviewCompletenessState;
|
||||||
|
use App\Support\TenantReviewStatus;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Infolists\Components\RepeatableEntry;
|
||||||
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Enums\TextSize;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class TenantReviewResource extends Resource
|
||||||
|
{
|
||||||
|
use InteractsWithTenantOwnedRecords;
|
||||||
|
use ResolvesPanelTenantContext;
|
||||||
|
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
|
protected static ?string $model = TenantReview::class;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'reviews';
|
||||||
|
|
||||||
|
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||||
|
|
||||||
|
protected static bool $isGloballySearchable = false;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-magnifying-glass';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Reporting';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Reviews';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 45;
|
||||||
|
|
||||||
|
public static function shouldRegisterNavigation(): bool
|
||||||
|
{
|
||||||
|
return Filament::getCurrentPanel()?->getId() === 'tenant';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canViewAny(): bool
|
||||||
|
{
|
||||||
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->can(Capabilities::TENANT_REVIEW_VIEW, $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canView(Model $record): bool
|
||||||
|
{
|
||||||
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User || ! $record instanceof TenantReview) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->can('view', $record);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Create review is available from the review library header.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes exactly one Create first review CTA.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Tenant reviews do not expose bulk actions in the first slice.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Primary row actions stay limited to View review and Export executive pack.')
|
||||||
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail exposes Refresh review, Publish review, Export executive pack, Archive review, and Create next review as applicable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
return static::getTenantOwnedEloquentQuery()
|
||||||
|
->with(['tenant', 'evidenceSnapshot', 'operationRun', 'initiator', 'publisher', 'currentExportReviewPack', 'sections'])
|
||||||
|
->latest('generated_at')
|
||||||
|
->latest('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function resolveScopedRecordOrFail(int|string|null $record): Model
|
||||||
|
{
|
||||||
|
return static::resolveTenantOwnedRecordOrFail($record);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function infolist(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema->schema([
|
||||||
|
Section::make('Artifact truth')
|
||||||
|
->schema([
|
||||||
|
ViewEntry::make('artifact_truth')
|
||||||
|
->hiddenLabel()
|
||||||
|
->view('filament.infolists.entries.governance-artifact-truth')
|
||||||
|
->state(fn (TenantReview $record): array => static::truthEnvelope($record)->toArray())
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
|
Section::make('Review')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('status')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
|
||||||
|
TextEntry::make('completeness_state')
|
||||||
|
->label('Completeness')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
||||||
|
TextEntry::make('tenant.name')->label('Tenant'),
|
||||||
|
TextEntry::make('generated_at')->dateTime()->placeholder('—'),
|
||||||
|
TextEntry::make('published_at')->dateTime()->placeholder('—'),
|
||||||
|
TextEntry::make('evidenceSnapshot.id')
|
||||||
|
->label('Evidence snapshot')
|
||||||
|
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||||
|
->url(fn (TenantReview $record): ?string => $record->evidenceSnapshot
|
||||||
|
? EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
|
||||||
|
: null),
|
||||||
|
TextEntry::make('currentExportReviewPack.id')
|
||||||
|
->label('Current export')
|
||||||
|
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||||
|
->url(fn (TenantReview $record): ?string => $record->currentExportReviewPack
|
||||||
|
? ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant)
|
||||||
|
: null),
|
||||||
|
TextEntry::make('fingerprint')
|
||||||
|
->copyable()
|
||||||
|
->placeholder('—')
|
||||||
|
->columnSpanFull()
|
||||||
|
->fontFamily('mono')
|
||||||
|
->size(TextSize::ExtraSmall),
|
||||||
|
])
|
||||||
|
->columns(2)
|
||||||
|
->columnSpanFull(),
|
||||||
|
Section::make('Executive posture')
|
||||||
|
->schema([
|
||||||
|
ViewEntry::make('review_summary')
|
||||||
|
->hiddenLabel()
|
||||||
|
->view('filament.infolists.entries.tenant-review-summary')
|
||||||
|
->state(fn (TenantReview $record): array => static::summaryPresentation($record))
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
|
Section::make('Sections')
|
||||||
|
->schema([
|
||||||
|
RepeatableEntry::make('sections')
|
||||||
|
->hiddenLabel()
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('title'),
|
||||||
|
TextEntry::make('completeness_state')
|
||||||
|
->label('Completeness')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
||||||
|
TextEntry::make('measured_at')->dateTime()->placeholder('—'),
|
||||||
|
Section::make('Details')
|
||||||
|
->schema([
|
||||||
|
ViewEntry::make('section_payload')
|
||||||
|
->hiddenLabel()
|
||||||
|
->view('filament.infolists.entries.tenant-review-section')
|
||||||
|
->state(fn (TenantReviewSection $record): array => static::sectionPresentation($record))
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->collapsible()
|
||||||
|
->collapsed()
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columns(3),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->defaultSort('generated_at', 'desc')
|
||||||
|
->persistFiltersInSession()
|
||||||
|
->persistSearchInSession()
|
||||||
|
->persistSortInSession()
|
||||||
|
->recordUrl(fn (TenantReview $record): string => static::tenantScopedUrl('view', ['record' => $record], $record->tenant))
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('status')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('artifact_truth')
|
||||||
|
->label('Artifact truth')
|
||||||
|
->badge()
|
||||||
|
->getStateUsing(fn (TenantReview $record): string => static::truthEnvelope($record)->primaryLabel)
|
||||||
|
->color(fn (TenantReview $record): string => static::truthEnvelope($record)->primaryBadgeSpec()->color)
|
||||||
|
->icon(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
||||||
|
->iconColor(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
||||||
|
->description(fn (TenantReview $record): ?string => static::truthEnvelope($record)->operatorExplanation?->headline ?? static::truthEnvelope($record)->primaryExplanation)
|
||||||
|
->wrap(),
|
||||||
|
Tables\Columns\TextColumn::make('completeness_state')
|
||||||
|
->label('Completeness')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness))
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
|
||||||
|
Tables\Columns\TextColumn::make('summary.section_state_counts.missing')->label(static::reviewCompletenessCountLabel(TenantReviewCompletenessState::Missing->value)),
|
||||||
|
Tables\Columns\TextColumn::make('publication_truth')
|
||||||
|
->label('Publication')
|
||||||
|
->badge()
|
||||||
|
->getStateUsing(fn (TenantReview $record): string => BadgeCatalog::spec(
|
||||||
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
|
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
||||||
|
)->label)
|
||||||
|
->color(fn (TenantReview $record): string => BadgeCatalog::spec(
|
||||||
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
|
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
||||||
|
)->color)
|
||||||
|
->icon(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
||||||
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
|
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
||||||
|
)->icon)
|
||||||
|
->iconColor(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
||||||
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
|
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
||||||
|
)->iconColor),
|
||||||
|
Tables\Columns\IconColumn::make('summary.has_ready_export')
|
||||||
|
->label('Export')
|
||||||
|
->boolean(),
|
||||||
|
Tables\Columns\TextColumn::make('artifact_next_step')
|
||||||
|
->label('Next step')
|
||||||
|
->getStateUsing(fn (TenantReview $record): string => static::truthEnvelope($record)->operatorExplanation?->nextActionText ?? static::truthEnvelope($record)->nextStepText())
|
||||||
|
->wrap(),
|
||||||
|
Tables\Columns\TextColumn::make('fingerprint')
|
||||||
|
->toggleable(isToggledHiddenByDefault: true)
|
||||||
|
->searchable(),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
Tables\Filters\SelectFilter::make('status')
|
||||||
|
->options(collect(TenantReviewStatus::cases())
|
||||||
|
->mapWithKeys(fn (TenantReviewStatus $status): array => [$status->value => Str::headline($status->value)])
|
||||||
|
->all()),
|
||||||
|
Tables\Filters\SelectFilter::make('completeness_state')
|
||||||
|
->options(BadgeCatalog::options(BadgeDomain::TenantReviewCompleteness, TenantReviewCompletenessState::values())),
|
||||||
|
\App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_review')
|
||||||
|
->label('View review')
|
||||||
|
->url(fn (TenantReview $record): string => static::tenantScopedUrl('view', ['record' => $record], $record->tenant)),
|
||||||
|
UiEnforcement::forTableAction(
|
||||||
|
Actions\Action::make('export_executive_pack')
|
||||||
|
->label('Export executive pack')
|
||||||
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
|
->visible(fn (TenantReview $record): bool => in_array($record->status, [
|
||||||
|
TenantReviewStatus::Ready->value,
|
||||||
|
TenantReviewStatus::Published->value,
|
||||||
|
], true))
|
||||||
|
->action(fn (TenantReview $record): mixed => static::executeExport($record)),
|
||||||
|
fn (TenantReview $record): TenantReview => $record,
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
|
])
|
||||||
|
->bulkActions([])
|
||||||
|
->emptyStateHeading('No tenant reviews yet')
|
||||||
|
->emptyStateDescription('Create the first review from an anchored evidence snapshot to start the recurring review history for this tenant.')
|
||||||
|
->emptyStateActions([
|
||||||
|
static::makeCreateReviewAction(
|
||||||
|
name: 'create_first_review',
|
||||||
|
label: 'Create first review',
|
||||||
|
icon: 'heroicon-o-plus',
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListTenantReviews::route('/'),
|
||||||
|
'view' => Pages\ViewTenantReview::route('/{record}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function makeCreateReviewAction(
|
||||||
|
string $name = 'create_review',
|
||||||
|
string $label = 'Create review',
|
||||||
|
string $icon = 'heroicon-o-plus',
|
||||||
|
): Actions\Action {
|
||||||
|
return UiEnforcement::forAction(
|
||||||
|
Actions\Action::make($name)
|
||||||
|
->label($label)
|
||||||
|
->icon($icon)
|
||||||
|
->form([
|
||||||
|
Section::make('Evidence basis')
|
||||||
|
->schema([
|
||||||
|
Select::make('evidence_snapshot_id')
|
||||||
|
->label('Evidence snapshot')
|
||||||
|
->required()
|
||||||
|
->options(fn (): array => static::evidenceSnapshotOptions())
|
||||||
|
->searchable()
|
||||||
|
->helperText('Choose the anchored evidence snapshot for this review.'),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->action(fn (array $data): mixed => static::executeCreateReview($data)),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||||
|
->apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public static function executeCreateReview(array $data): void
|
||||||
|
{
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
Notification::make()->danger()->title('Unable to create review — missing context.')->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->can(Capabilities::TENANT_REVIEW_MANAGE, $tenant)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$snapshotId = $data['evidence_snapshot_id'] ?? null;
|
||||||
|
$snapshot = is_numeric($snapshotId)
|
||||||
|
? EvidenceSnapshot::query()
|
||||||
|
->whereKey((int) $snapshotId)
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->first()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (! $snapshot instanceof EvidenceSnapshot) {
|
||||||
|
Notification::make()->danger()->title('Select a valid evidence snapshot.')->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$review = app(TenantReviewService::class)->create($tenant, $snapshot, $user);
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
Notification::make()->danger()->title('Unable to create review')->body($throwable->getMessage())->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
static::truthEnvelope($review->refresh(), fresh: true);
|
||||||
|
|
||||||
|
if (! $review->wasRecentlyCreated) {
|
||||||
|
Notification::make()
|
||||||
|
->success()
|
||||||
|
->title('Review already available')
|
||||||
|
->body('A matching mutable review already exists for this evidence basis.')
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_review')
|
||||||
|
->label('View review')
|
||||||
|
->url(static::tenantScopedUrl('view', ['record' => $review], $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$toast = OperationUxPresenter::queuedToast(OperationRunType::TenantReviewCompose->value)
|
||||||
|
->body('The review is being composed in the background.');
|
||||||
|
|
||||||
|
if ($review->operation_run_id) {
|
||||||
|
$toast->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::tenantlessView((int) $review->operation_run_id)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$toast->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function executeExport(TenantReview $review): void
|
||||||
|
{
|
||||||
|
$review->loadMissing(['tenant', 'currentExportReviewPack']);
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $review->tenant instanceof Tenant) {
|
||||||
|
Notification::make()->danger()->title('Unable to export review — missing context.')->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($review->tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->can('export', $review)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = app(ReviewPackService::class);
|
||||||
|
|
||||||
|
if ($service->checkActiveRunForReview($review)) {
|
||||||
|
OperationUxPresenter::alreadyQueuedToast(OperationRunType::ReviewPackGenerate->value)
|
||||||
|
->body('An executive pack export is already queued or running for this review.')
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pack = $service->generateFromReview($review, $user, [
|
||||||
|
'include_pii' => true,
|
||||||
|
'include_operations' => true,
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
Notification::make()->danger()->title('Unable to export executive pack')->body($throwable->getMessage())->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
static::truthEnvelope($review->refresh(), fresh: true);
|
||||||
|
app(ArtifactTruthPresenter::class)->forReviewPackFresh($pack->refresh());
|
||||||
|
|
||||||
|
if (! $pack->wasRecentlyCreated) {
|
||||||
|
Notification::make()
|
||||||
|
->success()
|
||||||
|
->title('Executive pack already available')
|
||||||
|
->body('A matching executive pack already exists for this review.')
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_pack')
|
||||||
|
->label('View pack')
|
||||||
|
->url(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $review->tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
OperationUxPresenter::queuedToast(OperationRunType::ReviewPackGenerate->value)
|
||||||
|
->body('The executive pack is being generated in the background.')
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $parameters
|
||||||
|
*/
|
||||||
|
public static function tenantScopedUrl(
|
||||||
|
string $page = 'index',
|
||||||
|
array $parameters = [],
|
||||||
|
?Tenant $tenant = null,
|
||||||
|
?string $panel = null,
|
||||||
|
): string {
|
||||||
|
$panelId = $panel ?? 'tenant';
|
||||||
|
|
||||||
|
return static::getUrl($page, $parameters, panel: $panelId, tenant: $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private static function evidenceSnapshotOptions(): array
|
||||||
|
{
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return EvidenceSnapshot::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->whereNotNull('generated_at')
|
||||||
|
->orderByDesc('generated_at')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->get()
|
||||||
|
->mapWithKeys(static fn (EvidenceSnapshot $snapshot): array => [
|
||||||
|
(string) $snapshot->getKey() => sprintf(
|
||||||
|
'#%d · %s · %s',
|
||||||
|
(int) $snapshot->getKey(),
|
||||||
|
BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, $snapshot->completeness_state)->label,
|
||||||
|
$snapshot->generated_at?->format('Y-m-d H:i') ?? 'Pending'
|
||||||
|
),
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function reviewCompletenessCountLabel(string $state): string
|
||||||
|
{
|
||||||
|
return BadgeCatalog::spec(BadgeDomain::TenantReviewCompleteness, $state)->label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function summaryPresentation(TenantReview $record): array
|
||||||
|
{
|
||||||
|
$summary = is_array($record->summary) ? $record->summary : [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'operator_explanation' => static::truthEnvelope($record)->operatorExplanation?->toArray(),
|
||||||
|
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [],
|
||||||
|
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
|
||||||
|
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
|
||||||
|
'metrics' => [
|
||||||
|
['label' => 'Findings', 'value' => (string) ($summary['finding_count'] ?? 0)],
|
||||||
|
['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)],
|
||||||
|
['label' => 'Operations', 'value' => (string) ($summary['operation_count'] ?? 0)],
|
||||||
|
['label' => 'Sections', 'value' => (string) ($summary['section_count'] ?? 0)],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function sectionPresentation(TenantReviewSection $section): array
|
||||||
|
{
|
||||||
|
$summary = is_array($section->summary_payload) ? $section->summary_payload : [];
|
||||||
|
$render = is_array($section->render_payload) ? $section->render_payload : [];
|
||||||
|
$review = $section->tenantReview;
|
||||||
|
$tenant = $section->tenant;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'summary' => collect($summary)->map(function (mixed $value, string $key): ?array {
|
||||||
|
if (is_array($value) || $value === null || $value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'label' => Str::headline($key),
|
||||||
|
'value' => (string) $value,
|
||||||
|
];
|
||||||
|
})->filter()->values()->all(),
|
||||||
|
'highlights' => is_array($render['highlights'] ?? null) ? $render['highlights'] : [],
|
||||||
|
'entries' => is_array($render['entries'] ?? null) ? $render['entries'] : [],
|
||||||
|
'disclosure' => is_string($render['disclosure'] ?? null) ? $render['disclosure'] : null,
|
||||||
|
'next_actions' => is_array($render['next_actions'] ?? null) ? $render['next_actions'] : [],
|
||||||
|
'empty_state' => is_string($render['empty_state'] ?? null) ? $render['empty_state'] : null,
|
||||||
|
'links' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function truthEnvelope(TenantReview $record, bool $fresh = false): ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? $presenter->forTenantReviewFresh($record)
|
||||||
|
: $presenter->forTenantReview($record);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user